Compare commits
44 Commits
feature/en
...
93a0edb0a2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93a0edb0a2 | ||
|
|
7d3af2bcec | ||
|
|
c03034c49f | ||
|
|
9234904e47 | ||
|
|
e36a9a781a | ||
|
|
d036e25fef | ||
|
|
fcdd2b6de7 | ||
|
|
3939ea773f | ||
|
|
4b4afe277e | ||
|
|
364e6e21dd | ||
|
|
9d352ab8ec | ||
|
|
d1d4ac745f | ||
|
|
3db466f5a2 | ||
|
|
7c6ae7ab7e | ||
|
|
ed7ceedef5 | ||
|
|
40815d3576 | ||
|
|
a17c08c890 | ||
|
|
4f31fadc6c | ||
|
|
29c0981a59 | ||
|
|
9d402fcd92 | ||
|
|
9530c8284f | ||
|
|
bb5aa2e6d6 | ||
|
|
cc7beba9de | ||
|
|
00eca6d8b2 | ||
|
|
32235f9647 | ||
|
|
71d59e7367 | ||
|
|
99c3ac905f | ||
|
|
0761c424c1 | ||
|
|
2a32d75895 | ||
|
|
4c1df53fd5 | ||
|
|
acab15ce30 | ||
|
|
0361bfe470 | ||
|
|
5b9d15584a | ||
|
|
898fd69a5d | ||
|
|
9cf01f7c7a | ||
|
|
ab362aabac | ||
|
|
283feea175 | ||
|
|
7af8624bf8 | ||
|
|
f8543f98cc | ||
|
|
6be23c2c37 | ||
|
|
eb2d7379bf | ||
|
|
67d86e5835 | ||
|
|
d6029556d3 | ||
|
|
7796c38c08 |
18
.env.example
18
.env.example
@@ -1,5 +1,21 @@
|
|||||||
# Rails Configuration
|
# Rails Configuration
|
||||||
SECRET_KEY_BASE=generate-with-bin-rails-secret
|
# SECRET_KEY_BASE is used for:
|
||||||
|
# - Session cookie encryption
|
||||||
|
# - Signed token verification
|
||||||
|
# - ActiveRecord encryption (currently: TOTP secrets)
|
||||||
|
# - OIDC token prefix HMAC derivation
|
||||||
|
#
|
||||||
|
# CRITICAL: Do NOT change SECRET_KEY_BASE after deployment. Changing it will:
|
||||||
|
# - Invalidate all user sessions (users must re-login)
|
||||||
|
# - Break encrypted data (users must re-setup 2FA)
|
||||||
|
# - Invalidate all OIDC access/refresh tokens (clients must re-authenticate)
|
||||||
|
#
|
||||||
|
# Optional: Override encryption keys with env vars for key rotation:
|
||||||
|
# - ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY
|
||||||
|
# - ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY
|
||||||
|
# - ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT
|
||||||
|
# - OIDC_TOKEN_PREFIX_HMAC
|
||||||
|
SECRET_KEY_BASE=generate-with-bin/rails/secret
|
||||||
RAILS_ENV=development
|
RAILS_ENV=development
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
|
|||||||
14
.github/workflows/ci.yml
vendored
14
.github/workflows/ci.yml
vendored
@@ -41,8 +41,6 @@ jobs:
|
|||||||
|
|
||||||
lint:
|
lint:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
|
||||||
RUBOCOP_CACHE_ROOT: tmp/rubocop
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v5
|
||||||
@@ -52,18 +50,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
bundler-cache: true
|
bundler-cache: true
|
||||||
|
|
||||||
- name: Prepare RuboCop cache
|
|
||||||
uses: actions/cache@v4
|
|
||||||
env:
|
|
||||||
DEPENDENCIES_HASH: ${{ hashFiles('.ruby-version', '**/.rubocop.yml', '**/.rubocop_todo.yml', 'Gemfile.lock') }}
|
|
||||||
with:
|
|
||||||
path: ${{ env.RUBOCOP_CACHE_ROOT }}
|
|
||||||
key: rubocop-${{ runner.os }}-${{ env.DEPENDENCIES_HASH }}-${{ github.ref_name == github.event.repository.default_branch && github.run_id || 'default' }}
|
|
||||||
restore-keys: |
|
|
||||||
rubocop-${{ runner.os }}-${{ env.DEPENDENCIES_HASH }}-
|
|
||||||
|
|
||||||
- name: Lint code for consistent style
|
- name: Lint code for consistent style
|
||||||
run: bin/rubocop -f github
|
run: bin/standardrb
|
||||||
|
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
7
.standard.yml
Normal file
7
.standard.yml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
ignore:
|
||||||
|
- 'test_*.rb' # Ignore test files in root directory
|
||||||
|
- 'tmp/**/*'
|
||||||
|
- 'vendor/**/*'
|
||||||
|
- 'node_modules/**/*'
|
||||||
|
- 'config/initializers/csp_local_logger.rb' # Complex CSP logger with intentional block structure
|
||||||
|
- 'config/initializers/sentry_subscriber.rb' # Sentry subscriber with module structure
|
||||||
@@ -11,6 +11,8 @@
|
|||||||
ARG RUBY_VERSION=3.4.6
|
ARG RUBY_VERSION=3.4.6
|
||||||
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
|
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
|
||||||
|
|
||||||
|
LABEL org.opencontainers.image.source=https://github.com/dkam/clinch
|
||||||
|
|
||||||
# Rails app lives here
|
# Rails app lives here
|
||||||
WORKDIR /rails
|
WORKDIR /rails
|
||||||
|
|
||||||
|
|||||||
18
Gemfile
18
Gemfile
@@ -35,18 +35,19 @@ gem "jwt", "~> 3.1"
|
|||||||
gem "webauthn", "~> 3.0"
|
gem "webauthn", "~> 3.0"
|
||||||
|
|
||||||
# Public Suffix List for domain parsing
|
# 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)
|
# Error tracking and performance monitoring (optional, configured via SENTRY_DSN)
|
||||||
gem "sentry-ruby", "~> 5.18"
|
gem "sentry-ruby", "~> 6.2"
|
||||||
gem "sentry-rails", "~> 5.18"
|
gem "sentry-rails", "~> 6.2"
|
||||||
|
|
||||||
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
|
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
|
||||||
gem "tzinfo-data", platforms: %i[ windows jruby ]
|
gem "tzinfo-data", platforms: %i[windows jruby]
|
||||||
|
|
||||||
# Use the database-backed adapters for Rails.cache and Action Cable
|
# Use the database-backed adapters for Rails.cache and Action Cable
|
||||||
gem "solid_cache"
|
gem "solid_cache"
|
||||||
gem "solid_cable"
|
gem "solid_cable"
|
||||||
|
gem "solid_queue", "~> 1.2"
|
||||||
|
|
||||||
# Reduces boot times through caching; required in config/boot.rb
|
# Reduces boot times through caching; required in config/boot.rb
|
||||||
gem "bootsnap", require: false
|
gem "bootsnap", require: false
|
||||||
@@ -62,7 +63,7 @@ gem "image_processing", "~> 1.2"
|
|||||||
|
|
||||||
group :development, :test do
|
group :development, :test do
|
||||||
# See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
|
# See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
|
||||||
gem "debug", platforms: %i[ mri windows ], require: "debug/prelude"
|
gem "debug", platforms: %i[mri windows], require: "debug/prelude"
|
||||||
|
|
||||||
# Audits gems for known security defects (use config/bundler-audit.yml to ignore issues)
|
# Audits gems for known security defects (use config/bundler-audit.yml to ignore issues)
|
||||||
gem "bundler-audit", require: false
|
gem "bundler-audit", require: false
|
||||||
@@ -70,8 +71,8 @@ group :development, :test do
|
|||||||
# Static analysis for security vulnerabilities [https://brakemanscanner.org/]
|
# Static analysis for security vulnerabilities [https://brakemanscanner.org/]
|
||||||
gem "brakeman", require: false
|
gem "brakeman", require: false
|
||||||
|
|
||||||
# Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/]
|
# Standard Ruby style guide, linter, and formatter [https://github.com/standardrb/standard]
|
||||||
gem "rubocop-rails-omakase", require: false
|
gem "standard", require: false
|
||||||
end
|
end
|
||||||
|
|
||||||
group :development do
|
group :development do
|
||||||
@@ -86,4 +87,7 @@ group :test do
|
|||||||
# Use system testing [https://guides.rubyonrails.org/testing.html#system-testing]
|
# Use system testing [https://guides.rubyonrails.org/testing.html#system-testing]
|
||||||
gem "capybara"
|
gem "capybara"
|
||||||
gem "selenium-webdriver"
|
gem "selenium-webdriver"
|
||||||
|
|
||||||
|
# Code coverage analysis
|
||||||
|
gem "simplecov", require: false
|
||||||
end
|
end
|
||||||
|
|||||||
138
Gemfile.lock
138
Gemfile.lock
@@ -75,8 +75,8 @@ GEM
|
|||||||
securerandom (>= 0.3)
|
securerandom (>= 0.3)
|
||||||
tzinfo (~> 2.0, >= 2.0.5)
|
tzinfo (~> 2.0, >= 2.0.5)
|
||||||
uri (>= 0.13.1)
|
uri (>= 0.13.1)
|
||||||
addressable (2.8.7)
|
addressable (2.8.8)
|
||||||
public_suffix (>= 2.0.2, < 7.0)
|
public_suffix (>= 2.0.2, < 8.0)
|
||||||
android_key_attestation (0.3.0)
|
android_key_attestation (0.3.0)
|
||||||
ast (2.4.3)
|
ast (2.4.3)
|
||||||
base64 (0.3.0)
|
base64 (0.3.0)
|
||||||
@@ -85,13 +85,13 @@ GEM
|
|||||||
bigdecimal (3.3.1)
|
bigdecimal (3.3.1)
|
||||||
bindata (2.5.1)
|
bindata (2.5.1)
|
||||||
bindex (0.8.1)
|
bindex (0.8.1)
|
||||||
bootsnap (1.18.6)
|
bootsnap (1.19.0)
|
||||||
msgpack (~> 1.2)
|
msgpack (~> 1.2)
|
||||||
brakeman (7.1.0)
|
brakeman (7.1.1)
|
||||||
racc
|
racc
|
||||||
builder (3.3.0)
|
builder (3.3.0)
|
||||||
bundler-audit (0.9.2)
|
bundler-audit (0.9.3)
|
||||||
bundler (>= 1.2.0, < 3)
|
bundler (>= 1.2.0)
|
||||||
thor (~> 1.0)
|
thor (~> 1.0)
|
||||||
capybara (3.40.0)
|
capybara (3.40.0)
|
||||||
addressable
|
addressable
|
||||||
@@ -107,7 +107,7 @@ GEM
|
|||||||
logger (~> 1.5)
|
logger (~> 1.5)
|
||||||
chunky_png (1.4.0)
|
chunky_png (1.4.0)
|
||||||
concurrent-ruby (1.3.5)
|
concurrent-ruby (1.3.5)
|
||||||
connection_pool (2.5.4)
|
connection_pool (2.5.5)
|
||||||
cose (1.3.1)
|
cose (1.3.1)
|
||||||
cbor (~> 0.5.9)
|
cbor (~> 0.5.9)
|
||||||
openssl-signature_algorithm (~> 1.0)
|
openssl-signature_algorithm (~> 1.0)
|
||||||
@@ -116,11 +116,14 @@ GEM
|
|||||||
debug (1.11.0)
|
debug (1.11.0)
|
||||||
irb (~> 1.10)
|
irb (~> 1.10)
|
||||||
reline (>= 0.3.8)
|
reline (>= 0.3.8)
|
||||||
|
docile (1.4.1)
|
||||||
dotenv (3.1.8)
|
dotenv (3.1.8)
|
||||||
drb (2.2.3)
|
drb (2.2.3)
|
||||||
ed25519 (1.4.0)
|
ed25519 (1.4.0)
|
||||||
erb (5.1.3)
|
erb (6.0.0)
|
||||||
erubi (1.13.1)
|
erubi (1.13.1)
|
||||||
|
et-orbi (1.4.0)
|
||||||
|
tzinfo
|
||||||
ffi (1.17.2-aarch64-linux-gnu)
|
ffi (1.17.2-aarch64-linux-gnu)
|
||||||
ffi (1.17.2-aarch64-linux-musl)
|
ffi (1.17.2-aarch64-linux-musl)
|
||||||
ffi (1.17.2-arm-linux-gnu)
|
ffi (1.17.2-arm-linux-gnu)
|
||||||
@@ -128,6 +131,9 @@ GEM
|
|||||||
ffi (1.17.2-arm64-darwin)
|
ffi (1.17.2-arm64-darwin)
|
||||||
ffi (1.17.2-x86_64-linux-gnu)
|
ffi (1.17.2-x86_64-linux-gnu)
|
||||||
ffi (1.17.2-x86_64-linux-musl)
|
ffi (1.17.2-x86_64-linux-musl)
|
||||||
|
fugit (1.12.1)
|
||||||
|
et-orbi (~> 1.4)
|
||||||
|
raabro (~> 1.4)
|
||||||
globalid (1.3.0)
|
globalid (1.3.0)
|
||||||
activesupport (>= 6.1)
|
activesupport (>= 6.1)
|
||||||
i18n (1.14.7)
|
i18n (1.14.7)
|
||||||
@@ -147,10 +153,10 @@ GEM
|
|||||||
jbuilder (2.14.1)
|
jbuilder (2.14.1)
|
||||||
actionview (>= 7.0.0)
|
actionview (>= 7.0.0)
|
||||||
activesupport (>= 7.0.0)
|
activesupport (>= 7.0.0)
|
||||||
json (2.15.2)
|
json (2.16.0)
|
||||||
jwt (3.1.2)
|
jwt (3.1.2)
|
||||||
base64
|
base64
|
||||||
kamal (2.8.1)
|
kamal (2.9.0)
|
||||||
activesupport (>= 7.0)
|
activesupport (>= 7.0)
|
||||||
base64 (~> 0.2)
|
base64 (~> 0.2)
|
||||||
bcrypt_pbkdf (~> 1.0)
|
bcrypt_pbkdf (~> 1.0)
|
||||||
@@ -184,7 +190,7 @@ GEM
|
|||||||
mini_magick (5.3.1)
|
mini_magick (5.3.1)
|
||||||
logger
|
logger
|
||||||
mini_mime (1.1.5)
|
mini_mime (1.1.5)
|
||||||
minitest (5.26.0)
|
minitest (5.26.2)
|
||||||
msgpack (1.8.0)
|
msgpack (1.8.0)
|
||||||
net-imap (0.5.12)
|
net-imap (0.5.12)
|
||||||
date
|
date
|
||||||
@@ -220,7 +226,7 @@ GEM
|
|||||||
openssl (> 2.0)
|
openssl (> 2.0)
|
||||||
ostruct (0.6.3)
|
ostruct (0.6.3)
|
||||||
parallel (1.27.0)
|
parallel (1.27.0)
|
||||||
parser (3.3.9.0)
|
parser (3.3.10.0)
|
||||||
ast (~> 2.4.1)
|
ast (~> 2.4.1)
|
||||||
racc
|
racc
|
||||||
pp (0.6.3)
|
pp (0.6.3)
|
||||||
@@ -234,9 +240,10 @@ GEM
|
|||||||
psych (5.2.6)
|
psych (5.2.6)
|
||||||
date
|
date
|
||||||
stringio
|
stringio
|
||||||
public_suffix (6.0.2)
|
public_suffix (7.0.0)
|
||||||
puma (7.1.0)
|
puma (7.1.0)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
|
raabro (1.4.0)
|
||||||
racc (1.8.1)
|
racc (1.8.1)
|
||||||
rack (3.2.4)
|
rack (3.2.4)
|
||||||
rack-session (2.1.1)
|
rack-session (2.1.1)
|
||||||
@@ -278,20 +285,20 @@ GEM
|
|||||||
zeitwerk (~> 2.6)
|
zeitwerk (~> 2.6)
|
||||||
rainbow (3.1.1)
|
rainbow (3.1.1)
|
||||||
rake (13.3.1)
|
rake (13.3.1)
|
||||||
rdoc (6.15.1)
|
rdoc (6.16.1)
|
||||||
erb
|
erb
|
||||||
psych (>= 4.0.0)
|
psych (>= 4.0.0)
|
||||||
tsort
|
tsort
|
||||||
regexp_parser (2.11.3)
|
regexp_parser (2.11.3)
|
||||||
reline (0.6.2)
|
reline (0.6.3)
|
||||||
io-console (~> 0.5)
|
io-console (~> 0.5)
|
||||||
rexml (3.4.4)
|
rexml (3.4.4)
|
||||||
rotp (6.3.0)
|
rotp (6.3.0)
|
||||||
rqrcode (3.1.0)
|
rqrcode (3.1.1)
|
||||||
chunky_png (~> 1.0)
|
chunky_png (~> 1.0)
|
||||||
rqrcode_core (~> 2.0)
|
rqrcode_core (~> 2.0)
|
||||||
rqrcode_core (2.0.0)
|
rqrcode_core (2.0.1)
|
||||||
rubocop (1.81.6)
|
rubocop (1.81.7)
|
||||||
json (~> 2.3)
|
json (~> 2.3)
|
||||||
language_server-protocol (~> 3.17.0.2)
|
language_server-protocol (~> 3.17.0.2)
|
||||||
lint_roller (~> 1.1.0)
|
lint_roller (~> 1.1.0)
|
||||||
@@ -302,28 +309,18 @@ GEM
|
|||||||
rubocop-ast (>= 1.47.1, < 2.0)
|
rubocop-ast (>= 1.47.1, < 2.0)
|
||||||
ruby-progressbar (~> 1.7)
|
ruby-progressbar (~> 1.7)
|
||||||
unicode-display_width (>= 2.4.0, < 4.0)
|
unicode-display_width (>= 2.4.0, < 4.0)
|
||||||
rubocop-ast (1.47.1)
|
rubocop-ast (1.48.0)
|
||||||
parser (>= 3.3.7.2)
|
parser (>= 3.3.7.2)
|
||||||
prism (~> 1.4)
|
prism (~> 1.4)
|
||||||
rubocop-performance (1.26.1)
|
rubocop-performance (1.26.1)
|
||||||
lint_roller (~> 1.1)
|
lint_roller (~> 1.1)
|
||||||
rubocop (>= 1.75.0, < 2.0)
|
rubocop (>= 1.75.0, < 2.0)
|
||||||
rubocop-ast (>= 1.47.1, < 2.0)
|
rubocop-ast (>= 1.47.1, < 2.0)
|
||||||
rubocop-rails (2.33.4)
|
|
||||||
activesupport (>= 4.2.0)
|
|
||||||
lint_roller (~> 1.1)
|
|
||||||
rack (>= 1.1)
|
|
||||||
rubocop (>= 1.75.0, < 2.0)
|
|
||||||
rubocop-ast (>= 1.44.0, < 2.0)
|
|
||||||
rubocop-rails-omakase (1.1.0)
|
|
||||||
rubocop (>= 1.72)
|
|
||||||
rubocop-performance (>= 1.24)
|
|
||||||
rubocop-rails (>= 2.30)
|
|
||||||
ruby-progressbar (1.13.0)
|
ruby-progressbar (1.13.0)
|
||||||
ruby-vips (2.2.5)
|
ruby-vips (2.2.5)
|
||||||
ffi (~> 1.12)
|
ffi (~> 1.12)
|
||||||
logger
|
logger
|
||||||
rubyzip (3.2.1)
|
rubyzip (3.2.2)
|
||||||
safety_net_attestation (0.5.0)
|
safety_net_attestation (0.5.0)
|
||||||
jwt (>= 2.0, < 4.0)
|
jwt (>= 2.0, < 4.0)
|
||||||
securerandom (0.4.1)
|
securerandom (0.4.1)
|
||||||
@@ -333,28 +330,41 @@ GEM
|
|||||||
rexml (~> 3.2, >= 3.2.5)
|
rexml (~> 3.2, >= 3.2.5)
|
||||||
rubyzip (>= 1.2.2, < 4.0)
|
rubyzip (>= 1.2.2, < 4.0)
|
||||||
websocket (~> 1.0)
|
websocket (~> 1.0)
|
||||||
sentry-rails (5.28.0)
|
sentry-rails (6.2.0)
|
||||||
railties (>= 5.0)
|
railties (>= 5.2.0)
|
||||||
sentry-ruby (~> 5.28.0)
|
sentry-ruby (~> 6.2.0)
|
||||||
sentry-ruby (5.28.0)
|
sentry-ruby (6.2.0)
|
||||||
bigdecimal
|
bigdecimal
|
||||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||||
|
simplecov (0.22.0)
|
||||||
|
docile (~> 1.1)
|
||||||
|
simplecov-html (~> 0.11)
|
||||||
|
simplecov_json_formatter (~> 0.1)
|
||||||
|
simplecov-html (0.13.2)
|
||||||
|
simplecov_json_formatter (0.1.4)
|
||||||
solid_cable (3.0.12)
|
solid_cable (3.0.12)
|
||||||
actioncable (>= 7.2)
|
actioncable (>= 7.2)
|
||||||
activejob (>= 7.2)
|
activejob (>= 7.2)
|
||||||
activerecord (>= 7.2)
|
activerecord (>= 7.2)
|
||||||
railties (>= 7.2)
|
railties (>= 7.2)
|
||||||
solid_cache (1.0.8)
|
solid_cache (1.0.10)
|
||||||
activejob (>= 7.2)
|
activejob (>= 7.2)
|
||||||
activerecord (>= 7.2)
|
activerecord (>= 7.2)
|
||||||
railties (>= 7.2)
|
railties (>= 7.2)
|
||||||
sqlite3 (2.7.4-aarch64-linux-gnu)
|
solid_queue (1.2.4)
|
||||||
sqlite3 (2.7.4-aarch64-linux-musl)
|
activejob (>= 7.1)
|
||||||
sqlite3 (2.7.4-arm-linux-gnu)
|
activerecord (>= 7.1)
|
||||||
sqlite3 (2.7.4-arm-linux-musl)
|
concurrent-ruby (>= 1.3.1)
|
||||||
sqlite3 (2.7.4-arm64-darwin)
|
fugit (~> 1.11)
|
||||||
sqlite3 (2.7.4-x86_64-linux-gnu)
|
railties (>= 7.1)
|
||||||
sqlite3 (2.7.4-x86_64-linux-musl)
|
thor (>= 1.3.1)
|
||||||
|
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)
|
sshkit (1.24.0)
|
||||||
base64
|
base64
|
||||||
logger
|
logger
|
||||||
@@ -362,18 +372,30 @@ GEM
|
|||||||
net-sftp (>= 2.1.2)
|
net-sftp (>= 2.1.2)
|
||||||
net-ssh (>= 2.8.0)
|
net-ssh (>= 2.8.0)
|
||||||
ostruct
|
ostruct
|
||||||
|
standard (1.52.0)
|
||||||
|
language_server-protocol (~> 3.17.0.2)
|
||||||
|
lint_roller (~> 1.0)
|
||||||
|
rubocop (~> 1.81.7)
|
||||||
|
standard-custom (~> 1.0.0)
|
||||||
|
standard-performance (~> 1.8)
|
||||||
|
standard-custom (1.0.2)
|
||||||
|
lint_roller (~> 1.0)
|
||||||
|
rubocop (~> 1.50)
|
||||||
|
standard-performance (1.9.0)
|
||||||
|
lint_roller (~> 1.1)
|
||||||
|
rubocop-performance (~> 1.26.0)
|
||||||
stimulus-rails (1.3.4)
|
stimulus-rails (1.3.4)
|
||||||
railties (>= 6.0.0)
|
railties (>= 6.0.0)
|
||||||
stringio (3.1.7)
|
stringio (3.1.8)
|
||||||
tailwindcss-rails (4.3.0)
|
tailwindcss-rails (4.4.0)
|
||||||
railties (>= 7.0.0)
|
railties (>= 7.0.0)
|
||||||
tailwindcss-ruby (~> 4.0)
|
tailwindcss-ruby (~> 4.0)
|
||||||
tailwindcss-ruby (4.1.13)
|
tailwindcss-ruby (4.1.16)
|
||||||
tailwindcss-ruby (4.1.13-aarch64-linux-gnu)
|
tailwindcss-ruby (4.1.16-aarch64-linux-gnu)
|
||||||
tailwindcss-ruby (4.1.13-aarch64-linux-musl)
|
tailwindcss-ruby (4.1.16-aarch64-linux-musl)
|
||||||
tailwindcss-ruby (4.1.13-arm64-darwin)
|
tailwindcss-ruby (4.1.16-arm64-darwin)
|
||||||
tailwindcss-ruby (4.1.13-x86_64-linux-gnu)
|
tailwindcss-ruby (4.1.16-x86_64-linux-gnu)
|
||||||
tailwindcss-ruby (4.1.13-x86_64-linux-musl)
|
tailwindcss-ruby (4.1.16-x86_64-linux-musl)
|
||||||
thor (1.4.0)
|
thor (1.4.0)
|
||||||
thruster (0.1.16)
|
thruster (0.1.16)
|
||||||
thruster (0.1.16-aarch64-linux)
|
thruster (0.1.16-aarch64-linux)
|
||||||
@@ -385,15 +407,15 @@ GEM
|
|||||||
openssl (> 2.0)
|
openssl (> 2.0)
|
||||||
openssl-signature_algorithm (~> 1.0)
|
openssl-signature_algorithm (~> 1.0)
|
||||||
tsort (0.2.0)
|
tsort (0.2.0)
|
||||||
turbo-rails (2.0.17)
|
turbo-rails (2.0.20)
|
||||||
actionpack (>= 7.1.0)
|
actionpack (>= 7.1.0)
|
||||||
railties (>= 7.1.0)
|
railties (>= 7.1.0)
|
||||||
tzinfo (2.0.6)
|
tzinfo (2.0.6)
|
||||||
concurrent-ruby (~> 1.0)
|
concurrent-ruby (~> 1.0)
|
||||||
unicode-display_width (3.2.0)
|
unicode-display_width (3.2.0)
|
||||||
unicode-emoji (~> 4.1)
|
unicode-emoji (~> 4.1)
|
||||||
unicode-emoji (4.1.0)
|
unicode-emoji (4.2.0)
|
||||||
uri (1.1.0)
|
uri (1.1.1)
|
||||||
useragent (0.16.11)
|
useragent (0.16.11)
|
||||||
web-console (4.2.1)
|
web-console (4.2.1)
|
||||||
actionview (>= 6.0.0)
|
actionview (>= 6.0.0)
|
||||||
@@ -442,18 +464,20 @@ DEPENDENCIES
|
|||||||
kamal
|
kamal
|
||||||
letter_opener
|
letter_opener
|
||||||
propshaft
|
propshaft
|
||||||
public_suffix (~> 6.0)
|
public_suffix (~> 7.0)
|
||||||
puma (>= 5.0)
|
puma (>= 5.0)
|
||||||
rails (~> 8.1.1)
|
rails (~> 8.1.1)
|
||||||
rotp (~> 6.3)
|
rotp (~> 6.3)
|
||||||
rqrcode (~> 3.1)
|
rqrcode (~> 3.1)
|
||||||
rubocop-rails-omakase
|
|
||||||
selenium-webdriver
|
selenium-webdriver
|
||||||
sentry-rails (~> 5.18)
|
sentry-rails (~> 6.2)
|
||||||
sentry-ruby (~> 5.18)
|
sentry-ruby (~> 6.2)
|
||||||
|
simplecov
|
||||||
solid_cable
|
solid_cable
|
||||||
solid_cache
|
solid_cache
|
||||||
|
solid_queue (~> 1.2)
|
||||||
sqlite3 (>= 2.1)
|
sqlite3 (>= 2.1)
|
||||||
|
standard
|
||||||
stimulus-rails
|
stimulus-rails
|
||||||
tailwindcss-rails
|
tailwindcss-rails
|
||||||
thruster
|
thruster
|
||||||
|
|||||||
373
README.md
373
README.md
@@ -1,32 +1,15 @@
|
|||||||
# Clinch
|
# Clinch
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> This software is experiemental. If you'd like to try it out, find bugs, security flaws and improvements, please do.
|
> This software is experimental. If you'd like to try it out, find bugs, security flaws and improvements, please do.
|
||||||
|
|
||||||
**A lightweight, self-hosted identity & SSO / IpD portal**
|
**A lightweight, self-hosted identity & SSO / IpD portal**
|
||||||
|
|
||||||
Clinch gives you one place to manage users and lets any web app authenticate against it without maintaining its own user table.
|
Clinch gives you one place to manage users and lets any web app authenticate against it without managing its own users.
|
||||||
|
|
||||||
I've completed all planned features:
|
|
||||||
|
|
||||||
* Create Admin user on first login
|
|
||||||
* TOTP ( QR Code ) 2FA, with backup codes ( encrypted at rest )
|
|
||||||
* Passkey generation and login, with detection of Passkey during login
|
|
||||||
* Forward Auth configured and working
|
|
||||||
* OIDC provider with auto discovery, refresh tokens, and token revocation
|
|
||||||
* Configurable token expiry per application (access, refresh, ID tokens)
|
|
||||||
* Invite users by email, assign to groups
|
|
||||||
* Self managed password reset by email
|
|
||||||
* Use Groups to assign Applications ( Family group can access Kavita, Developers can access Gitea )
|
|
||||||
* Configurable Group and User custom claims for OIDC token
|
|
||||||
* Display all Applications available to the user on their Dashboard
|
|
||||||
* Display all logged in sessions and OIDC logged in sessions
|
|
||||||
|
|
||||||
What remains now is ensure test coverage,
|
|
||||||
|
|
||||||
## Why Clinch?
|
## Why Clinch?
|
||||||
|
|
||||||
Do you host your own web apps? MeTube, Kavita, Audiobookshelf, Gitea? Rather than managing all those separate user accounts, set everyone up on Clinch and let it do the authentication and user management.
|
Do you host your own web apps? MeTube, Kavita, Audiobookshelf, Gitea, Grafana, Proxmox? Rather than managing all those separate user accounts, set everyone up on Clinch and let it do the authentication and user management.
|
||||||
|
|
||||||
Clinch sits in a sweet spot between two excellent open-source identity solutions:
|
Clinch sits in a sweet spot between two excellent open-source identity solutions:
|
||||||
|
|
||||||
@@ -76,14 +59,17 @@ Clinch sits in a sweet spot between two excellent open-source identity solutions
|
|||||||
- **User statuses** - Active, disabled, or pending invitation
|
- **User statuses** - Active, disabled, or pending invitation
|
||||||
|
|
||||||
### Authentication Methods
|
### Authentication Methods
|
||||||
|
- **WebAuthn/Passkeys** - Modern passwordless authentication using FIDO2 standards
|
||||||
- **Password authentication** - Secure bcrypt-based password storage
|
- **Password authentication** - Secure bcrypt-based password storage
|
||||||
- **Magic login links** - Passwordless login via email (15-minute expiry)
|
|
||||||
- **TOTP 2FA** - Optional time-based one-time passwords with QR code setup
|
- **TOTP 2FA** - Optional time-based one-time passwords with QR code setup
|
||||||
- **Backup codes** - 10 single-use recovery codes per user
|
- **Backup codes** - 10 single-use recovery codes per user
|
||||||
- **Configurable 2FA enforcement** - Admins can require TOTP for specific users/groups
|
- **Configurable 2FA enforcement** - Admins can require TOTP for specific users
|
||||||
|
|
||||||
### SSO Protocols
|
### SSO Protocols
|
||||||
|
|
||||||
|
Apps that speak OIDC use the OIDC flow.
|
||||||
|
Apps that only need "who is it?", or you want available from the internet behind authentication (MeTube, Jellyfin) use ForwardAuth.
|
||||||
|
|
||||||
#### OpenID Connect (OIDC)
|
#### OpenID Connect (OIDC)
|
||||||
Standard OAuth2/OIDC provider with endpoints:
|
Standard OAuth2/OIDC provider with endpoints:
|
||||||
- `/.well-known/openid-configuration` - Discovery endpoint
|
- `/.well-known/openid-configuration` - Discovery endpoint
|
||||||
@@ -94,18 +80,47 @@ Standard OAuth2/OIDC provider with endpoints:
|
|||||||
|
|
||||||
Features:
|
Features:
|
||||||
- **Refresh tokens** - Long-lived tokens (30 days default) with automatic rotation and revocation
|
- **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
|
- **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
|
- **Token security** - All tokens HMAC-SHA256 hashed (suitable for 256-bit random data), 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.
|
**ID Token Claims** (JWT with RS256 signature):
|
||||||
|
|
||||||
|
| Claim | Description | Notes |
|
||||||
|
|-------|-------------|-------|
|
||||||
|
| Standard Claims | | |
|
||||||
|
| `iss` | Issuer (Clinch URL) | From `CLINCH_HOST` |
|
||||||
|
| `sub` | Subject (user identifier) | Pairwise SID - unique per app |
|
||||||
|
| `aud` | Audience | OAuth client_id |
|
||||||
|
| `exp` | Expiration timestamp | Configurable TTL |
|
||||||
|
| `iat` | Issued-at timestamp | Token creation time |
|
||||||
|
| `email` | User email | |
|
||||||
|
| `email_verified` | Email verification | Always `true` |
|
||||||
|
| `preferred_username` | Username/email | Fallback to email |
|
||||||
|
| `name` | Display name | User's name or email |
|
||||||
|
| `nonce` | Random value | From auth request (prevents replay) |
|
||||||
|
| **Security Claims** | | |
|
||||||
|
| `at_hash` | Access token hash | SHA-256 hash of access_token (OIDC Core §3.1.3.6) |
|
||||||
|
| `auth_time` | Authentication time | Unix timestamp of when user logged in (OIDC Core §2) |
|
||||||
|
| `acr` | Auth context class | `"1"` = password, `"2"` = 2FA/passkey (OIDC Core §2) |
|
||||||
|
| `azp` | Authorized party | OAuth client_id (OIDC Core §2) |
|
||||||
|
| Custom Claims | | |
|
||||||
|
| `groups` | User's groups | Array of group names |
|
||||||
|
| *custom* | Arbitrary key-values | From groups, users, or app-specific config |
|
||||||
|
|
||||||
|
**Authentication Context Class Reference (`acr`):**
|
||||||
|
- `"1"` - Something you know (password only)
|
||||||
|
- `"2"` - Two-factor or phishing-resistant (TOTP, backup codes, WebAuthn/passkey)
|
||||||
|
|
||||||
|
Client apps (Audiobookshelf, Kavita, Proxmox, Grafana, etc.) redirect to Clinch for login and receive ID tokens, access tokens, and refresh tokens.
|
||||||
|
|
||||||
#### Trusted-Header SSO (ForwardAuth)
|
#### Trusted-Header SSO (ForwardAuth)
|
||||||
Works with reverse proxies (Caddy, Traefik, Nginx):
|
Works with reverse proxies (Caddy, Traefik, Nginx):
|
||||||
1. Proxy sends every request to `/api/verify`
|
1. Proxy sends every request to `/api/verify`
|
||||||
2. **200 OK** → Proxy injects headers (`Remote-User`, `Remote-Groups`, `Remote-Email`) and forwards to app
|
2. Response handling:
|
||||||
3. **401/403** → Proxy redirects to Clinch login; after login, user returns to original URL
|
- **200 OK** → Proxy injects headers (`Remote-User`, `Remote-Groups`, `Remote-Email`) and forwards to app
|
||||||
|
- **Any other status** → Proxy returns that response directly to client (typically 302 redirect to login page)
|
||||||
Apps that speak OIDC use the OIDC flow; apps that only need "who is it?" headers use ForwardAuth.
|
|
||||||
|
|
||||||
**Note:** ForwardAuth requires applications to run on the same domain as Clinch (e.g., `app.yourdomain.com` with Clinch at `auth.yourdomain.com`) for secure session cookie sharing. Take a look at Authentik if you need multi domain support.
|
**Note:** ForwardAuth requires applications to run on the same domain as Clinch (e.g., `app.yourdomain.com` with Clinch at `auth.yourdomain.com`) for secure session cookie sharing. Take a look at Authentik if you need multi domain support.
|
||||||
|
|
||||||
@@ -113,7 +128,6 @@ Apps that speak OIDC use the OIDC flow; apps that only need "who is it?" headers
|
|||||||
Send emails for:
|
Send emails for:
|
||||||
- Invitation links (one-time token, 7-day expiry)
|
- Invitation links (one-time token, 7-day expiry)
|
||||||
- Password reset links (one-time token, 1-hour expiry)
|
- Password reset links (one-time token, 1-hour expiry)
|
||||||
- 2FA backup codes
|
|
||||||
|
|
||||||
### Session Management
|
### Session Management
|
||||||
- **Device tracking** - See all active sessions with device names and IPs
|
- **Device tracking** - See all active sessions with device names and IPs
|
||||||
@@ -121,10 +135,54 @@ Send emails for:
|
|||||||
- **Session revocation** - Users and admins can revoke individual sessions
|
- **Session revocation** - Users and admins can revoke individual sessions
|
||||||
|
|
||||||
### Access Control
|
### Access Control
|
||||||
- **Group-based allowlists** - Restrict applications to specific user groups
|
|
||||||
- **Per-application access** - Each app defines which groups can access it
|
#### Group-Based Application Access
|
||||||
- **Automatic enforcement** - Access checks during OIDC authorization and ForwardAuth
|
Clinch uses groups to control which users can access which applications:
|
||||||
- **Custom claims** - Add arbitrary claims to OIDC tokens via groups and users (perfect for app-specific roles)
|
|
||||||
|
- **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"]}`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -169,9 +227,9 @@ Send emails for:
|
|||||||
- Many-to-many with Groups (allowlist)
|
- Many-to-many with Groups (allowlist)
|
||||||
|
|
||||||
**OIDC Tokens**
|
**OIDC Tokens**
|
||||||
- Authorization codes (10-minute expiry, one-time use, PKCE support)
|
- Authorization codes (opaque, HMAC-SHA256 hashed, 10-minute expiry, one-time use, PKCE support)
|
||||||
- Access tokens (opaque, BCrypt-hashed, configurable expiry 5min-24hr, revocable)
|
- Access tokens (opaque, HMAC-SHA256 hashed, configurable expiry 5min-24hr, revocable)
|
||||||
- Refresh tokens (opaque, BCrypt-hashed, configurable expiry 1-90 days, single-use with rotation)
|
- Refresh tokens (opaque, HMAC-SHA256 hashed, configurable expiry 1-90 days, single-use with rotation)
|
||||||
- ID tokens (JWT, signed with RS256, configurable expiry 5min-24hr)
|
- ID tokens (JWT, signed with RS256, configurable expiry 5min-24hr)
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -286,24 +344,237 @@ OIDC_PRIVATE_KEY=<contents-of-private-key.pem>
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Roadmap
|
## Rails Console
|
||||||
|
|
||||||
### In Progress
|
One advantage of being a Rails application is direct access to the Rails console for administrative tasks. This is particularly useful for debugging, emergency access, or bulk operations.
|
||||||
- OIDC provider implementation
|
|
||||||
- ForwardAuth endpoint
|
|
||||||
- Admin UI for user/group/app management
|
|
||||||
- First-run wizard
|
|
||||||
|
|
||||||
### Planned Features
|
### Starting the Console
|
||||||
- **Audit logging** - Track all authentication events
|
|
||||||
- **WebAuthn/Passkeys** - Hardware key support
|
|
||||||
|
|
||||||
#### Maybe
|
```bash
|
||||||
- **SAML support** - SAML 2.0 identity provider
|
# Docker / Docker Compose
|
||||||
- **Policy engine** - Rule-based access control
|
docker exec -it clinch bin/rails console
|
||||||
- Example: `IF user.email =~ "*@gmail.com" AND app.slug == "kavita" THEN DENY`
|
# or
|
||||||
- Stored as JSON, evaluated after auth but before consent
|
docker compose exec -it clinch bin/rails console
|
||||||
- **LDAP sync** - Import users from LDAP/Active Directory
|
|
||||||
|
# Local development
|
||||||
|
bin/rails console
|
||||||
|
```
|
||||||
|
|
||||||
|
### Finding Users
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
# Find by email
|
||||||
|
user = User.find_by(email_address: 'alice@example.com')
|
||||||
|
|
||||||
|
# Find by username
|
||||||
|
user = User.find_by(username: 'alice')
|
||||||
|
|
||||||
|
# List all users
|
||||||
|
User.all.pluck(:id, :email_address, :status)
|
||||||
|
|
||||||
|
# Find admins
|
||||||
|
User.admins.pluck(:email_address)
|
||||||
|
|
||||||
|
# Find users in a specific status
|
||||||
|
User.active.count
|
||||||
|
User.disabled.pluck(:email_address)
|
||||||
|
User.pending_invitation.pluck(:email_address)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Creating Users
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
# Create a regular user
|
||||||
|
User.create!(
|
||||||
|
email_address: 'newuser@example.com',
|
||||||
|
password: 'secure-password-here',
|
||||||
|
status: :active
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create an admin user
|
||||||
|
User.create!(
|
||||||
|
email_address: 'admin@example.com',
|
||||||
|
password: 'secure-password-here',
|
||||||
|
status: :active,
|
||||||
|
admin: true
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Managing Passwords
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
user = User.find_by(email_address: 'alice@example.com')
|
||||||
|
user.password = 'new-secure-password'
|
||||||
|
user.save!
|
||||||
|
```
|
||||||
|
|
||||||
|
### Two-Factor Authentication (TOTP)
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
user = User.find_by(email_address: 'alice@example.com')
|
||||||
|
|
||||||
|
# Check if TOTP is enabled
|
||||||
|
user.totp_enabled?
|
||||||
|
|
||||||
|
# Get current TOTP code (useful for testing/debugging)
|
||||||
|
puts user.console_totp
|
||||||
|
|
||||||
|
# Enable TOTP (generates secret and backup codes)
|
||||||
|
backup_codes = user.enable_totp!
|
||||||
|
puts backup_codes # Display backup codes to give to user
|
||||||
|
|
||||||
|
# Disable TOTP
|
||||||
|
user.disable_totp!
|
||||||
|
|
||||||
|
# Force user to set up TOTP on next login
|
||||||
|
user.update!(totp_required: true)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Managing User Status
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
user = User.find_by(email_address: 'alice@example.com')
|
||||||
|
|
||||||
|
# Disable a user (prevents login)
|
||||||
|
user.disabled!
|
||||||
|
|
||||||
|
# Re-enable a user
|
||||||
|
user.active!
|
||||||
|
|
||||||
|
# Check current status
|
||||||
|
user.status # => "active", "disabled", or "pending_invitation"
|
||||||
|
|
||||||
|
# Grant admin privileges
|
||||||
|
user.update!(admin: true)
|
||||||
|
|
||||||
|
# Revoke admin privileges
|
||||||
|
user.update!(admin: false)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Managing Groups
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
user = User.find_by(email_address: 'alice@example.com')
|
||||||
|
|
||||||
|
# View user's groups
|
||||||
|
user.groups.pluck(:name)
|
||||||
|
|
||||||
|
# Add user to a group
|
||||||
|
family = Group.find_by(name: 'family')
|
||||||
|
user.groups << family
|
||||||
|
|
||||||
|
# Remove user from a group
|
||||||
|
user.groups.delete(family)
|
||||||
|
|
||||||
|
# Create a new group
|
||||||
|
Group.create!(name: 'developers', description: 'Development team')
|
||||||
|
```
|
||||||
|
|
||||||
|
### Managing Sessions
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
user = User.find_by(email_address: 'alice@example.com')
|
||||||
|
|
||||||
|
# View active sessions
|
||||||
|
user.sessions.pluck(:id, :device_name, :client_ip, :created_at)
|
||||||
|
|
||||||
|
# Revoke all sessions (force logout everywhere)
|
||||||
|
user.sessions.destroy_all
|
||||||
|
|
||||||
|
# Revoke a specific session
|
||||||
|
user.sessions.find(123).destroy
|
||||||
|
```
|
||||||
|
|
||||||
|
### Managing Applications
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
# List all OIDC applications
|
||||||
|
Application.oidc.pluck(:name, :client_id)
|
||||||
|
|
||||||
|
# Find an application
|
||||||
|
app = Application.find_by(slug: 'kavita')
|
||||||
|
|
||||||
|
# Regenerate client secret
|
||||||
|
new_secret = app.generate_new_client_secret!
|
||||||
|
puts new_secret # Display once - not stored in plain text
|
||||||
|
|
||||||
|
# Check which users can access an app
|
||||||
|
app.allowed_groups.flat_map(&:users).uniq.pluck(:email_address)
|
||||||
|
|
||||||
|
# Revoke all tokens for an application
|
||||||
|
app.oidc_access_tokens.destroy_all
|
||||||
|
app.oidc_refresh_tokens.destroy_all
|
||||||
|
```
|
||||||
|
|
||||||
|
### Revoking OIDC Consents
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
user = User.find_by(email_address: 'alice@example.com')
|
||||||
|
app = Application.find_by(slug: 'kavita')
|
||||||
|
|
||||||
|
# Revoke consent for a specific app
|
||||||
|
user.revoke_consent!(app)
|
||||||
|
|
||||||
|
# Revoke all OIDC consents
|
||||||
|
user.revoke_all_consents!
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing & Security
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
|
||||||
|
Clinch has comprehensive test coverage with 341 tests covering integration, models, controllers, services, and system tests.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all tests
|
||||||
|
bin/rails test
|
||||||
|
|
||||||
|
# Run specific test types
|
||||||
|
bin/rails test:integration
|
||||||
|
bin/rails test:models
|
||||||
|
bin/rails test:controllers
|
||||||
|
bin/rails test:system
|
||||||
|
|
||||||
|
# Run with code coverage report
|
||||||
|
COVERAGE=1 bin/rails test
|
||||||
|
# View coverage report at coverage/index.html
|
||||||
|
```
|
||||||
|
|
||||||
|
### Security Scanning
|
||||||
|
|
||||||
|
Clinch uses multiple automated security tools to ensure code quality and security:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all security checks
|
||||||
|
bin/rake security
|
||||||
|
|
||||||
|
# Individual security scans
|
||||||
|
bin/brakeman --no-pager # Static security analysis
|
||||||
|
bin/bundler-audit check --update # Dependency vulnerability scan
|
||||||
|
bin/importmap audit # JavaScript dependency scan
|
||||||
|
```
|
||||||
|
|
||||||
|
**CI/CD Integration:**
|
||||||
|
All security scans run automatically on every pull request and push to main via GitHub Actions.
|
||||||
|
|
||||||
|
**Security Tools:**
|
||||||
|
- **Brakeman** - Static analysis for Rails security vulnerabilities
|
||||||
|
- **bundler-audit** - Checks gems for known CVEs
|
||||||
|
- **SimpleCov** - Code coverage tracking
|
||||||
|
- **RuboCop** - Code style and quality enforcement
|
||||||
|
|
||||||
|
**Current Status:**
|
||||||
|
- ✅ All security scans passing
|
||||||
|
- ✅ 341 tests, 1349 assertions, 0 failures
|
||||||
|
- ✅ No known dependency vulnerabilities
|
||||||
|
- ✅ Phases 1-4 security hardening complete (18+ vulnerabilities fixed)
|
||||||
|
- 🟡 3 outstanding security issues (all MEDIUM/LOW priority)
|
||||||
|
|
||||||
|
**Security Documentation:**
|
||||||
|
- [docs/security-todo.md](docs/security-todo.md) - Detailed vulnerability tracking and remediation history
|
||||||
|
- [docs/beta-checklist.md](docs/beta-checklist.md) - Beta release readiness criteria
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -7,10 +7,11 @@ module ApplicationCable
|
|||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
def set_current_user
|
|
||||||
if session = Session.find_by(id: cookies.signed[:session_id])
|
def set_current_user
|
||||||
self.current_user = session.user
|
if (session = Session.find_by(id: cookies.signed[:session_id]))
|
||||||
end
|
self.current_user = session.user
|
||||||
end
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -16,16 +16,82 @@ class ActiveSessionsController < ApplicationController
|
|||||||
return
|
return
|
||||||
end
|
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
|
# Revoke the consent
|
||||||
consent.destroy
|
consent.destroy
|
||||||
redirect_to active_sessions_path, notice: "Successfully revoked access to #{application.name}."
|
redirect_to active_sessions_path, notice: "Successfully revoked access to #{application.name}."
|
||||||
end
|
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
|
def revoke_all_consents
|
||||||
@user = Current.session.user
|
@user = Current.session.user
|
||||||
count = @user.oidc_user_consents.count
|
consents = @user.oidc_user_consents.includes(:application)
|
||||||
|
count = consents.count
|
||||||
|
|
||||||
if count > 0
|
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
|
@user.oidc_user_consents.destroy_all
|
||||||
redirect_to active_sessions_path, notice: "Successfully revoked access to #{count} applications."
|
redirect_to active_sessions_path, notice: "Successfully revoked access to #{count} applications."
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -26,18 +26,17 @@ module Admin
|
|||||||
@application.allowed_groups = Group.where(id: group_ids)
|
@application.allowed_groups = Group.where(id: group_ids)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Get the plain text client secret to show one time
|
# Get the plain text client secret to show one time (confidential clients only)
|
||||||
client_secret = nil
|
client_secret = nil
|
||||||
if @application.oidc?
|
if @application.oidc? && @application.confidential_client?
|
||||||
client_secret = @application.generate_new_client_secret!
|
client_secret = @application.generate_new_client_secret!
|
||||||
end
|
end
|
||||||
|
|
||||||
if @application.oidc? && client_secret
|
flash[:notice] = "Application created successfully."
|
||||||
flash[:notice] = "Application created successfully."
|
if @application.oidc?
|
||||||
flash[:client_id] = @application.client_id
|
flash[:client_id] = @application.client_id
|
||||||
flash[:client_secret] = client_secret
|
flash[:client_secret] = client_secret if client_secret
|
||||||
else
|
flash[:public_client] = true if @application.public_client?
|
||||||
flash[:notice] = "Application created successfully."
|
|
||||||
end
|
end
|
||||||
|
|
||||||
redirect_to admin_application_path(@application)
|
redirect_to admin_application_path(@application)
|
||||||
@@ -74,15 +73,20 @@ module Admin
|
|||||||
|
|
||||||
def regenerate_credentials
|
def regenerate_credentials
|
||||||
if @application.oidc?
|
if @application.oidc?
|
||||||
# Generate new client ID and secret
|
# Generate new client ID (always)
|
||||||
new_client_id = SecureRandom.urlsafe_base64(32)
|
new_client_id = SecureRandom.urlsafe_base64(32)
|
||||||
client_secret = @application.generate_new_client_secret!
|
|
||||||
|
|
||||||
@application.update!(client_id: new_client_id)
|
@application.update!(client_id: new_client_id)
|
||||||
|
|
||||||
flash[:notice] = "Credentials regenerated successfully."
|
flash[:notice] = "Credentials regenerated successfully."
|
||||||
flash[:client_id] = @application.client_id
|
flash[:client_id] = @application.client_id
|
||||||
flash[:client_secret] = client_secret
|
|
||||||
|
# Generate new client secret only for confidential clients
|
||||||
|
if @application.confidential_client?
|
||||||
|
client_secret = @application.generate_new_client_secret!
|
||||||
|
flash[:client_secret] = client_secret
|
||||||
|
else
|
||||||
|
flash[:public_client] = true
|
||||||
|
end
|
||||||
|
|
||||||
redirect_to admin_application_path(@application)
|
redirect_to admin_application_path(@application)
|
||||||
else
|
else
|
||||||
@@ -97,14 +101,24 @@ module Admin
|
|||||||
end
|
end
|
||||||
|
|
||||||
def application_params
|
def application_params
|
||||||
params.require(:application).permit(
|
permitted = params.require(:application).permit(
|
||||||
:name, :slug, :app_type, :active, :redirect_uris, :description, :metadata,
|
:name, :slug, :app_type, :active, :redirect_uris, :description, :metadata,
|
||||||
:domain_pattern, :landing_url, :access_token_ttl, :refresh_token_ttl, :id_token_ttl,
|
:domain_pattern, :landing_url, :access_token_ttl, :refresh_token_ttl, :id_token_ttl,
|
||||||
headers_config: {}
|
:icon, :backchannel_logout_uri, :is_public_client, :require_pkce
|
||||||
).tap do |whitelisted|
|
)
|
||||||
# Remove client_secret from params if present (shouldn't be updated via form)
|
|
||||||
whitelisted.delete(:client_secret)
|
# Handle headers_config - it comes as a JSON string from the text area
|
||||||
|
if params[:application][:headers_config].present?
|
||||||
|
begin
|
||||||
|
permitted[:headers_config] = JSON.parse(params[:application][:headers_config])
|
||||||
|
rescue JSON::ParserError
|
||||||
|
permitted[:headers_config] = {}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Remove client_secret from params if present (shouldn't be updated via form)
|
||||||
|
permitted.delete(:client_secret)
|
||||||
|
permitted
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -18,7 +18,25 @@ module Admin
|
|||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
@group = Group.new(group_params)
|
create_params = group_params
|
||||||
|
|
||||||
|
# Parse custom_claims JSON if provided
|
||||||
|
if create_params[:custom_claims].present?
|
||||||
|
begin
|
||||||
|
create_params[:custom_claims] = JSON.parse(create_params[:custom_claims])
|
||||||
|
rescue JSON::ParserError
|
||||||
|
@group = Group.new
|
||||||
|
@group.errors.add(:custom_claims, "must be valid JSON")
|
||||||
|
@available_users = User.order(:email_address)
|
||||||
|
render :new, status: :unprocessable_entity
|
||||||
|
return
|
||||||
|
end
|
||||||
|
else
|
||||||
|
# If empty or blank, set to empty hash (NOT NULL constraint)
|
||||||
|
create_params[:custom_claims] = {}
|
||||||
|
end
|
||||||
|
|
||||||
|
@group = Group.new(create_params)
|
||||||
|
|
||||||
if @group.save
|
if @group.save
|
||||||
# Handle user assignments
|
# Handle user assignments
|
||||||
@@ -39,7 +57,24 @@ module Admin
|
|||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
if @group.update(group_params)
|
update_params = group_params
|
||||||
|
|
||||||
|
# Parse custom_claims JSON if provided
|
||||||
|
if update_params[:custom_claims].present?
|
||||||
|
begin
|
||||||
|
update_params[:custom_claims] = JSON.parse(update_params[:custom_claims])
|
||||||
|
rescue JSON::ParserError
|
||||||
|
@group.errors.add(:custom_claims, "must be valid JSON")
|
||||||
|
@available_users = User.order(:email_address)
|
||||||
|
render :edit, status: :unprocessable_entity
|
||||||
|
return
|
||||||
|
end
|
||||||
|
else
|
||||||
|
# If empty or blank, set to empty hash (NOT NULL constraint)
|
||||||
|
update_params[:custom_claims] = {}
|
||||||
|
end
|
||||||
|
|
||||||
|
if @group.update(update_params)
|
||||||
# Handle user assignments
|
# Handle user assignments
|
||||||
if params[:group][:user_ids].present?
|
if params[:group][:user_ids].present?
|
||||||
user_ids = params[:group][:user_ids].reject(&:blank?)
|
user_ids = params[:group][:user_ids].reject(&:blank?)
|
||||||
@@ -67,7 +102,7 @@ module Admin
|
|||||||
end
|
end
|
||||||
|
|
||||||
def group_params
|
def group_params
|
||||||
params.require(:group).permit(:name, :description, custom_claims: {})
|
params.require(:group).permit(:name, :description, :custom_claims)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
module Admin
|
module Admin
|
||||||
class UsersController < BaseController
|
class UsersController < BaseController
|
||||||
before_action :set_user, only: [:show, :edit, :update, :destroy, :resend_invitation]
|
before_action :set_user, only: [:show, :edit, :update, :destroy, :resend_invitation, :update_application_claims, :delete_application_claims]
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@users = User.order(created_at: :desc)
|
@users = User.order(created_at: :desc)
|
||||||
@@ -27,23 +27,34 @@ module Admin
|
|||||||
end
|
end
|
||||||
|
|
||||||
def edit
|
def edit
|
||||||
|
@applications = Application.active.order(:name)
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
# Prevent changing params for the current user's email and admin status
|
update_params = user_params
|
||||||
# to avoid locking themselves out
|
|
||||||
update_params = user_params.dup
|
|
||||||
|
|
||||||
if @user == Current.session.user
|
|
||||||
update_params.delete(:admin)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Only update password if provided
|
# Only update password if provided
|
||||||
update_params.delete(:password) if update_params[:password].blank?
|
update_params.delete(:password) if update_params[:password].blank?
|
||||||
|
|
||||||
|
# Parse custom_claims JSON if provided
|
||||||
|
if update_params[:custom_claims].present?
|
||||||
|
begin
|
||||||
|
update_params[:custom_claims] = JSON.parse(update_params[:custom_claims])
|
||||||
|
rescue JSON::ParserError
|
||||||
|
@user.errors.add(:custom_claims, "must be valid JSON")
|
||||||
|
@applications = Application.active.order(:name)
|
||||||
|
render :edit, status: :unprocessable_entity
|
||||||
|
return
|
||||||
|
end
|
||||||
|
else
|
||||||
|
# If empty or blank, set to empty hash (NOT NULL constraint)
|
||||||
|
update_params[:custom_claims] = {}
|
||||||
|
end
|
||||||
|
|
||||||
if @user.update(update_params)
|
if @user.update(update_params)
|
||||||
redirect_to admin_users_path, notice: "User updated successfully."
|
redirect_to admin_users_path, notice: "User updated successfully."
|
||||||
else
|
else
|
||||||
|
@applications = Application.active.order(:name)
|
||||||
render :edit, status: :unprocessable_entity
|
render :edit, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -69,6 +80,41 @@ module Admin
|
|||||||
redirect_to admin_users_path, notice: "User deleted successfully."
|
redirect_to admin_users_path, notice: "User deleted successfully."
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# POST /admin/users/:id/update_application_claims
|
||||||
|
def update_application_claims
|
||||||
|
application = Application.find(params[:application_id])
|
||||||
|
|
||||||
|
claims_json = params[:custom_claims].presence || "{}"
|
||||||
|
begin
|
||||||
|
claims = JSON.parse(claims_json)
|
||||||
|
rescue JSON::ParserError
|
||||||
|
redirect_to edit_admin_user_path(@user), alert: "Invalid JSON format for claims."
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
app_claim = @user.application_user_claims.find_or_initialize_by(application: application)
|
||||||
|
app_claim.custom_claims = claims
|
||||||
|
|
||||||
|
if app_claim.save
|
||||||
|
redirect_to edit_admin_user_path(@user), notice: "App-specific claims updated for #{application.name}."
|
||||||
|
else
|
||||||
|
error_message = app_claim.errors.full_messages.join(", ")
|
||||||
|
redirect_to edit_admin_user_path(@user), alert: "Failed to update claims: #{error_message}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# DELETE /admin/users/:id/delete_application_claims
|
||||||
|
def delete_application_claims
|
||||||
|
application = Application.find(params[:application_id])
|
||||||
|
app_claim = @user.application_user_claims.find_by(application: application)
|
||||||
|
|
||||||
|
if app_claim&.destroy
|
||||||
|
redirect_to edit_admin_user_path(@user), notice: "App-specific claims removed for #{application.name}."
|
||||||
|
else
|
||||||
|
redirect_to edit_admin_user_path(@user), alert: "No claims found to remove."
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def set_user
|
def set_user
|
||||||
@@ -76,7 +122,15 @@ module Admin
|
|||||||
end
|
end
|
||||||
|
|
||||||
def user_params
|
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
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ module Api
|
|||||||
def violation_report
|
def violation_report
|
||||||
# Parse CSP violation report
|
# Parse CSP violation report
|
||||||
report_data = JSON.parse(request.body.read)
|
report_data = JSON.parse(request.body.read)
|
||||||
csp_report = report_data['csp-report']
|
csp_report = report_data["csp-report"]
|
||||||
|
|
||||||
# Validate that we have a proper CSP report
|
# Validate that we have a proper CSP report
|
||||||
unless csp_report.is_a?(Hash) && csp_report.present?
|
unless csp_report.is_a?(Hash) && csp_report.present?
|
||||||
@@ -19,28 +19,28 @@ module Api
|
|||||||
|
|
||||||
# Log the violation for security monitoring
|
# Log the violation for security monitoring
|
||||||
Rails.logger.warn "CSP Violation Report:"
|
Rails.logger.warn "CSP Violation Report:"
|
||||||
Rails.logger.warn " Blocked URI: #{csp_report['blocked-uri']}"
|
Rails.logger.warn " Blocked URI: #{csp_report["blocked-uri"]}"
|
||||||
Rails.logger.warn " Document URI: #{csp_report['document-uri']}"
|
Rails.logger.warn " Document URI: #{csp_report["document-uri"]}"
|
||||||
Rails.logger.warn " Referrer: #{csp_report['referrer']}"
|
Rails.logger.warn " Referrer: #{csp_report["referrer"]}"
|
||||||
Rails.logger.warn " Violated Directive: #{csp_report['violated-directive']}"
|
Rails.logger.warn " Violated Directive: #{csp_report["violated-directive"]}"
|
||||||
Rails.logger.warn " Original Policy: #{csp_report['original-policy']}"
|
Rails.logger.warn " Original Policy: #{csp_report["original-policy"]}"
|
||||||
Rails.logger.warn " User Agent: #{request.user_agent}"
|
Rails.logger.warn " User Agent: #{request.user_agent}"
|
||||||
Rails.logger.warn " IP Address: #{request.remote_ip}"
|
Rails.logger.warn " IP Address: #{request.remote_ip}"
|
||||||
|
|
||||||
# Emit structured event for CSP violation
|
# Emit structured event for CSP violation
|
||||||
# This allows multiple subscribers to process the event (Sentry, local logging, etc.)
|
# This allows multiple subscribers to process the event (Sentry, local logging, etc.)
|
||||||
Rails.event.notify("csp.violation", {
|
Rails.event.notify("csp.violation", {
|
||||||
blocked_uri: csp_report['blocked-uri'],
|
blocked_uri: csp_report["blocked-uri"],
|
||||||
document_uri: csp_report['document-uri'],
|
document_uri: csp_report["document-uri"],
|
||||||
referrer: csp_report['referrer'],
|
referrer: csp_report["referrer"],
|
||||||
violated_directive: csp_report['violated-directive'],
|
violated_directive: csp_report["violated-directive"],
|
||||||
original_policy: csp_report['original-policy'],
|
original_policy: csp_report["original-policy"],
|
||||||
disposition: csp_report['disposition'],
|
disposition: csp_report["disposition"],
|
||||||
effective_directive: csp_report['effective-directive'],
|
effective_directive: csp_report["effective-directive"],
|
||||||
source_file: csp_report['source-file'],
|
source_file: csp_report["source-file"],
|
||||||
line_number: csp_report['line-number'],
|
line_number: csp_report["line-number"],
|
||||||
column_number: csp_report['column-number'],
|
column_number: csp_report["column-number"],
|
||||||
status_code: csp_report['status-code'],
|
status_code: csp_report["status-code"],
|
||||||
user_agent: request.user_agent,
|
user_agent: request.user_agent,
|
||||||
ip_address: request.remote_ip,
|
ip_address: request.remote_ip,
|
||||||
current_user_id: Current.user&.id,
|
current_user_id: Current.user&.id,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ module Api
|
|||||||
# ForwardAuth endpoints need session storage for return URL
|
# ForwardAuth endpoints need session storage for return URL
|
||||||
allow_unauthenticated_access
|
allow_unauthenticated_access
|
||||||
skip_before_action :verify_authenticity_token
|
skip_before_action :verify_authenticity_token
|
||||||
rate_limit to: 100, within: 1.minute, only: :verify, with: -> { head :too_many_requests }
|
# No rate limiting on forward_auth endpoint - proxy middleware hits this frequently
|
||||||
|
|
||||||
# GET /api/verify
|
# GET /api/verify
|
||||||
# This endpoint is called by reverse proxies (Traefik, Caddy, nginx)
|
# This endpoint is called by reverse proxies (Traefik, Caddy, nginx)
|
||||||
@@ -49,14 +49,20 @@ module Api
|
|||||||
forwarded_host = request.headers["X-Forwarded-Host"] || request.headers["Host"]
|
forwarded_host = request.headers["X-Forwarded-Host"] || request.headers["Host"]
|
||||||
|
|
||||||
if forwarded_host.present?
|
if forwarded_host.present?
|
||||||
# Load active forward auth applications with their associations for better performance
|
# Load all forward auth applications (including inactive ones) for security checks
|
||||||
# Preload groups to avoid N+1 queries in user_allowed? checks
|
# Preload groups to avoid N+1 queries in user_allowed? checks
|
||||||
apps = Application.forward_auth.includes(:allowed_groups).active
|
apps = Application.forward_auth.includes(:allowed_groups)
|
||||||
|
|
||||||
# Find matching forward auth application for this domain
|
# Find matching forward auth application for this domain
|
||||||
app = apps.find { |a| a.matches_domain?(forwarded_host) }
|
app = apps.find { |a| a.matches_domain?(forwarded_host) }
|
||||||
|
|
||||||
if app
|
if app
|
||||||
|
# Check if application is active
|
||||||
|
unless app.active?
|
||||||
|
Rails.logger.info "ForwardAuth: Access denied to #{forwarded_host} - application is inactive"
|
||||||
|
return render_forbidden("No authentication rule configured for this domain")
|
||||||
|
end
|
||||||
|
|
||||||
# Check if user is allowed by this application
|
# Check if user is allowed by this application
|
||||||
unless app.user_allowed?(user)
|
unless app.user_allowed?(user)
|
||||||
Rails.logger.info "ForwardAuth: User #{user.email_address} denied access to #{forwarded_host} by app #{app.domain_pattern}"
|
Rails.logger.info "ForwardAuth: User #{user.email_address} denied access to #{forwarded_host} by app #{app.domain_pattern}"
|
||||||
@@ -65,8 +71,9 @@ module Api
|
|||||||
|
|
||||||
Rails.logger.info "ForwardAuth: User #{user.email_address} granted access to #{forwarded_host} by app #{app.domain_pattern} (policy: #{app.policy_for_user(user)})"
|
Rails.logger.info "ForwardAuth: User #{user.email_address} granted access to #{forwarded_host} by app #{app.domain_pattern} (policy: #{app.policy_for_user(user)})"
|
||||||
else
|
else
|
||||||
# No application found - allow access with default headers (original behavior)
|
# No application found - DENY by default (fail-closed security)
|
||||||
Rails.logger.info "ForwardAuth: No application found for domain: #{forwarded_host}, allowing with default headers"
|
Rails.logger.info "ForwardAuth: Access denied to #{forwarded_host} - no authentication rule configured"
|
||||||
|
return render_forbidden("No authentication rule configured for this domain")
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
Rails.logger.info "ForwardAuth: User #{user.email_address} authenticated (no domain specified)"
|
Rails.logger.info "ForwardAuth: User #{user.email_address} authenticated (no domain specified)"
|
||||||
@@ -74,22 +81,26 @@ module Api
|
|||||||
|
|
||||||
# User is authenticated and authorized
|
# User is authenticated and authorized
|
||||||
# Return 200 with user information headers using app-specific configuration
|
# Return 200 with user information headers using app-specific configuration
|
||||||
headers = app ? app.headers_for_user(user) : Application::DEFAULT_HEADERS.map { |key, header_name|
|
headers = if app
|
||||||
case key
|
app.headers_for_user(user)
|
||||||
when :user, :email, :name
|
else
|
||||||
[header_name, user.email_address]
|
Application::DEFAULT_HEADERS.map { |key, header_name|
|
||||||
when :groups
|
case key
|
||||||
user.groups.any? ? [header_name, user.groups.pluck(:name).join(",")] : nil
|
when :user, :email, :name
|
||||||
when :admin
|
[header_name, user.email_address]
|
||||||
[header_name, user.admin? ? "true" : "false"]
|
when :groups
|
||||||
end
|
user.groups.any? ? [header_name, user.groups.pluck(:name).join(",")] : nil
|
||||||
}.compact.to_h
|
when :admin
|
||||||
|
[header_name, user.admin? ? "true" : "false"]
|
||||||
|
end
|
||||||
|
}.compact.to_h
|
||||||
|
end
|
||||||
|
|
||||||
headers.each { |key, value| response.headers[key] = value }
|
headers.each { |key, value| response.headers[key] = value }
|
||||||
|
|
||||||
# Log what headers we're sending (helpful for debugging)
|
# Log what headers we're sending (helpful for debugging)
|
||||||
if headers.any?
|
if headers.any?
|
||||||
Rails.logger.debug "ForwardAuth: Headers sent: #{headers.keys.join(', ')}"
|
Rails.logger.debug "ForwardAuth: Headers sent: #{headers.keys.join(", ")}"
|
||||||
else
|
else
|
||||||
Rails.logger.debug "ForwardAuth: No headers sent (access only)"
|
Rails.logger.debug "ForwardAuth: No headers sent (access only)"
|
||||||
end
|
end
|
||||||
@@ -116,14 +127,13 @@ module Api
|
|||||||
# Delete the token immediately (one-time use)
|
# Delete the token immediately (one-time use)
|
||||||
Rails.cache.delete("forward_auth_token:#{token}")
|
Rails.cache.delete("forward_auth_token:#{token}")
|
||||||
|
|
||||||
session_id
|
session_id
|
||||||
end
|
end
|
||||||
|
|
||||||
def extract_session_id
|
def extract_session_id
|
||||||
# Extract session ID from cookie
|
# Extract session ID from cookie
|
||||||
# Rails uses signed cookies by default
|
# Rails uses signed cookies by default
|
||||||
session_id = cookies.signed[:session_id]
|
cookies.signed[:session_id]
|
||||||
session_id
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def extract_app_from_headers
|
def extract_app_from_headers
|
||||||
@@ -135,6 +145,9 @@ module Api
|
|||||||
def render_unauthorized(reason = nil)
|
def render_unauthorized(reason = nil)
|
||||||
Rails.logger.info "ForwardAuth: Unauthorized - #{reason}"
|
Rails.logger.info "ForwardAuth: Unauthorized - #{reason}"
|
||||||
|
|
||||||
|
# Set auth reason header for debugging (like Authelia)
|
||||||
|
response.headers["X-Auth-Reason"] = reason if reason.present?
|
||||||
|
|
||||||
# Get the redirect URL from query params or construct default
|
# Get the redirect URL from query params or construct default
|
||||||
redirect_url = validate_redirect_url(params[:rd])
|
redirect_url = validate_redirect_url(params[:rd])
|
||||||
base_url = determine_base_url(redirect_url)
|
base_url = determine_base_url(redirect_url)
|
||||||
@@ -145,7 +158,7 @@ module Api
|
|||||||
original_uri = request.headers["X-Forwarded-Uri"] || request.headers["X-Forwarded-Path"] || "/"
|
original_uri = request.headers["X-Forwarded-Uri"] || request.headers["X-Forwarded-Path"] || "/"
|
||||||
|
|
||||||
# Debug logging to see what headers we're getting
|
# Debug logging to see what headers we're getting
|
||||||
Rails.logger.info "ForwardAuth Headers: Host=#{request.headers['Host']}, X-Forwarded-Host=#{original_host}, X-Forwarded-Uri=#{request.headers['X-Forwarded-Uri']}, X-Forwarded-Path=#{request.headers['X-Forwarded-Path']}"
|
Rails.logger.info "ForwardAuth Headers: Host=#{request.headers["Host"]}, X-Forwarded-Host=#{original_host}, X-Forwarded-Uri=#{request.headers["X-Forwarded-Uri"]}, X-Forwarded-Path=#{request.headers["X-Forwarded-Path"]}"
|
||||||
|
|
||||||
original_url = if original_host
|
original_url = if original_host
|
||||||
# Use the forwarded host and URI (original behavior)
|
# Use the forwarded host and URI (original behavior)
|
||||||
@@ -176,6 +189,9 @@ module Api
|
|||||||
def render_forbidden(reason = nil)
|
def render_forbidden(reason = nil)
|
||||||
Rails.logger.info "ForwardAuth: Forbidden - #{reason}"
|
Rails.logger.info "ForwardAuth: Forbidden - #{reason}"
|
||||||
|
|
||||||
|
# Set auth reason header for debugging (like Authelia)
|
||||||
|
response.headers["X-Auth-Reason"] = reason if reason.present?
|
||||||
|
|
||||||
# Return 403 Forbidden
|
# Return 403 Forbidden
|
||||||
head :forbidden
|
head :forbidden
|
||||||
end
|
end
|
||||||
@@ -190,7 +206,7 @@ module Api
|
|||||||
return nil unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
|
return nil unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
|
||||||
|
|
||||||
# Only allow HTTPS in production
|
# Only allow HTTPS in production
|
||||||
return nil unless Rails.env.development? || uri.scheme == 'https'
|
return nil unless Rails.env.development? || uri.scheme == "https"
|
||||||
|
|
||||||
redirect_domain = uri.host.downcase
|
redirect_domain = uri.host.downcase
|
||||||
return nil unless redirect_domain.present?
|
return nil unless redirect_domain.present?
|
||||||
@@ -201,7 +217,6 @@ module Api
|
|||||||
end
|
end
|
||||||
|
|
||||||
matching_app ? url : nil
|
matching_app ? url : nil
|
||||||
|
|
||||||
rescue URI::InvalidURIError
|
rescue URI::InvalidURIError
|
||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
@@ -220,13 +235,13 @@ module Api
|
|||||||
return redirect_url if redirect_url.present?
|
return redirect_url if redirect_url.present?
|
||||||
|
|
||||||
# Try CLINCH_HOST environment variable first
|
# Try CLINCH_HOST environment variable first
|
||||||
if ENV['CLINCH_HOST'].present?
|
if ENV["CLINCH_HOST"].present?
|
||||||
host = ENV['CLINCH_HOST']
|
host = ENV["CLINCH_HOST"]
|
||||||
# Ensure URL has https:// protocol
|
# Ensure URL has https:// protocol
|
||||||
host.match?(/^https?:\/\//) ? host : "https://#{host}"
|
host.match?(/^https?:\/\//) ? host : "https://#{host}"
|
||||||
else
|
else
|
||||||
# Fallback to the request host
|
# Fallback to the request host
|
||||||
request_host = request.host || request.headers['X-Forwarded-Host']
|
request_host = request.host || request.headers["X-Forwarded-Host"]
|
||||||
if request_host.present?
|
if request_host.present?
|
||||||
Rails.logger.warn "ForwardAuth: CLINCH_HOST not set, using request host: #{request_host}"
|
Rails.logger.warn "ForwardAuth: CLINCH_HOST not set, using request host: #{request_host}"
|
||||||
"https://#{request_host}"
|
"https://#{request_host}"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
class ApplicationController < ActionController::Base
|
class ApplicationController < ActionController::Base
|
||||||
include Authentication
|
include Authentication
|
||||||
|
|
||||||
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
|
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
|
||||||
allow_browser versions: :modern
|
allow_browser versions: :modern
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
require 'uri'
|
require "uri"
|
||||||
require 'public_suffix'
|
require "public_suffix"
|
||||||
require 'ipaddr'
|
require "ipaddr"
|
||||||
|
|
||||||
module Authentication
|
module Authentication
|
||||||
extend ActiveSupport::Concern
|
extend ActiveSupport::Concern
|
||||||
@@ -17,133 +17,137 @@ module Authentication
|
|||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
def authenticated?
|
|
||||||
resume_session
|
def authenticated?
|
||||||
|
resume_session
|
||||||
|
end
|
||||||
|
|
||||||
|
def require_authentication
|
||||||
|
resume_session || request_authentication
|
||||||
|
end
|
||||||
|
|
||||||
|
def resume_session
|
||||||
|
Current.session ||= find_session_by_cookie
|
||||||
|
end
|
||||||
|
|
||||||
|
def find_session_by_cookie
|
||||||
|
Session.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id]
|
||||||
|
end
|
||||||
|
|
||||||
|
def request_authentication
|
||||||
|
session[:return_to_after_authenticating] = request.url
|
||||||
|
redirect_to signin_path
|
||||||
|
end
|
||||||
|
|
||||||
|
def after_authentication_url
|
||||||
|
session[:return_to_after_authenticating]
|
||||||
|
session.delete(:return_to_after_authenticating) || root_url
|
||||||
|
end
|
||||||
|
|
||||||
|
def start_new_session_for(user, acr: "1")
|
||||||
|
user.update!(last_sign_in_at: Time.current)
|
||||||
|
user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip, acr: acr).tap do |session|
|
||||||
|
Current.session = session
|
||||||
|
|
||||||
|
# Extract root domain for cross-subdomain cookies (required for forward auth)
|
||||||
|
domain = extract_root_domain(request.host)
|
||||||
|
|
||||||
|
cookie_options = {
|
||||||
|
value: session.id,
|
||||||
|
httponly: true,
|
||||||
|
same_site: :lax,
|
||||||
|
secure: Rails.env.production?
|
||||||
|
}
|
||||||
|
|
||||||
|
# Set domain for cross-subdomain authentication if we can extract it
|
||||||
|
cookie_options[:domain] = domain if domain.present?
|
||||||
|
|
||||||
|
cookies.signed.permanent[:session_id] = cookie_options
|
||||||
|
|
||||||
|
# Create a one-time token for immediate forward auth after authentication
|
||||||
|
# This solves the race condition where browser hasn't processed cookie yet
|
||||||
|
create_forward_auth_token(session)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def terminate_session
|
||||||
|
Current.session.destroy
|
||||||
|
cookies.delete(:session_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Extract root domain for cross-subdomain cookies in SSO forward_auth system.
|
||||||
|
#
|
||||||
|
# PURPOSE: Enables a single authentication session to work across multiple subdomains
|
||||||
|
# by setting cookies with the domain parameter (e.g., .example.com allows access from
|
||||||
|
# both app.example.com and api.example.com).
|
||||||
|
#
|
||||||
|
# CRITICAL: Returns nil for IP addresses (IPv4 and IPv6) and localhost - this is intentional!
|
||||||
|
# When accessing services by IP, there are no subdomains to share cookies with,
|
||||||
|
# and setting a domain cookie would break authentication.
|
||||||
|
#
|
||||||
|
# Uses the Public Suffix List (industry standard maintained by Mozilla) to
|
||||||
|
# correctly handle complex domain patterns like co.uk, com.au, appspot.com, etc.
|
||||||
|
#
|
||||||
|
# Examples:
|
||||||
|
# - app.example.com -> .example.com (enables cross-subdomain SSO)
|
||||||
|
# - api.example.co.uk -> .example.co.uk (handles complex TLDs)
|
||||||
|
# - myapp.appspot.com -> .myapp.appspot.com (handles platform domains)
|
||||||
|
# - localhost -> nil (local development, no domain cookie)
|
||||||
|
# - 192.168.1.1 -> nil (IP access, no domain cookie - prevents SSO breakage)
|
||||||
|
#
|
||||||
|
# @param host [String] The request host (may include port)
|
||||||
|
# @return [String, nil] Root domain with leading dot for cookies, or nil for no domain setting
|
||||||
|
def extract_root_domain(host)
|
||||||
|
return nil if host.blank? || host.match?(/^(localhost|127\.0\.0\.1|::1)$/)
|
||||||
|
|
||||||
|
# Strip port number for domain parsing
|
||||||
|
host_without_port = host.split(":").first
|
||||||
|
|
||||||
|
# Check if it's an IP address (IPv4 or IPv6) - if so, don't set domain cookie
|
||||||
|
begin
|
||||||
|
return nil if IPAddr.new(host_without_port)
|
||||||
|
rescue
|
||||||
|
false
|
||||||
end
|
end
|
||||||
|
|
||||||
def require_authentication
|
# Use Public Suffix List for accurate domain parsing
|
||||||
resume_session || request_authentication
|
domain = PublicSuffix.parse(host_without_port)
|
||||||
end
|
".#{domain.domain}"
|
||||||
|
rescue PublicSuffix::DomainInvalid
|
||||||
|
# Fallback for invalid domains or IPs
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
def resume_session
|
# Create a one-time token for forward auth to handle the race condition
|
||||||
Current.session ||= find_session_by_cookie
|
# where the browser hasn't processed the session cookie yet
|
||||||
end
|
def create_forward_auth_token(session_obj)
|
||||||
|
# Generate a secure random token
|
||||||
|
token = SecureRandom.urlsafe_base64(32)
|
||||||
|
|
||||||
def find_session_by_cookie
|
# Store it with an expiry of 60 seconds
|
||||||
Session.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id]
|
Rails.cache.write(
|
||||||
end
|
"forward_auth_token:#{token}",
|
||||||
|
session_obj.id,
|
||||||
|
expires_in: 60.seconds
|
||||||
|
)
|
||||||
|
|
||||||
def request_authentication
|
# Set the token as a query parameter on the redirect URL
|
||||||
session[:return_to_after_authenticating] = request.url
|
# We need to store this in the controller's session
|
||||||
redirect_to signin_path
|
controller_session = session
|
||||||
end
|
if controller_session[:return_to_after_authenticating].present?
|
||||||
|
original_url = controller_session[:return_to_after_authenticating]
|
||||||
|
uri = URI.parse(original_url)
|
||||||
|
|
||||||
def after_authentication_url
|
# Skip adding fa_token for OAuth URLs (OAuth flow should not have forward auth tokens)
|
||||||
return_url = session[:return_to_after_authenticating]
|
unless uri.path&.start_with?("/oauth/")
|
||||||
final_url = session.delete(:return_to_after_authenticating) || root_url
|
# Add token as query parameter
|
||||||
final_url
|
query_params = URI.decode_www_form(uri.query || "").to_h
|
||||||
end
|
query_params["fa_token"] = token
|
||||||
|
uri.query = URI.encode_www_form(query_params)
|
||||||
|
|
||||||
def start_new_session_for(user)
|
# Update the session with the tokenized URL
|
||||||
user.update!(last_sign_in_at: Time.current)
|
controller_session[:return_to_after_authenticating] = uri.to_s
|
||||||
user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session|
|
|
||||||
Current.session = session
|
|
||||||
|
|
||||||
# Extract root domain for cross-subdomain cookies (required for forward auth)
|
|
||||||
domain = extract_root_domain(request.host)
|
|
||||||
|
|
||||||
cookie_options = {
|
|
||||||
value: session.id,
|
|
||||||
httponly: true,
|
|
||||||
same_site: :lax,
|
|
||||||
secure: Rails.env.production?
|
|
||||||
}
|
|
||||||
|
|
||||||
# Set domain for cross-subdomain authentication if we can extract it
|
|
||||||
cookie_options[:domain] = domain if domain.present?
|
|
||||||
|
|
||||||
cookies.signed.permanent[:session_id] = cookie_options
|
|
||||||
|
|
||||||
# Create a one-time token for immediate forward auth after authentication
|
|
||||||
# This solves the race condition where browser hasn't processed cookie yet
|
|
||||||
create_forward_auth_token(session)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def terminate_session
|
|
||||||
Current.session.destroy
|
|
||||||
cookies.delete(:session_id)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Extract root domain for cross-subdomain cookies in SSO forward_auth system.
|
|
||||||
#
|
|
||||||
# PURPOSE: Enables a single authentication session to work across multiple subdomains
|
|
||||||
# by setting cookies with the domain parameter (e.g., .example.com allows access from
|
|
||||||
# both app.example.com and api.example.com).
|
|
||||||
#
|
|
||||||
# CRITICAL: Returns nil for IP addresses (IPv4 and IPv6) and localhost - this is intentional!
|
|
||||||
# When accessing services by IP, there are no subdomains to share cookies with,
|
|
||||||
# and setting a domain cookie would break authentication.
|
|
||||||
#
|
|
||||||
# Uses the Public Suffix List (industry standard maintained by Mozilla) to
|
|
||||||
# correctly handle complex domain patterns like co.uk, com.au, appspot.com, etc.
|
|
||||||
#
|
|
||||||
# Examples:
|
|
||||||
# - app.example.com -> .example.com (enables cross-subdomain SSO)
|
|
||||||
# - api.example.co.uk -> .example.co.uk (handles complex TLDs)
|
|
||||||
# - myapp.appspot.com -> .myapp.appspot.com (handles platform domains)
|
|
||||||
# - localhost -> nil (local development, no domain cookie)
|
|
||||||
# - 192.168.1.1 -> nil (IP access, no domain cookie - prevents SSO breakage)
|
|
||||||
#
|
|
||||||
# @param host [String] The request host (may include port)
|
|
||||||
# @return [String, nil] Root domain with leading dot for cookies, or nil for no domain setting
|
|
||||||
def extract_root_domain(host)
|
|
||||||
return nil if host.blank? || host.match?(/^(localhost|127\.0\.0\.1|::1)$/)
|
|
||||||
|
|
||||||
# Strip port number for domain parsing
|
|
||||||
host_without_port = host.split(':').first
|
|
||||||
|
|
||||||
# Check if it's an IP address (IPv4 or IPv6) - if so, don't set domain cookie
|
|
||||||
return nil if IPAddr.new(host_without_port) rescue false
|
|
||||||
|
|
||||||
# Use Public Suffix List for accurate domain parsing
|
|
||||||
domain = PublicSuffix.parse(host_without_port)
|
|
||||||
".#{domain.domain}"
|
|
||||||
rescue PublicSuffix::DomainInvalid
|
|
||||||
# Fallback for invalid domains or IPs
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
|
|
||||||
# Create a one-time token for forward auth to handle the race condition
|
|
||||||
# where the browser hasn't processed the session cookie yet
|
|
||||||
def create_forward_auth_token(session_obj)
|
|
||||||
# Generate a secure random token
|
|
||||||
token = SecureRandom.urlsafe_base64(32)
|
|
||||||
|
|
||||||
# Store it with an expiry of 60 seconds
|
|
||||||
Rails.cache.write(
|
|
||||||
"forward_auth_token:#{token}",
|
|
||||||
session_obj.id,
|
|
||||||
expires_in: 60.seconds
|
|
||||||
)
|
|
||||||
|
|
||||||
# Set the token as a query parameter on the redirect URL
|
|
||||||
# We need to store this in the controller's session
|
|
||||||
controller_session = session
|
|
||||||
if controller_session[:return_to_after_authenticating].present?
|
|
||||||
original_url = controller_session[:return_to_after_authenticating]
|
|
||||||
uri = URI.parse(original_url)
|
|
||||||
|
|
||||||
# Skip adding fa_token for OAuth URLs (OAuth flow should not have forward auth tokens)
|
|
||||||
unless uri.path&.start_with?("/oauth/")
|
|
||||||
# Add token as query parameter
|
|
||||||
query_params = URI.decode_www_form(uri.query || "").to_h
|
|
||||||
query_params['fa_token'] = token
|
|
||||||
uri.query = URI.encode_www_form(query_params)
|
|
||||||
|
|
||||||
# Update the session with the tokenized URL
|
|
||||||
controller_session[:return_to_after_authenticating] = uri.to_s
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
class InvitationsController < ApplicationController
|
class InvitationsController < ApplicationController
|
||||||
include Authentication
|
include Authentication
|
||||||
|
|
||||||
allow_unauthenticated_access
|
allow_unauthenticated_access
|
||||||
before_action :set_user_by_invitation_token, only: %i[ show update ]
|
before_action :set_user_by_invitation_token, only: %i[show update]
|
||||||
|
|
||||||
def show
|
def show
|
||||||
# Show the password setup form
|
# Show the password setup form
|
||||||
@@ -35,16 +36,16 @@ class InvitationsController < ApplicationController
|
|||||||
# Check if user is still pending invitation
|
# Check if user is still pending invitation
|
||||||
if @user.nil?
|
if @user.nil?
|
||||||
redirect_to signin_path, alert: "Invitation link is invalid or has expired."
|
redirect_to signin_path, alert: "Invitation link is invalid or has expired."
|
||||||
return false
|
false
|
||||||
elsif @user.pending_invitation?
|
elsif @user.pending_invitation?
|
||||||
# User is valid and pending - proceed
|
# User is valid and pending - proceed
|
||||||
return true
|
true
|
||||||
else
|
else
|
||||||
redirect_to signin_path, alert: "This invitation has already been used or is no longer valid."
|
redirect_to signin_path, alert: "This invitation has already been used or is no longer valid."
|
||||||
return false
|
false
|
||||||
end
|
end
|
||||||
rescue ActiveSupport::MessageVerifier::InvalidSignature
|
rescue ActiveSupport::MessageVerifier::InvalidSignature
|
||||||
redirect_to signin_path, alert: "Invitation link is invalid or has expired."
|
redirect_to signin_path, alert: "Invitation link is invalid or has expired."
|
||||||
return false
|
false
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -3,6 +3,14 @@ class OidcController < ApplicationController
|
|||||||
allow_unauthenticated_access only: [:discovery, :jwks, :token, :revoke, :userinfo, :logout]
|
allow_unauthenticated_access only: [:discovery, :jwks, :token, :revoke, :userinfo, :logout]
|
||||||
skip_before_action :verify_authenticity_token, only: [:token, :revoke, :logout]
|
skip_before_action :verify_authenticity_token, only: [:token, :revoke, :logout]
|
||||||
|
|
||||||
|
# Rate limiting to prevent brute force and abuse
|
||||||
|
rate_limit to: 60, within: 1.minute, only: [:token, :revoke], with: -> {
|
||||||
|
render json: {error: "too_many_requests", error_description: "Rate limit exceeded. Try again later."}, status: :too_many_requests
|
||||||
|
}
|
||||||
|
rate_limit to: 30, within: 1.minute, only: [:authorize, :consent], with: -> {
|
||||||
|
render plain: "Too many authorization attempts. Try again later.", status: :too_many_requests
|
||||||
|
}
|
||||||
|
|
||||||
# GET /.well-known/openid-configuration
|
# GET /.well-known/openid-configuration
|
||||||
def discovery
|
def discovery
|
||||||
base_url = OidcJwtService.issuer_url
|
base_url = OidcJwtService.issuer_url
|
||||||
@@ -18,12 +26,14 @@ class OidcController < ApplicationController
|
|||||||
response_types_supported: ["code"],
|
response_types_supported: ["code"],
|
||||||
response_modes_supported: ["query"],
|
response_modes_supported: ["query"],
|
||||||
grant_types_supported: ["authorization_code", "refresh_token"],
|
grant_types_supported: ["authorization_code", "refresh_token"],
|
||||||
subject_types_supported: ["public"],
|
subject_types_supported: ["pairwise"],
|
||||||
id_token_signing_alg_values_supported: ["RS256"],
|
id_token_signing_alg_values_supported: ["RS256"],
|
||||||
scopes_supported: ["openid", "profile", "email", "groups"],
|
scopes_supported: ["openid", "profile", "email", "groups", "offline_access"],
|
||||||
token_endpoint_auth_methods_supported: ["client_secret_post", "client_secret_basic"],
|
token_endpoint_auth_methods_supported: ["client_secret_post", "client_secret_basic"],
|
||||||
claims_supported: ["sub", "email", "email_verified", "name", "preferred_username", "groups", "admin"],
|
claims_supported: ["sub", "email", "email_verified", "name", "preferred_username", "groups", "admin", "auth_time", "acr", "azp", "at_hash"],
|
||||||
code_challenge_methods_supported: ["plain", "S256"]
|
code_challenge_methods_supported: ["plain", "S256"],
|
||||||
|
backchannel_logout_supported: true,
|
||||||
|
backchannel_logout_session_supported: true
|
||||||
}
|
}
|
||||||
|
|
||||||
render json: config
|
render json: config
|
||||||
@@ -53,7 +63,7 @@ class OidcController < ApplicationController
|
|||||||
error_details << "redirect_uri is required" unless redirect_uri.present?
|
error_details << "redirect_uri is required" unless redirect_uri.present?
|
||||||
error_details << "response_type must be 'code'" unless response_type == "code"
|
error_details << "response_type must be 'code'" unless response_type == "code"
|
||||||
|
|
||||||
render plain: "Invalid request: #{error_details.join(', ')}", status: :bad_request
|
render plain: "Invalid request: #{error_details.join(", ")}", status: :bad_request
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -80,7 +90,7 @@ class OidcController < ApplicationController
|
|||||||
Rails.logger.error "OAuth: Available OIDC applications: #{all_oidc_apps.pluck(:id, :client_id, :name)}"
|
Rails.logger.error "OAuth: Available OIDC applications: #{all_oidc_apps.pluck(:id, :client_id, :name)}"
|
||||||
|
|
||||||
error_msg = if Rails.env.development?
|
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(', ')}"
|
"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
|
else
|
||||||
"Invalid request: Application not found"
|
"Invalid request: Application not found"
|
||||||
end
|
end
|
||||||
@@ -89,13 +99,13 @@ class OidcController < ApplicationController
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Validate redirect URI
|
# Validate redirect URI first (required before we can safely redirect with errors)
|
||||||
unless @application.parsed_redirect_uris.include?(redirect_uri)
|
unless @application.parsed_redirect_uris.include?(redirect_uri)
|
||||||
Rails.logger.error "OAuth: Invalid request - redirect URI mismatch. Expected: #{@application.parsed_redirect_uris}, Got: #{redirect_uri}"
|
Rails.logger.error "OAuth: Invalid request - redirect URI mismatch. Expected: #{@application.parsed_redirect_uris}, Got: #{redirect_uri}"
|
||||||
|
|
||||||
# For development, show detailed error
|
# For development, show detailed error
|
||||||
error_msg = if Rails.env.development?
|
error_msg = if Rails.env.development?
|
||||||
"Invalid request: Redirect URI mismatch. Application is configured for: #{@application.parsed_redirect_uris.join(', ')}, but received: #{redirect_uri}"
|
"Invalid request: Redirect URI mismatch. Application is configured for: #{@application.parsed_redirect_uris.join(", ")}, but received: #{redirect_uri}"
|
||||||
else
|
else
|
||||||
"Invalid request: Redirect URI not registered for this application"
|
"Invalid request: Redirect URI not registered for this application"
|
||||||
end
|
end
|
||||||
@@ -104,6 +114,15 @@ class OidcController < ApplicationController
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Check if application is active (now we can safely redirect with error)
|
||||||
|
unless @application.active?
|
||||||
|
Rails.logger.error "OAuth: Application is not active: #{@application.name}"
|
||||||
|
error_uri = "#{redirect_uri}?error=unauthorized_client&error_description=Application+is+not+active"
|
||||||
|
error_uri += "&state=#{CGI.escape(state)}" if state.present?
|
||||||
|
redirect_to error_uri, allow_other_host: true
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
# Check if user is authenticated
|
# Check if user is authenticated
|
||||||
unless authenticated?
|
unless authenticated?
|
||||||
# Store OAuth parameters in session and redirect to sign in
|
# Store OAuth parameters in session and redirect to sign in
|
||||||
@@ -135,22 +154,22 @@ class OidcController < ApplicationController
|
|||||||
existing_consent = user.has_oidc_consent?(@application, requested_scopes)
|
existing_consent = user.has_oidc_consent?(@application, requested_scopes)
|
||||||
if existing_consent
|
if existing_consent
|
||||||
# User has already consented, generate authorization code directly
|
# User has already consented, generate authorization code directly
|
||||||
code = SecureRandom.urlsafe_base64(32)
|
|
||||||
auth_code = OidcAuthorizationCode.create!(
|
auth_code = OidcAuthorizationCode.create!(
|
||||||
application: @application,
|
application: @application,
|
||||||
user: user,
|
user: user,
|
||||||
code: code,
|
|
||||||
redirect_uri: redirect_uri,
|
redirect_uri: redirect_uri,
|
||||||
scope: scope,
|
scope: scope,
|
||||||
nonce: nonce,
|
nonce: nonce,
|
||||||
code_challenge: code_challenge,
|
code_challenge: code_challenge,
|
||||||
code_challenge_method: code_challenge_method,
|
code_challenge_method: code_challenge_method,
|
||||||
|
auth_time: Current.session.created_at.to_i,
|
||||||
|
acr: Current.session.acr,
|
||||||
expires_at: 10.minutes.from_now
|
expires_at: 10.minutes.from_now
|
||||||
)
|
)
|
||||||
|
|
||||||
# Redirect back to client with authorization code
|
# Redirect back to client with authorization code (plaintext)
|
||||||
redirect_uri = "#{redirect_uri}?code=#{code}"
|
redirect_uri = "#{redirect_uri}?code=#{auth_code.plaintext_code}"
|
||||||
redirect_uri += "&state=#{state}" if state.present?
|
redirect_uri += "&state=#{CGI.escape(state)}" if state.present?
|
||||||
redirect_to redirect_uri, allow_other_host: true
|
redirect_to redirect_uri, allow_other_host: true
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -204,49 +223,55 @@ class OidcController < ApplicationController
|
|||||||
# User denied consent
|
# User denied consent
|
||||||
if params[:deny].present?
|
if params[:deny].present?
|
||||||
session.delete(:oauth_params)
|
session.delete(:oauth_params)
|
||||||
error_uri = "#{oauth_params['redirect_uri']}?error=access_denied"
|
error_uri = "#{oauth_params["redirect_uri"]}?error=access_denied"
|
||||||
error_uri += "&state=#{oauth_params['state']}" if oauth_params['state']
|
error_uri += "&state=#{CGI.escape(oauth_params["state"])}" if oauth_params["state"]
|
||||||
redirect_to error_uri, allow_other_host: true
|
redirect_to error_uri, allow_other_host: true
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Find the application
|
# Find the application
|
||||||
client_id = oauth_params['client_id']
|
client_id = oauth_params["client_id"]
|
||||||
application = Application.find_by(client_id: client_id, app_type: "oidc")
|
application = Application.find_by(client_id: client_id, app_type: "oidc")
|
||||||
|
|
||||||
|
# Check if application is active (redirect with OAuth error)
|
||||||
|
unless application&.active?
|
||||||
|
Rails.logger.error "OAuth: Application is not active: #{application&.name || client_id}"
|
||||||
|
session.delete(:oauth_params)
|
||||||
|
error_uri = "#{oauth_params["redirect_uri"]}?error=unauthorized_client&error_description=Application+is+not+active"
|
||||||
|
error_uri += "&state=#{CGI.escape(oauth_params["state"])}" if oauth_params["state"].present?
|
||||||
|
redirect_to error_uri, allow_other_host: true
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
user = Current.session.user
|
user = Current.session.user
|
||||||
|
|
||||||
# Record user consent
|
# Record user consent
|
||||||
requested_scopes = oauth_params['scope'].split(' ')
|
requested_scopes = oauth_params["scope"].split(" ")
|
||||||
OidcUserConsent.upsert(
|
consent = OidcUserConsent.find_or_initialize_by(user: user, application: application)
|
||||||
{
|
consent.scopes_granted = requested_scopes.join(" ")
|
||||||
user_id: user.id,
|
consent.granted_at = Time.current
|
||||||
application_id: application.id,
|
consent.save!
|
||||||
scopes_granted: requested_scopes.join(' '),
|
|
||||||
granted_at: Time.current
|
|
||||||
},
|
|
||||||
unique_by: [:user_id, :application_id]
|
|
||||||
)
|
|
||||||
|
|
||||||
# Generate authorization code
|
# Generate authorization code
|
||||||
code = SecureRandom.urlsafe_base64(32)
|
|
||||||
auth_code = OidcAuthorizationCode.create!(
|
auth_code = OidcAuthorizationCode.create!(
|
||||||
application: application,
|
application: application,
|
||||||
user: user,
|
user: user,
|
||||||
code: code,
|
redirect_uri: oauth_params["redirect_uri"],
|
||||||
redirect_uri: oauth_params['redirect_uri'],
|
scope: oauth_params["scope"],
|
||||||
scope: oauth_params['scope'],
|
nonce: oauth_params["nonce"],
|
||||||
nonce: oauth_params['nonce'],
|
code_challenge: oauth_params["code_challenge"],
|
||||||
code_challenge: oauth_params['code_challenge'],
|
code_challenge_method: oauth_params["code_challenge_method"],
|
||||||
code_challenge_method: oauth_params['code_challenge_method'],
|
auth_time: Current.session.created_at.to_i,
|
||||||
|
acr: Current.session.acr,
|
||||||
expires_at: 10.minutes.from_now
|
expires_at: 10.minutes.from_now
|
||||||
)
|
)
|
||||||
|
|
||||||
# Clear OAuth params from session
|
# Clear OAuth params from session
|
||||||
session.delete(:oauth_params)
|
session.delete(:oauth_params)
|
||||||
|
|
||||||
# Redirect back to client with authorization code
|
# Redirect back to client with authorization code (plaintext)
|
||||||
redirect_uri = "#{oauth_params['redirect_uri']}?code=#{code}"
|
redirect_uri = "#{oauth_params["redirect_uri"]}?code=#{auth_code.plaintext_code}"
|
||||||
redirect_uri += "&state=#{oauth_params['state']}" if oauth_params['state']
|
redirect_uri += "&state=#{CGI.escape(oauth_params["state"])}" if oauth_params["state"]
|
||||||
|
|
||||||
redirect_to redirect_uri, allow_other_host: true
|
redirect_to redirect_uri, allow_other_host: true
|
||||||
end
|
end
|
||||||
@@ -261,24 +286,42 @@ class OidcController < ApplicationController
|
|||||||
when "refresh_token"
|
when "refresh_token"
|
||||||
handle_refresh_token_grant
|
handle_refresh_token_grant
|
||||||
else
|
else
|
||||||
render json: { error: "unsupported_grant_type" }, status: :bad_request
|
render json: {error: "unsupported_grant_type"}, status: :bad_request
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_authorization_code_grant
|
def handle_authorization_code_grant
|
||||||
|
|
||||||
# Get client credentials from Authorization header or params
|
# Get client credentials from Authorization header or params
|
||||||
client_id, client_secret = extract_client_credentials
|
client_id, client_secret = extract_client_credentials
|
||||||
|
|
||||||
unless client_id && client_secret
|
unless client_id
|
||||||
render json: { error: "invalid_client" }, status: :unauthorized
|
render json: {error: "invalid_client", error_description: "client_id is required"}, status: :unauthorized
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Find and validate the application
|
# Find the application
|
||||||
application = Application.find_by(client_id: client_id)
|
application = Application.find_by(client_id: client_id)
|
||||||
unless application && application.authenticate_client_secret(client_secret)
|
unless application
|
||||||
render json: { error: "invalid_client" }, status: :unauthorized
|
render json: {error: "invalid_client", error_description: "Unknown client"}, status: :unauthorized
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Validate client credentials based on client type
|
||||||
|
if application.public_client?
|
||||||
|
# Public clients don't have a secret - they MUST use PKCE (checked later)
|
||||||
|
Rails.logger.info "OAuth: Public client authentication for #{application.name}"
|
||||||
|
else
|
||||||
|
# Confidential clients MUST provide valid client_secret
|
||||||
|
unless client_secret.present? && application.authenticate_client_secret(client_secret)
|
||||||
|
render json: {error: "invalid_client", error_description: "Invalid client credentials"}, status: :unauthorized
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if application is active
|
||||||
|
unless application.active?
|
||||||
|
Rails.logger.error "OAuth: Token request for inactive application: #{application.name}"
|
||||||
|
render json: {error: "invalid_client", error_description: "Application is not active"}, status: :forbidden
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -287,13 +330,11 @@ class OidcController < ApplicationController
|
|||||||
redirect_uri = params[:redirect_uri]
|
redirect_uri = params[:redirect_uri]
|
||||||
code_verifier = params[:code_verifier]
|
code_verifier = params[:code_verifier]
|
||||||
|
|
||||||
auth_code = OidcAuthorizationCode.find_by(
|
# Find authorization code using HMAC verification
|
||||||
application: application,
|
auth_code = OidcAuthorizationCode.find_by_plaintext(code)
|
||||||
code: code
|
|
||||||
)
|
|
||||||
|
|
||||||
unless auth_code
|
unless auth_code && auth_code.application == application
|
||||||
render json: { error: "invalid_grant" }, status: :bad_request
|
render json: {error: "invalid_grant"}, status: :bad_request
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -324,18 +365,18 @@ class OidcController < ApplicationController
|
|||||||
|
|
||||||
# Check if code is expired
|
# Check if code is expired
|
||||||
if auth_code.expires_at < Time.current
|
if auth_code.expires_at < Time.current
|
||||||
render json: { error: "invalid_grant", error_description: "Authorization code expired" }, status: :bad_request
|
render json: {error: "invalid_grant", error_description: "Authorization code expired"}, status: :bad_request
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Validate redirect URI matches
|
# Validate redirect URI matches
|
||||||
unless auth_code.redirect_uri == redirect_uri
|
unless auth_code.redirect_uri == redirect_uri
|
||||||
render json: { error: "invalid_grant", error_description: "Redirect URI mismatch" }, status: :bad_request
|
render json: {error: "invalid_grant", error_description: "Redirect URI mismatch"}, status: :bad_request
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Validate PKCE if code challenge is present
|
# Validate PKCE - required for public clients and optionally for confidential clients
|
||||||
pkce_result = validate_pkce(auth_code, code_verifier)
|
pkce_result = validate_pkce(application, auth_code, code_verifier)
|
||||||
unless pkce_result[:valid]
|
unless pkce_result[:valid]
|
||||||
render json: {
|
render json: {
|
||||||
error: pkce_result[:error],
|
error: pkce_result[:error],
|
||||||
@@ -362,11 +403,31 @@ class OidcController < ApplicationController
|
|||||||
application: application,
|
application: application,
|
||||||
user: user,
|
user: user,
|
||||||
oidc_access_token: access_token_record,
|
oidc_access_token: access_token_record,
|
||||||
scope: auth_code.scope
|
scope: auth_code.scope,
|
||||||
|
auth_time: auth_code.auth_time,
|
||||||
|
acr: auth_code.acr
|
||||||
)
|
)
|
||||||
|
|
||||||
# Generate ID token (JWT)
|
# Find user consent for this application
|
||||||
id_token = OidcJwtService.generate_id_token(user, application, nonce: auth_code.nonce)
|
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, at_hash, auth_time, and acr
|
||||||
|
# auth_time and acr come from the authorization code (captured at /authorize time)
|
||||||
|
id_token = OidcJwtService.generate_id_token(
|
||||||
|
user,
|
||||||
|
application,
|
||||||
|
consent: consent,
|
||||||
|
nonce: auth_code.nonce,
|
||||||
|
access_token: access_token_record.plaintext_token,
|
||||||
|
auth_time: auth_code.auth_time,
|
||||||
|
acr: auth_code.acr
|
||||||
|
)
|
||||||
|
|
||||||
# Return tokens
|
# Return tokens
|
||||||
render json: {
|
render json: {
|
||||||
@@ -379,7 +440,7 @@ class OidcController < ApplicationController
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
rescue ActiveRecord::RecordNotFound
|
rescue ActiveRecord::RecordNotFound
|
||||||
render json: { error: "invalid_grant" }, status: :bad_request
|
render json: {error: "invalid_grant"}, status: :bad_request
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -387,40 +448,56 @@ class OidcController < ApplicationController
|
|||||||
# Get client credentials from Authorization header or params
|
# Get client credentials from Authorization header or params
|
||||||
client_id, client_secret = extract_client_credentials
|
client_id, client_secret = extract_client_credentials
|
||||||
|
|
||||||
unless client_id && client_secret
|
unless client_id
|
||||||
render json: { error: "invalid_client" }, status: :unauthorized
|
render json: {error: "invalid_client", error_description: "client_id is required"}, status: :unauthorized
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Find and validate the application
|
# Find the application
|
||||||
application = Application.find_by(client_id: client_id)
|
application = Application.find_by(client_id: client_id)
|
||||||
unless application && application.authenticate_client_secret(client_secret)
|
unless application
|
||||||
render json: { error: "invalid_client" }, status: :unauthorized
|
render json: {error: "invalid_client", error_description: "Unknown client"}, status: :unauthorized
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Validate client credentials based on client type
|
||||||
|
if application.public_client?
|
||||||
|
# Public clients don't have a secret
|
||||||
|
Rails.logger.info "OAuth: Public client refresh token request for #{application.name}"
|
||||||
|
else
|
||||||
|
# Confidential clients MUST provide valid client_secret
|
||||||
|
unless client_secret.present? && application.authenticate_client_secret(client_secret)
|
||||||
|
render json: {error: "invalid_client", error_description: "Invalid client credentials"}, status: :unauthorized
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if application is active
|
||||||
|
unless application.active?
|
||||||
|
Rails.logger.error "OAuth: Refresh token request for inactive application: #{application.name}"
|
||||||
|
render json: {error: "invalid_client", error_description: "Application is not active"}, status: :forbidden
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Get the refresh token
|
# Get the refresh token
|
||||||
refresh_token = params[:refresh_token]
|
refresh_token = params[:refresh_token]
|
||||||
unless refresh_token.present?
|
unless refresh_token.present?
|
||||||
render json: { error: "invalid_request", error_description: "refresh_token is required" }, status: :bad_request
|
render json: {error: "invalid_request", error_description: "refresh_token is required"}, status: :bad_request
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Find the refresh token record
|
# Find the refresh token record using indexed token prefix lookup
|
||||||
# Note: This is inefficient with BCrypt hashing, but necessary for security
|
refresh_token_record = OidcRefreshToken.find_by_token(refresh_token)
|
||||||
# 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
|
# Verify the token belongs to the correct application
|
||||||
render json: { error: "invalid_grant", error_description: "Invalid refresh token" }, status: :bad_request
|
unless refresh_token_record && refresh_token_record.application == application
|
||||||
|
render json: {error: "invalid_grant", error_description: "Invalid refresh token"}, status: :bad_request
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Check if refresh token is expired
|
# Check if refresh token is expired
|
||||||
if refresh_token_record.expired?
|
if refresh_token_record.expired?
|
||||||
render json: { error: "invalid_grant", error_description: "Refresh token expired" }, status: :bad_request
|
render json: {error: "invalid_grant", error_description: "Refresh token expired"}, status: :bad_request
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -431,7 +508,7 @@ class OidcController < ApplicationController
|
|||||||
Rails.logger.warn "OAuth Security: Revoked refresh token reuse detected for token family #{refresh_token_record.token_family_id}"
|
Rails.logger.warn "OAuth Security: Revoked refresh token reuse detected for token family #{refresh_token_record.token_family_id}"
|
||||||
refresh_token_record.revoke_family!
|
refresh_token_record.revoke_family!
|
||||||
|
|
||||||
render json: { error: "invalid_grant", error_description: "Refresh token has been revoked" }, status: :bad_request
|
render json: {error: "invalid_grant", error_description: "Refresh token has been revoked"}, status: :bad_request
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -454,11 +531,30 @@ class OidcController < ApplicationController
|
|||||||
user: user,
|
user: user,
|
||||||
oidc_access_token: new_access_token,
|
oidc_access_token: new_access_token,
|
||||||
scope: refresh_token_record.scope,
|
scope: refresh_token_record.scope,
|
||||||
token_family_id: refresh_token_record.token_family_id # Keep same family for rotation tracking
|
token_family_id: refresh_token_record.token_family_id, # Keep same family for rotation tracking
|
||||||
|
auth_time: refresh_token_record.auth_time, # Carry over original auth_time
|
||||||
|
acr: refresh_token_record.acr # Carry over original acr
|
||||||
)
|
)
|
||||||
|
|
||||||
# Generate new ID token (JWT, no nonce for refresh grants)
|
# Find user consent for this application
|
||||||
id_token = OidcJwtService.generate_id_token(user, 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, at_hash, auth_time, acr; no nonce for refresh grants)
|
||||||
|
# auth_time and acr come from the original refresh token (carried over from initial auth)
|
||||||
|
id_token = OidcJwtService.generate_id_token(
|
||||||
|
user,
|
||||||
|
application,
|
||||||
|
consent: consent,
|
||||||
|
access_token: new_access_token.plaintext_token,
|
||||||
|
auth_time: refresh_token_record.auth_time,
|
||||||
|
acr: refresh_token_record.acr
|
||||||
|
)
|
||||||
|
|
||||||
# Return new tokens
|
# Return new tokens
|
||||||
render json: {
|
render json: {
|
||||||
@@ -470,7 +566,7 @@ class OidcController < ApplicationController
|
|||||||
scope: refresh_token_record.scope
|
scope: refresh_token_record.scope
|
||||||
}
|
}
|
||||||
rescue ActiveRecord::RecordNotFound
|
rescue ActiveRecord::RecordNotFound
|
||||||
render json: { error: "invalid_grant" }, status: :bad_request
|
render json: {error: "invalid_grant"}, status: :bad_request
|
||||||
end
|
end
|
||||||
|
|
||||||
# GET /oauth/userinfo
|
# GET /oauth/userinfo
|
||||||
@@ -491,6 +587,13 @@ class OidcController < ApplicationController
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Check if application is active (immediate cutoff when app is disabled)
|
||||||
|
unless access_token.application&.active?
|
||||||
|
Rails.logger.warn "OAuth: Userinfo request for inactive application: #{access_token.application&.name}"
|
||||||
|
head :forbidden
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
# Get the user (with fresh data from database)
|
# Get the user (with fresh data from database)
|
||||||
user = access_token.user
|
user = access_token.user
|
||||||
unless user
|
unless user
|
||||||
@@ -498,9 +601,13 @@ class OidcController < ApplicationController
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# 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
|
# Return user claims
|
||||||
claims = {
|
claims = {
|
||||||
sub: user.id.to_s,
|
sub: subject,
|
||||||
email: user.email_address,
|
email: user.email_address,
|
||||||
email_verified: true,
|
email_verified: true,
|
||||||
preferred_username: user.email_address,
|
preferred_username: user.email_address,
|
||||||
@@ -512,9 +619,6 @@ class OidcController < ApplicationController
|
|||||||
claims[:groups] = user.groups.pluck(:name)
|
claims[:groups] = user.groups.pluck(:name)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Add admin claim if user is admin
|
|
||||||
claims[:admin] = true if user.admin?
|
|
||||||
|
|
||||||
# Merge custom claims from groups
|
# Merge custom claims from groups
|
||||||
user.groups.each do |group|
|
user.groups.each do |group|
|
||||||
claims.merge!(group.parsed_custom_claims)
|
claims.merge!(group.parsed_custom_claims)
|
||||||
@@ -523,6 +627,10 @@ class OidcController < ApplicationController
|
|||||||
# Merge custom claims from user (overrides group claims)
|
# Merge custom claims from user (overrides group claims)
|
||||||
claims.merge!(user.parsed_custom_claims)
|
claims.merge!(user.parsed_custom_claims)
|
||||||
|
|
||||||
|
# Merge app-specific custom claims (highest priority)
|
||||||
|
application = access_token.application
|
||||||
|
claims.merge!(application.custom_claims_for_user(user))
|
||||||
|
|
||||||
render json: claims
|
render json: claims
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -542,19 +650,26 @@ class OidcController < ApplicationController
|
|||||||
|
|
||||||
# Find and validate the application
|
# Find and validate the application
|
||||||
application = Application.find_by(client_id: client_id)
|
application = Application.find_by(client_id: client_id)
|
||||||
unless application && application.authenticate_client_secret(client_secret)
|
unless application&.authenticate_client_secret(client_secret)
|
||||||
Rails.logger.warn "OAuth: Token revocation attempted for invalid application: #{client_id}"
|
Rails.logger.warn "OAuth: Token revocation attempted for invalid application: #{client_id}"
|
||||||
head :ok
|
head :ok
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Check if application is active (RFC 7009: still return 200 OK for privacy)
|
||||||
|
unless application.active?
|
||||||
|
Rails.logger.warn "OAuth: Token revocation attempted for inactive application: #{application.name}"
|
||||||
|
head :ok
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
# Get the token to revoke
|
# Get the token to revoke
|
||||||
token = params[:token]
|
token = params[:token]
|
||||||
token_type_hint = params[:token_type_hint] # Optional hint: "access_token" or "refresh_token"
|
token_type_hint = params[:token_type_hint] # Optional hint: "access_token" or "refresh_token"
|
||||||
|
|
||||||
unless token.present?
|
unless token.present?
|
||||||
# RFC 7009: Missing token parameter is an error
|
# RFC 7009: Missing token parameter is an error
|
||||||
render json: { error: "invalid_request", error_description: "token parameter is required" }, status: :bad_request
|
render json: {error: "invalid_request", error_description: "token parameter is required"}, status: :bad_request
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -564,9 +679,7 @@ class OidcController < ApplicationController
|
|||||||
|
|
||||||
if token_type_hint == "refresh_token" || token_type_hint.nil?
|
if token_type_hint == "refresh_token" || token_type_hint.nil?
|
||||||
# Try to find as refresh token
|
# Try to find as refresh token
|
||||||
refresh_token_record = OidcRefreshToken.where(application: application).find do |rt|
|
refresh_token_record = OidcRefreshToken.find_by_token(token)
|
||||||
rt.token_matches?(token)
|
|
||||||
end
|
|
||||||
|
|
||||||
if refresh_token_record
|
if refresh_token_record
|
||||||
refresh_token_record.revoke!
|
refresh_token_record.revoke!
|
||||||
@@ -577,14 +690,12 @@ class OidcController < ApplicationController
|
|||||||
|
|
||||||
if !revoked && (token_type_hint == "access_token" || token_type_hint.nil?)
|
if !revoked && (token_type_hint == "access_token" || token_type_hint.nil?)
|
||||||
# Try to find as access token
|
# Try to find as access token
|
||||||
access_token_record = OidcAccessToken.where(application: application).find do |at|
|
access_token_record = OidcAccessToken.find_by_token(token)
|
||||||
at.token_matches?(token)
|
|
||||||
end
|
|
||||||
|
|
||||||
if access_token_record
|
if access_token_record
|
||||||
access_token_record.revoke!
|
access_token_record.revoke!
|
||||||
Rails.logger.info "OAuth: Access token revoked for application #{application.name}"
|
Rails.logger.info "OAuth: Access token revoked for application #{application.name}"
|
||||||
revoked = true
|
true
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -598,22 +709,35 @@ class OidcController < ApplicationController
|
|||||||
# OpenID Connect RP-Initiated Logout
|
# OpenID Connect RP-Initiated Logout
|
||||||
# Handle id_token_hint and post_logout_redirect_uri parameters
|
# Handle id_token_hint and post_logout_redirect_uri parameters
|
||||||
|
|
||||||
id_token_hint = params[:id_token_hint]
|
params[:id_token_hint]
|
||||||
post_logout_redirect_uri = params[:post_logout_redirect_uri]
|
post_logout_redirect_uri = params[:post_logout_redirect_uri]
|
||||||
state = params[:state]
|
state = params[:state]
|
||||||
|
|
||||||
# If user is authenticated, log them out
|
# If user is authenticated, log them out
|
||||||
if authenticated?
|
if authenticated?
|
||||||
|
user = Current.session.user
|
||||||
|
|
||||||
|
# Send backchannel logout notifications to all connected applications
|
||||||
|
send_backchannel_logout_notifications(user)
|
||||||
|
|
||||||
# Invalidate the current session
|
# Invalidate the current session
|
||||||
Current.session&.destroy
|
Current.session&.destroy
|
||||||
reset_session
|
reset_session
|
||||||
end
|
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?
|
if post_logout_redirect_uri.present?
|
||||||
redirect_uri = post_logout_redirect_uri
|
validated_uri = validate_logout_redirect_uri(post_logout_redirect_uri)
|
||||||
redirect_uri += "?state=#{state}" if state.present?
|
|
||||||
redirect_to redirect_uri, allow_other_host: true
|
if validated_uri
|
||||||
|
redirect_uri = validated_uri
|
||||||
|
redirect_uri += "?state=#{CGI.escape(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
|
else
|
||||||
# Default redirect to home page
|
# Default redirect to home page
|
||||||
redirect_to root_path
|
redirect_to root_path
|
||||||
@@ -622,11 +746,26 @@ class OidcController < ApplicationController
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def validate_pkce(auth_code, code_verifier)
|
def validate_pkce(application, auth_code, code_verifier)
|
||||||
# Skip PKCE validation if no code challenge was stored (legacy clients)
|
# Check if PKCE is required for this application
|
||||||
return { valid: true } unless auth_code.code_challenge.present?
|
pkce_required = application.requires_pkce?
|
||||||
|
pkce_provided = auth_code.code_challenge.present?
|
||||||
|
|
||||||
# PKCE is required but no verifier provided
|
# If PKCE is required but wasn't provided during authorization
|
||||||
|
if pkce_required && !pkce_provided
|
||||||
|
client_type = application.public_client? ? "public clients" : "this application"
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: "invalid_request",
|
||||||
|
error_description: "PKCE is required for #{client_type}. code_challenge must be provided during authorization.",
|
||||||
|
status: :bad_request
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Skip validation if no code challenge was stored (legacy clients without PKCE requirement)
|
||||||
|
return {valid: true} unless pkce_provided
|
||||||
|
|
||||||
|
# PKCE was provided during authorization but no verifier sent with token request
|
||||||
unless code_verifier.present?
|
unless code_verifier.present?
|
||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
@@ -648,18 +787,18 @@ class OidcController < ApplicationController
|
|||||||
|
|
||||||
# Recreate code challenge based on method
|
# Recreate code challenge based on method
|
||||||
expected_challenge = case auth_code.code_challenge_method
|
expected_challenge = case auth_code.code_challenge_method
|
||||||
when "plain"
|
when "plain"
|
||||||
code_verifier
|
code_verifier
|
||||||
when "S256"
|
when "S256"
|
||||||
Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false)
|
Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false)
|
||||||
else
|
else
|
||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
error: "server_error",
|
error: "server_error",
|
||||||
error_description: "Unsupported code challenge method",
|
error_description: "Unsupported code challenge method",
|
||||||
status: :internal_server_error
|
status: :internal_server_error
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
# Validate the code challenge
|
# Validate the code challenge
|
||||||
unless auth_code.code_challenge == expected_challenge
|
unless auth_code.code_challenge == expected_challenge
|
||||||
@@ -671,7 +810,7 @@ class OidcController < ApplicationController
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
{ valid: true }
|
{valid: true}
|
||||||
end
|
end
|
||||||
|
|
||||||
def extract_client_credentials
|
def extract_client_credentials
|
||||||
@@ -685,4 +824,76 @@ class OidcController < ApplicationController
|
|||||||
[params[:client_id], params[:client_secret]]
|
[params[:client_id], params[:client_secret]]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def validate_logout_redirect_uri(uri)
|
||||||
|
return nil unless uri.present?
|
||||||
|
|
||||||
|
begin
|
||||||
|
parsed_uri = URI.parse(uri)
|
||||||
|
|
||||||
|
# Only allow HTTP/HTTPS schemes (prevent javascript:, data:, etc.)
|
||||||
|
return nil unless parsed_uri.is_a?(URI::HTTP) || parsed_uri.is_a?(URI::HTTPS)
|
||||||
|
|
||||||
|
# Only allow HTTPS in production
|
||||||
|
return nil if Rails.env.production? && parsed_uri.scheme != "https"
|
||||||
|
|
||||||
|
# Check if URI matches any registered OIDC application's redirect URIs
|
||||||
|
# According to OIDC spec, post_logout_redirect_uri should be pre-registered
|
||||||
|
Application.oidc.active.find_each do |app|
|
||||||
|
# Check if this URI matches any of the app's registered redirect URIs
|
||||||
|
if app.parsed_redirect_uris.any? { |registered_uri| logout_uri_matches?(uri, registered_uri) }
|
||||||
|
return uri
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# No matching application found
|
||||||
|
nil
|
||||||
|
rescue URI::InvalidURIError
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if logout URI matches a registered redirect URI
|
||||||
|
# More lenient than exact match - allows same host/path with different query params
|
||||||
|
def logout_uri_matches?(provided, registered)
|
||||||
|
# Exact match is always valid
|
||||||
|
return true if provided == registered
|
||||||
|
|
||||||
|
# Parse both URIs to compare components
|
||||||
|
begin
|
||||||
|
provided_parsed = URI.parse(provided)
|
||||||
|
registered_parsed = URI.parse(registered)
|
||||||
|
|
||||||
|
# Match if scheme, host, port, and path are the same
|
||||||
|
# (allows different query params which is common for logout redirects)
|
||||||
|
provided_parsed.scheme == registered_parsed.scheme &&
|
||||||
|
provided_parsed.host == registered_parsed.host &&
|
||||||
|
provided_parsed.port == registered_parsed.port &&
|
||||||
|
provided_parsed.path == registered_parsed.path
|
||||||
|
rescue URI::InvalidURIError
|
||||||
|
false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def send_backchannel_logout_notifications(user)
|
||||||
|
# Find all active OIDC consents for this user
|
||||||
|
consents = OidcUserConsent.where(user: user).includes(:application)
|
||||||
|
|
||||||
|
consents.each do |consent|
|
||||||
|
# Skip if application doesn't support backchannel logout
|
||||||
|
next unless consent.application.supports_backchannel_logout?
|
||||||
|
|
||||||
|
# Enqueue background job to send logout notification
|
||||||
|
BackchannelLogoutJob.perform_later(
|
||||||
|
user_id: user.id,
|
||||||
|
application_id: consent.application.id,
|
||||||
|
consent_sid: consent.sid
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
Rails.logger.info "OidcController: Enqueued #{consents.count} backchannel logout notifications for user #{user.id}"
|
||||||
|
rescue => e
|
||||||
|
# Log error but don't block logout
|
||||||
|
Rails.logger.error "OidcController: Failed to enqueue backchannel logout: #{e.class} - #{e.message}"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
class PasswordsController < ApplicationController
|
class PasswordsController < ApplicationController
|
||||||
allow_unauthenticated_access
|
allow_unauthenticated_access
|
||||||
before_action :set_user_by_token, only: %i[ edit update ]
|
before_action :set_user_by_token, only: %i[edit update]
|
||||||
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_password_path, alert: "Try again later." }
|
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_password_path, alert: "Try again later." }
|
||||||
|
|
||||||
def new
|
def new
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
if user = User.find_by(email_address: params[:email_address])
|
if (user = User.find_by(email_address: params[:email_address]))
|
||||||
PasswordsMailer.reset(user).deliver_later
|
PasswordsMailer.reset(user).deliver_later
|
||||||
end
|
end
|
||||||
|
|
||||||
redirect_to new_session_path, notice: "Password reset instructions sent (if user with that email address exists)."
|
redirect_to signin_path, notice: "Password reset instructions sent (if user with that email address exists)."
|
||||||
end
|
end
|
||||||
|
|
||||||
def edit
|
def edit
|
||||||
@@ -20,16 +20,18 @@ class PasswordsController < ApplicationController
|
|||||||
def update
|
def update
|
||||||
if @user.update(params.permit(:password, :password_confirmation))
|
if @user.update(params.permit(:password, :password_confirmation))
|
||||||
@user.sessions.destroy_all
|
@user.sessions.destroy_all
|
||||||
redirect_to new_session_path, notice: "Password has been reset."
|
redirect_to signin_path, notice: "Password has been reset."
|
||||||
else
|
else
|
||||||
redirect_to edit_password_path(params[:token]), alert: "Passwords did not match."
|
redirect_to edit_password_path(params[:token]), alert: "Passwords did not match."
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
def set_user_by_token
|
|
||||||
@user = User.find_by_token_for(:password_reset, params[:token])
|
def set_user_by_token
|
||||||
rescue ActiveSupport::MessageVerifier::InvalidSignature
|
@user = User.find_by_token_for(:password_reset, params[:token])
|
||||||
redirect_to new_password_path, alert: "Password reset link is invalid or has expired."
|
redirect_to new_password_path, alert: "Password reset link is invalid or has expired." if @user.nil?
|
||||||
end
|
rescue ActiveSupport::MessageVerifier::InvalidSignature
|
||||||
|
redirect_to new_password_path, alert: "Password reset link is invalid or has expired."
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -19,13 +19,21 @@ class ProfilesController < ApplicationController
|
|||||||
else
|
else
|
||||||
render :show, status: :unprocessable_entity
|
render :show, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
else
|
elsif params[:user][:email_address].present?
|
||||||
# Updating email
|
# Updating email - requires current password (security: prevents account takeover)
|
||||||
|
unless @user.authenticate(params[:user][:current_password])
|
||||||
|
@user.errors.add(:current_password, "is required to change email")
|
||||||
|
render :show, status: :unprocessable_entity
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
if @user.update(email_params)
|
if @user.update(email_params)
|
||||||
redirect_to profile_path, notice: "Email updated successfully."
|
redirect_to profile_path, notice: "Email updated successfully."
|
||||||
else
|
else
|
||||||
render :show, status: :unprocessable_entity
|
render :show, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
|
else
|
||||||
|
render :show, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,23 @@
|
|||||||
class SessionsController < ApplicationController
|
class SessionsController < ApplicationController
|
||||||
allow_unauthenticated_access only: %i[ new create verify_totp webauthn_challenge webauthn_verify ]
|
allow_unauthenticated_access only: %i[new create verify_totp webauthn_challenge webauthn_verify]
|
||||||
rate_limit to: 20, within: 3.minutes, only: :create, with: -> { redirect_to signin_path, alert: "Too many attempts. Try again later." }
|
rate_limit to: 20, within: 3.minutes, only: :create, with: -> { redirect_to signin_path, alert: "Too many attempts. Try again later." }
|
||||||
rate_limit to: 10, within: 3.minutes, only: :verify_totp, with: -> { redirect_to totp_verification_path, alert: "Too many attempts. Try again later." }
|
rate_limit to: 10, within: 3.minutes, only: :verify_totp, with: -> { redirect_to totp_verification_path, alert: "Too many attempts. Try again later." }
|
||||||
rate_limit to: 10, within: 3.minutes, only: [:webauthn_challenge, :webauthn_verify], with: -> { render json: { error: "Too many attempts. Try again later." }, status: :too_many_requests }
|
rate_limit to: 10, within: 3.minutes, only: [:webauthn_challenge, :webauthn_verify], with: -> { render json: {error: "Too many attempts. Try again later."}, status: :too_many_requests }
|
||||||
|
|
||||||
def new
|
def new
|
||||||
# Redirect to signup if this is first run
|
# Redirect to signup if this is first run
|
||||||
redirect_to signup_path if User.count.zero?
|
if User.count.zero?
|
||||||
|
respond_to do |format|
|
||||||
|
format.html { redirect_to signup_path }
|
||||||
|
format.json { render json: {error: "No users exist. Please complete initial setup."}, status: :service_unavailable }
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
respond_to do |format|
|
||||||
|
format.html # render HTML login page
|
||||||
|
format.json { render json: {error: "Authentication required"}, status: :unauthorized }
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
@@ -33,8 +44,22 @@ class SessionsController < ApplicationController
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Check if TOTP is required
|
# Check if TOTP is required or enabled
|
||||||
if user.totp_enabled?
|
if user.totp_required? || user.totp_enabled?
|
||||||
|
# If TOTP is required but not yet set up, redirect to setup
|
||||||
|
if user.totp_required? && !user.totp_enabled?
|
||||||
|
# Store user ID in session for TOTP setup
|
||||||
|
session[:pending_totp_setup_user_id] = user.id
|
||||||
|
# Preserve the redirect URL through TOTP setup
|
||||||
|
if params[:rd].present?
|
||||||
|
validated_url = validate_redirect_url(params[:rd])
|
||||||
|
session[:totp_redirect_url] = validated_url if validated_url
|
||||||
|
end
|
||||||
|
redirect_to new_totp_path, alert: "Your administrator requires two-factor authentication. Please set it up now to continue."
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# TOTP is enabled, proceed to verification
|
||||||
# Store user ID in session temporarily for TOTP verification
|
# Store user ID in session temporarily for TOTP verification
|
||||||
session[:pending_totp_user_id] = user.id
|
session[:pending_totp_user_id] = user.id
|
||||||
# Preserve the redirect URL through TOTP verification (after validation)
|
# Preserve the redirect URL through TOTP verification (after validation)
|
||||||
@@ -46,8 +71,8 @@ class SessionsController < ApplicationController
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Sign in successful
|
# Sign in successful (password only)
|
||||||
start_new_session_for user
|
start_new_session_for user, acr: "1"
|
||||||
redirect_to after_authentication_url, notice: "Signed in successfully.", allow_other_host: true
|
redirect_to after_authentication_url, notice: "Signed in successfully.", allow_other_host: true
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -76,39 +101,45 @@ class SessionsController < ApplicationController
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Try TOTP verification first
|
# Try TOTP verification first (password + TOTP = 2FA)
|
||||||
if user.verify_totp(code)
|
if user.verify_totp(code)
|
||||||
session.delete(:pending_totp_user_id)
|
session.delete(:pending_totp_user_id)
|
||||||
# Restore redirect URL if it was preserved
|
# Restore redirect URL if it was preserved
|
||||||
if session[:totp_redirect_url].present?
|
if session[:totp_redirect_url].present?
|
||||||
session[:return_to_after_authenticating] = session.delete(:totp_redirect_url)
|
session[:return_to_after_authenticating] = session.delete(:totp_redirect_url)
|
||||||
end
|
end
|
||||||
start_new_session_for user
|
start_new_session_for user, acr: "2"
|
||||||
redirect_to after_authentication_url, notice: "Signed in successfully.", allow_other_host: true
|
redirect_to after_authentication_url, notice: "Signed in successfully.", allow_other_host: true
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Try backup code verification
|
# Try backup code verification (password + backup code = 2FA)
|
||||||
if user.verify_backup_code(code)
|
if user.verify_backup_code(code)
|
||||||
session.delete(:pending_totp_user_id)
|
session.delete(:pending_totp_user_id)
|
||||||
# Restore redirect URL if it was preserved
|
# Restore redirect URL if it was preserved
|
||||||
if session[:totp_redirect_url].present?
|
if session[:totp_redirect_url].present?
|
||||||
session[:return_to_after_authenticating] = session.delete(:totp_redirect_url)
|
session[:return_to_after_authenticating] = session.delete(:totp_redirect_url)
|
||||||
end
|
end
|
||||||
start_new_session_for user
|
start_new_session_for user, acr: "2"
|
||||||
redirect_to after_authentication_url, notice: "Signed in successfully using backup code.", allow_other_host: true
|
redirect_to after_authentication_url, notice: "Signed in successfully using backup code.", allow_other_host: true
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Invalid code
|
# Invalid code
|
||||||
redirect_to totp_verification_path, alert: "Invalid verification code. Please try again."
|
redirect_to totp_verification_path, alert: "Invalid verification code. Please try again."
|
||||||
return
|
nil
|
||||||
end
|
end
|
||||||
|
|
||||||
# Just render the form
|
# Just render the form
|
||||||
end
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
|
# Send backchannel logout notifications before terminating session
|
||||||
|
if authenticated?
|
||||||
|
user = Current.session.user
|
||||||
|
send_backchannel_logout_notifications(user)
|
||||||
|
end
|
||||||
|
|
||||||
terminate_session
|
terminate_session
|
||||||
redirect_to signin_path, status: :see_other, notice: "Signed out successfully."
|
redirect_to signin_path, status: :see_other, notice: "Signed out successfully."
|
||||||
end
|
end
|
||||||
@@ -124,14 +155,14 @@ class SessionsController < ApplicationController
|
|||||||
email = params[:email]&.strip&.downcase
|
email = params[:email]&.strip&.downcase
|
||||||
|
|
||||||
if email.blank?
|
if email.blank?
|
||||||
render json: { error: "Email is required" }, status: :unprocessable_entity
|
render json: {error: "Email is required"}, status: :unprocessable_entity
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
user = User.find_by(email_address: email)
|
user = User.find_by(email_address: email)
|
||||||
|
|
||||||
if user.nil? || !user.can_authenticate_with_webauthn?
|
if user.nil? || !user.can_authenticate_with_webauthn?
|
||||||
render json: { error: "User not found or WebAuthn not available" }, status: :unprocessable_entity
|
render json: {error: "User not found or WebAuthn not available"}, status: :unprocessable_entity
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -160,10 +191,9 @@ class SessionsController < ApplicationController
|
|||||||
session[:webauthn_challenge] = options.challenge
|
session[:webauthn_challenge] = options.challenge
|
||||||
|
|
||||||
render json: options
|
render json: options
|
||||||
|
|
||||||
rescue => e
|
rescue => e
|
||||||
Rails.logger.error "WebAuthn challenge generation error: #{e.message}"
|
Rails.logger.error "WebAuthn challenge generation error: #{e.message}"
|
||||||
render json: { error: "Failed to generate WebAuthn challenge" }, status: :internal_server_error
|
render json: {error: "Failed to generate WebAuthn challenge"}, status: :internal_server_error
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -171,21 +201,21 @@ class SessionsController < ApplicationController
|
|||||||
# Get pending user from session
|
# Get pending user from session
|
||||||
user_id = session[:pending_webauthn_user_id]
|
user_id = session[:pending_webauthn_user_id]
|
||||||
unless user_id
|
unless user_id
|
||||||
render json: { error: "Session expired. Please try again." }, status: :unprocessable_entity
|
render json: {error: "Session expired. Please try again."}, status: :unprocessable_entity
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
user = User.find_by(id: user_id)
|
user = User.find_by(id: user_id)
|
||||||
unless user
|
unless user
|
||||||
session.delete(:pending_webauthn_user_id)
|
session.delete(:pending_webauthn_user_id)
|
||||||
render json: { error: "Session expired. Please try again." }, status: :unprocessable_entity
|
render json: {error: "Session expired. Please try again."}, status: :unprocessable_entity
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Get the credential and assertion from params
|
# Get the credential and assertion from params
|
||||||
credential_data = params[:credential]
|
credential_data = params[:credential]
|
||||||
if credential_data.blank?
|
if credential_data.blank?
|
||||||
render json: { error: "Credential data is required" }, status: :unprocessable_entity
|
render json: {error: "Credential data is required"}, status: :unprocessable_entity
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -193,7 +223,7 @@ class SessionsController < ApplicationController
|
|||||||
challenge = session.delete(:webauthn_challenge)
|
challenge = session.delete(:webauthn_challenge)
|
||||||
|
|
||||||
if challenge.blank?
|
if challenge.blank?
|
||||||
render json: { error: "Invalid or expired session" }, status: :unprocessable_entity
|
render json: {error: "Invalid or expired session"}, status: :unprocessable_entity
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -206,7 +236,7 @@ class SessionsController < ApplicationController
|
|||||||
stored_credential = user.webauthn_credential_for(external_id)
|
stored_credential = user.webauthn_credential_for(external_id)
|
||||||
|
|
||||||
if stored_credential.nil?
|
if stored_credential.nil?
|
||||||
render json: { error: "Credential not found" }, status: :unprocessable_entity
|
render json: {error: "Credential not found"}, status: :unprocessable_entity
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -237,24 +267,23 @@ class SessionsController < ApplicationController
|
|||||||
session[:return_to_after_authenticating] = session.delete(:webauthn_redirect_url)
|
session[:return_to_after_authenticating] = session.delete(:webauthn_redirect_url)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Create session
|
# Create session (WebAuthn/passkey = phishing-resistant, ACR = "2")
|
||||||
start_new_session_for user
|
start_new_session_for user, acr: "2"
|
||||||
|
|
||||||
render json: {
|
render json: {
|
||||||
success: true,
|
success: true,
|
||||||
redirect_to: after_authentication_url,
|
redirect_to: after_authentication_url,
|
||||||
message: "Signed in successfully with passkey"
|
message: "Signed in successfully with passkey"
|
||||||
}
|
}
|
||||||
|
|
||||||
rescue WebAuthn::Error => e
|
rescue WebAuthn::Error => e
|
||||||
Rails.logger.error "WebAuthn verification error: #{e.message}"
|
Rails.logger.error "WebAuthn verification error: #{e.message}"
|
||||||
render json: { error: "Authentication failed: #{e.message}" }, status: :unprocessable_entity
|
render json: {error: "Authentication failed: #{e.message}"}, status: :unprocessable_entity
|
||||||
rescue JSON::ParserError => e
|
rescue JSON::ParserError => e
|
||||||
Rails.logger.error "WebAuthn JSON parsing error: #{e.message}"
|
Rails.logger.error "WebAuthn JSON parsing error: #{e.message}"
|
||||||
render json: { error: "Invalid credential format" }, status: :unprocessable_entity
|
render json: {error: "Invalid credential format"}, status: :unprocessable_entity
|
||||||
rescue => e
|
rescue => e
|
||||||
Rails.logger.error "Unexpected WebAuthn verification error: #{e.class} - #{e.message}"
|
Rails.logger.error "Unexpected WebAuthn verification error: #{e.class} - #{e.message}"
|
||||||
render json: { error: "An unexpected error occurred" }, status: :internal_server_error
|
render json: {error: "An unexpected error occurred"}, status: :internal_server_error
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -270,20 +299,41 @@ class SessionsController < ApplicationController
|
|||||||
return nil unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
|
return nil unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
|
||||||
|
|
||||||
# Only allow HTTPS in production
|
# Only allow HTTPS in production
|
||||||
return nil unless Rails.env.development? || uri.scheme == 'https'
|
return nil unless Rails.env.development? || uri.scheme == "https"
|
||||||
|
|
||||||
redirect_domain = uri.host.downcase
|
redirect_domain = uri.host.downcase
|
||||||
return nil unless redirect_domain.present?
|
return nil unless redirect_domain.present?
|
||||||
|
|
||||||
# Check against our ForwardAuthRules
|
# Check against our forward auth applications
|
||||||
matching_rule = ForwardAuthRule.active.find do |rule|
|
matching_app = Application.forward_auth.active.find do |app|
|
||||||
rule.matches_domain?(redirect_domain)
|
app.matches_domain?(redirect_domain)
|
||||||
end
|
end
|
||||||
|
|
||||||
matching_rule ? url : nil
|
matching_app ? url : nil
|
||||||
|
|
||||||
rescue URI::InvalidURIError
|
rescue URI::InvalidURIError
|
||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def send_backchannel_logout_notifications(user)
|
||||||
|
# Find all active OIDC consents for this user
|
||||||
|
consents = OidcUserConsent.where(user: user).includes(:application)
|
||||||
|
|
||||||
|
consents.each do |consent|
|
||||||
|
# Skip if application doesn't support backchannel logout
|
||||||
|
next unless consent.application.supports_backchannel_logout?
|
||||||
|
|
||||||
|
# Enqueue background job to send logout notification
|
||||||
|
BackchannelLogoutJob.perform_later(
|
||||||
|
user_id: user.id,
|
||||||
|
application_id: consent.application.id,
|
||||||
|
consent_sid: consent.sid
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
Rails.logger.info "SessionsController: Enqueued #{consents.count} backchannel logout notifications for user #{user.id}"
|
||||||
|
rescue => e
|
||||||
|
# Log error but don't block logout
|
||||||
|
Rails.logger.error "SessionsController: Failed to enqueue backchannel logout: #{e.class} - #{e.message}"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ class TotpController < ApplicationController
|
|||||||
|
|
||||||
# GET /totp/new - Show QR code to set up TOTP
|
# GET /totp/new - Show QR code to set up TOTP
|
||||||
def new
|
def new
|
||||||
|
# Check if user is being forced to set up TOTP by admin
|
||||||
|
@totp_setup_required = session[:pending_totp_setup_user_id].present?
|
||||||
|
|
||||||
# Generate TOTP secret but don't save yet
|
# Generate TOTP secret but don't save yet
|
||||||
@totp_secret = ROTP::Base32.random
|
@totp_secret = ROTP::Base32.random
|
||||||
@provisioning_uri = ROTP::TOTP.new(@totp_secret, issuer: "Clinch").provisioning_uri(@user.email_address)
|
@provisioning_uri = ROTP::TOTP.new(@totp_secret, issuer: "Clinch").provisioning_uri(@user.email_address)
|
||||||
@@ -30,8 +33,16 @@ class TotpController < ApplicationController
|
|||||||
# Store plain codes temporarily in session for display after redirect
|
# Store plain codes temporarily in session for display after redirect
|
||||||
session[:temp_backup_codes] = plain_codes
|
session[:temp_backup_codes] = plain_codes
|
||||||
|
|
||||||
# Redirect to backup codes page with success message
|
# Check if this was a required setup from login
|
||||||
redirect_to backup_codes_totp_path, notice: "Two-factor authentication has been enabled successfully! Save these backup codes now."
|
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
|
else
|
||||||
redirect_to new_totp_path, alert: "Invalid verification code. Please try again."
|
redirect_to new_totp_path, alert: "Invalid verification code. Please try again."
|
||||||
end
|
end
|
||||||
@@ -43,6 +54,12 @@ class TotpController < ApplicationController
|
|||||||
if session[:temp_backup_codes].present?
|
if session[:temp_backup_codes].present?
|
||||||
@backup_codes = session[:temp_backup_codes]
|
@backup_codes = session[:temp_backup_codes]
|
||||||
session.delete(:temp_backup_codes) # Clear after use
|
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
|
else
|
||||||
# This will be shown after password verification for existing users
|
# This will be shown after password verification for existing users
|
||||||
# Since we can't display BCrypt hashes, redirect to regenerate
|
# Since we can't display BCrypt hashes, redirect to regenerate
|
||||||
@@ -81,6 +98,18 @@ class TotpController < ApplicationController
|
|||||||
redirect_to backup_codes_totp_path, notice: "New backup codes have been generated. Save them now!"
|
redirect_to backup_codes_totp_path, notice: "New backup codes have been generated. Save them now!"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# POST /totp/complete_setup - Complete forced TOTP setup and sign in
|
||||||
|
def complete_setup
|
||||||
|
# Sign in the user after they've saved their backup codes
|
||||||
|
# This is only used when admin requires TOTP and user just set it up during login
|
||||||
|
if session[:totp_redirect_url].present?
|
||||||
|
session[:return_to_after_authenticating] = session.delete(:totp_redirect_url)
|
||||||
|
end
|
||||||
|
|
||||||
|
start_new_session_for @user
|
||||||
|
redirect_to after_authentication_url, notice: "Two-factor authentication enabled. Signed in successfully.", allow_other_host: true
|
||||||
|
end
|
||||||
|
|
||||||
# DELETE /totp - Disable TOTP (requires password)
|
# DELETE /totp - Disable TOTP (requires password)
|
||||||
def destroy
|
def destroy
|
||||||
unless @user.authenticate(params[:password])
|
unless @user.authenticate(params[:password])
|
||||||
@@ -88,6 +117,12 @@ class TotpController < ApplicationController
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Prevent disabling if admin requires TOTP
|
||||||
|
if @user.totp_required?
|
||||||
|
redirect_to profile_path, alert: "Two-factor authentication is required by your administrator and cannot be disabled."
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
@user.disable_totp!
|
@user.disable_totp!
|
||||||
redirect_to profile_path, notice: "Two-factor authentication has been disabled."
|
redirect_to profile_path, notice: "Two-factor authentication has been disabled."
|
||||||
end
|
end
|
||||||
@@ -99,7 +134,8 @@ class TotpController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def redirect_if_totp_enabled
|
def redirect_if_totp_enabled
|
||||||
if @user.totp_enabled?
|
# Allow setup if admin requires it, even if already enabled (for regeneration)
|
||||||
|
if @user.totp_enabled? && !session[:pending_totp_setup_user_id].present?
|
||||||
redirect_to profile_path, alert: "Two-factor authentication is already enabled."
|
redirect_to profile_path, alert: "Two-factor authentication is already enabled."
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
class UsersController < ApplicationController
|
class UsersController < ApplicationController
|
||||||
allow_unauthenticated_access only: %i[ new create ]
|
allow_unauthenticated_access only: %i[new create]
|
||||||
before_action :ensure_first_run, only: %i[ new create ]
|
before_action :ensure_first_run, only: %i[new create]
|
||||||
|
|
||||||
def new
|
def new
|
||||||
@user = User.new
|
@user = User.new
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ class WebauthnController < ApplicationController
|
|||||||
before_action :set_webauthn_credential, only: [:destroy]
|
before_action :set_webauthn_credential, only: [:destroy]
|
||||||
skip_before_action :require_authentication, only: [:check]
|
skip_before_action :require_authentication, only: [:check]
|
||||||
|
|
||||||
|
# Rate limit check endpoint to prevent enumeration attacks
|
||||||
|
rate_limit to: 10, within: 1.minute, only: [:check], with: -> {
|
||||||
|
render json: {error: "Too many requests. Try again later."}, status: :too_many_requests
|
||||||
|
}
|
||||||
|
|
||||||
# GET /webauthn/new
|
# GET /webauthn/new
|
||||||
def new
|
def new
|
||||||
@webauthn_credential = WebauthnCredential.new
|
@webauthn_credential = WebauthnCredential.new
|
||||||
@@ -11,7 +16,7 @@ class WebauthnController < ApplicationController
|
|||||||
# Generate registration challenge for creating a new passkey
|
# Generate registration challenge for creating a new passkey
|
||||||
def challenge
|
def challenge
|
||||||
user = Current.session&.user
|
user = Current.session&.user
|
||||||
return render json: { error: "Not authenticated" }, status: :unauthorized unless user
|
return render json: {error: "Not authenticated"}, status: :unauthorized unless user
|
||||||
|
|
||||||
registration_options = WebAuthn::Credential.options_for_create(
|
registration_options = WebAuthn::Credential.options_for_create(
|
||||||
user: {
|
user: {
|
||||||
@@ -39,7 +44,7 @@ class WebauthnController < ApplicationController
|
|||||||
credential_data, nickname = extract_credential_params
|
credential_data, nickname = extract_credential_params
|
||||||
|
|
||||||
if credential_data.blank? || nickname.blank?
|
if credential_data.blank? || nickname.blank?
|
||||||
render json: { error: "Credential and nickname are required" }, status: :unprocessable_entity
|
render json: {error: "Credential and nickname are required"}, status: :unprocessable_entity
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -47,7 +52,7 @@ class WebauthnController < ApplicationController
|
|||||||
challenge = session.delete(:webauthn_challenge)
|
challenge = session.delete(:webauthn_challenge)
|
||||||
|
|
||||||
if challenge.blank?
|
if challenge.blank?
|
||||||
render json: { error: "Invalid or expired session" }, status: :unprocessable_entity
|
render json: {error: "Invalid or expired session"}, status: :unprocessable_entity
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -63,10 +68,10 @@ class WebauthnController < ApplicationController
|
|||||||
client_extension_results = response["clientExtensionResults"] || {}
|
client_extension_results = response["clientExtensionResults"] || {}
|
||||||
|
|
||||||
authenticator_type = if response["response"]["authenticatorAttachment"] == "cross-platform"
|
authenticator_type = if response["response"]["authenticatorAttachment"] == "cross-platform"
|
||||||
"cross-platform"
|
"cross-platform"
|
||||||
else
|
else
|
||||||
"platform"
|
"platform"
|
||||||
end
|
end
|
||||||
|
|
||||||
# Determine if this is a backup/synced credential
|
# Determine if this is a backup/synced credential
|
||||||
backup_eligible = client_extension_results["credProps"]&.dig("rk") || false
|
backup_eligible = client_extension_results["credProps"]&.dig("rk") || false
|
||||||
@@ -74,7 +79,7 @@ class WebauthnController < ApplicationController
|
|||||||
|
|
||||||
# Store the credential
|
# Store the credential
|
||||||
user = Current.session&.user
|
user = Current.session&.user
|
||||||
return render json: { error: "Not authenticated" }, status: :unauthorized unless user
|
return render json: {error: "Not authenticated"}, status: :unauthorized unless user
|
||||||
|
|
||||||
@webauthn_credential = user.webauthn_credentials.create!(
|
@webauthn_credential = user.webauthn_credentials.create!(
|
||||||
external_id: Base64.urlsafe_encode64(webauthn_credential.id),
|
external_id: Base64.urlsafe_encode64(webauthn_credential.id),
|
||||||
@@ -91,34 +96,25 @@ class WebauthnController < ApplicationController
|
|||||||
message: "Passkey '#{nickname}' registered successfully",
|
message: "Passkey '#{nickname}' registered successfully",
|
||||||
credential_id: @webauthn_credential.id
|
credential_id: @webauthn_credential.id
|
||||||
}
|
}
|
||||||
|
|
||||||
rescue WebAuthn::Error => e
|
rescue WebAuthn::Error => e
|
||||||
Rails.logger.error "WebAuthn registration error: #{e.message}"
|
Rails.logger.error "WebAuthn registration error: #{e.message}"
|
||||||
render json: { error: "Failed to register passkey: #{e.message}" }, status: :unprocessable_entity
|
render json: {error: "Failed to register passkey: #{e.message}"}, status: :unprocessable_entity
|
||||||
rescue => e
|
rescue => e
|
||||||
Rails.logger.error "Unexpected WebAuthn registration error: #{e.class} - #{e.message}"
|
Rails.logger.error "Unexpected WebAuthn registration error: #{e.class} - #{e.message}"
|
||||||
render json: { error: "An unexpected error occurred" }, status: :internal_server_error
|
render json: {error: "An unexpected error occurred"}, status: :internal_server_error
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# DELETE /webauthn/:id
|
# DELETE /webauthn/:id
|
||||||
# Remove a passkey
|
# Remove a passkey
|
||||||
def destroy
|
def destroy
|
||||||
user = Current.session&.user
|
|
||||||
return render json: { error: "Not authenticated" }, status: :unauthorized unless user
|
|
||||||
|
|
||||||
if @webauthn_credential.user != user
|
|
||||||
render json: { error: "Unauthorized" }, status: :forbidden
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
nickname = @webauthn_credential.nickname
|
nickname = @webauthn_credential.nickname
|
||||||
@webauthn_credential.destroy
|
@webauthn_credential.destroy
|
||||||
|
|
||||||
respond_to do |format|
|
respond_to do |format|
|
||||||
format.html {
|
format.html {
|
||||||
redirect_to profile_path,
|
redirect_to profile_path,
|
||||||
notice: "Passkey '#{nickname}' has been removed"
|
notice: "Passkey '#{nickname}' has been removed"
|
||||||
}
|
}
|
||||||
format.json {
|
format.json {
|
||||||
render json: {
|
render json: {
|
||||||
@@ -131,25 +127,27 @@ class WebauthnController < ApplicationController
|
|||||||
|
|
||||||
# GET /webauthn/check
|
# GET /webauthn/check
|
||||||
# Check if user has WebAuthn credentials (for login page detection)
|
# Check if user has WebAuthn credentials (for login page detection)
|
||||||
|
# Security: Returns identical responses for non-existent users to prevent enumeration
|
||||||
def check
|
def check
|
||||||
email = params[:email]&.strip&.downcase
|
email = params[:email]&.strip&.downcase
|
||||||
|
|
||||||
if email.blank?
|
if email.blank?
|
||||||
render json: { has_webauthn: false, error: "Email is required" }
|
render json: {has_webauthn: false, requires_webauthn: false}
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
user = User.find_by(email_address: email)
|
user = User.find_by(email_address: email)
|
||||||
|
|
||||||
|
# Security: Return identical response for non-existent users
|
||||||
|
# Combined with rate limiting (10/min), this prevents account enumeration
|
||||||
if user.nil?
|
if user.nil?
|
||||||
render json: { has_webauthn: false, message: "User not found" }
|
render json: {has_webauthn: false, requires_webauthn: false}
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Only return minimal necessary info - no user_id or preferred_method
|
||||||
render json: {
|
render json: {
|
||||||
has_webauthn: user.can_authenticate_with_webauthn?,
|
has_webauthn: user.can_authenticate_with_webauthn?,
|
||||||
user_id: user.id,
|
|
||||||
preferred_method: user.preferred_authentication_method,
|
|
||||||
requires_webauthn: user.require_webauthn?
|
requires_webauthn: user.require_webauthn?
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
@@ -159,40 +157,36 @@ class WebauthnController < ApplicationController
|
|||||||
def extract_credential_params
|
def extract_credential_params
|
||||||
# Use require.permit which is working and reliable
|
# Use require.permit which is working and reliable
|
||||||
# The JavaScript sends params both directly and wrapped in webauthn key
|
# The JavaScript sends params both directly and wrapped in webauthn key
|
||||||
begin
|
|
||||||
# Try direct parameters first
|
# Try direct parameters first
|
||||||
credential_params = params.require(:credential).permit(:id, :rawId, :type, response: {}, clientExtensionResults: {})
|
credential_params = params.require(:credential).permit(:id, :rawId, :type, response: {}, clientExtensionResults: {})
|
||||||
nickname = params.require(:nickname)
|
nickname = params.require(:nickname)
|
||||||
[credential_params, nickname]
|
[credential_params, nickname]
|
||||||
rescue ActionController::ParameterMissing
|
rescue ActionController::ParameterMissing
|
||||||
Rails.logger.error("Using the fallback parameters")
|
Rails.logger.error("Using the fallback parameters")
|
||||||
# Fallback to webauthn-wrapped parameters
|
# Fallback to webauthn-wrapped parameters
|
||||||
webauthn_params = params.require(:webauthn).permit(:nickname, credential: [:id, :rawId, :type, response: {}, clientExtensionResults: {}])
|
webauthn_params = params.require(:webauthn).permit(:nickname, credential: [:id, :rawId, :type, response: {}, clientExtensionResults: {}])
|
||||||
[webauthn_params[:credential], webauthn_params[:nickname]]
|
[webauthn_params[:credential], webauthn_params[:nickname]]
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_webauthn_credential
|
def set_webauthn_credential
|
||||||
@webauthn_credential = WebauthnCredential.find(params[:id])
|
user = Current.session&.user
|
||||||
|
return render json: {error: "Not authenticated"}, status: :unauthorized unless user
|
||||||
|
@webauthn_credential = user.webauthn_credentials.find(params[:id])
|
||||||
rescue ActiveRecord::RecordNotFound
|
rescue ActiveRecord::RecordNotFound
|
||||||
respond_to do |format|
|
respond_to do |format|
|
||||||
format.html {
|
format.html { redirect_to profile_path, alert: "Passkey not found" }
|
||||||
redirect_to profile_path,
|
format.json { render json: {error: "Passkey not found"}, status: :not_found }
|
||||||
alert: "Passkey not found"
|
|
||||||
}
|
|
||||||
format.json {
|
|
||||||
render json: { error: "Passkey not found" }, status: :not_found
|
|
||||||
}
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Helper method to convert Base64 to Base64URL if needed
|
# Helper method to convert Base64 to Base64URL if needed
|
||||||
def base64_to_base64url(str)
|
def base64_to_base64url(str)
|
||||||
str.gsub('+', '-').gsub('/', '_').gsub(/=+$/, '')
|
str.tr("+", "-").tr("/", "_").gsub(/=+$/, "")
|
||||||
end
|
end
|
||||||
|
|
||||||
# Helper method to convert Base64URL to Base64 if needed
|
# Helper method to convert Base64URL to Base64 if needed
|
||||||
def base64url_to_base64(str)
|
def base64url_to_base64(str)
|
||||||
str.gsub('-', '+').gsub('_', '/') + '=' * (4 - str.length % 4) % 4
|
str.tr("-", "+").tr("_", "/") + "=" * (4 - str.length % 4) % 4
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -6,10 +6,10 @@ module ApplicationHelper
|
|||||||
smtp_port = ENV["SMTP_PORT"]
|
smtp_port = ENV["SMTP_PORT"]
|
||||||
|
|
||||||
smtp_address.present? &&
|
smtp_address.present? &&
|
||||||
smtp_port.present? &&
|
smtp_port.present? &&
|
||||||
smtp_address != "localhost" &&
|
smtp_address != "localhost" &&
|
||||||
!smtp_address.start_with?("127.0.0.1") &&
|
!smtp_address.start_with?("127.0.0.1") &&
|
||||||
!smtp_address.start_with?("localhost")
|
!smtp_address.start_with?("localhost")
|
||||||
end
|
end
|
||||||
|
|
||||||
def email_delivery_method
|
def email_delivery_method
|
||||||
@@ -22,11 +22,11 @@ module ApplicationHelper
|
|||||||
|
|
||||||
def border_class_for(type)
|
def border_class_for(type)
|
||||||
case type.to_s
|
case type.to_s
|
||||||
when 'notice' then 'border-green-200'
|
when "notice" then "border-green-200"
|
||||||
when 'alert', 'error' then 'border-red-200'
|
when "alert", "error" then "border-red-200"
|
||||||
when 'warning' then 'border-yellow-200'
|
when "warning" then "border-yellow-200"
|
||||||
when 'info' then 'border-blue-200'
|
when "info" then "border-blue-200"
|
||||||
else 'border-gray-200'
|
else "border-gray-200"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
67
app/helpers/claims_helper.rb
Normal file
67
app/helpers/claims_helper.rb
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
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)
|
||||||
|
deep_merge_claims(claims, application.custom_claims_for_user(user))
|
||||||
|
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
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Controller } from "@hotwired/stimulus"
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
|
||||||
export default class extends Controller {
|
export default class extends Controller {
|
||||||
static targets = ["appTypeSelect", "oidcFields", "forwardAuthFields"]
|
static targets = ["appTypeSelect", "oidcFields", "forwardAuthFields", "pkceOptions"]
|
||||||
|
|
||||||
connect() {
|
connect() {
|
||||||
this.updateFieldVisibility()
|
this.updateFieldVisibility()
|
||||||
@@ -21,4 +21,17 @@ export default class extends Controller {
|
|||||||
this.forwardAuthFieldsTarget.classList.add('hidden')
|
this.forwardAuthFieldsTarget.classList.add('hidden')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updatePkceVisibility(event) {
|
||||||
|
// Show PKCE options for confidential clients, hide for public clients
|
||||||
|
const isPublicClient = event.target.value === "true"
|
||||||
|
|
||||||
|
if (this.hasPkceOptionsTarget) {
|
||||||
|
if (isPublicClient) {
|
||||||
|
this.pkceOptionsTarget.classList.add('hidden')
|
||||||
|
} else {
|
||||||
|
this.pkceOptionsTarget.classList.remove('hidden')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
96
app/javascript/controllers/file_drop_controller.js
Normal file
96
app/javascript/controllers/file_drop_controller.js
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = ["input", "dropzone", "preview", "previewImage", "filename", "filesize"]
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
// Prevent default drag behaviors on the whole document
|
||||||
|
["dragenter", "dragover", "dragleave", "drop"].forEach(eventName => {
|
||||||
|
document.body.addEventListener(eventName, this.preventDefaults, false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
["dragenter", "dragover", "dragleave", "drop"].forEach(eventName => {
|
||||||
|
document.body.removeEventListener(eventName, this.preventDefaults, false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
preventDefaults(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
}
|
||||||
|
|
||||||
|
dragover(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
this.dropzoneTarget.classList.add("border-blue-500", "bg-blue-50")
|
||||||
|
}
|
||||||
|
|
||||||
|
dragleave(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
this.dropzoneTarget.classList.remove("border-blue-500", "bg-blue-50")
|
||||||
|
}
|
||||||
|
|
||||||
|
drop(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
this.dropzoneTarget.classList.remove("border-blue-500", "bg-blue-50")
|
||||||
|
|
||||||
|
const files = e.dataTransfer.files
|
||||||
|
if (files.length > 0) {
|
||||||
|
// Set the file to the input element
|
||||||
|
this.inputTarget.files = files
|
||||||
|
this.handleFiles()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleFiles() {
|
||||||
|
const file = this.inputTarget.files[0]
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
// Validate file type
|
||||||
|
const validTypes = ["image/png", "image/jpg", "image/jpeg", "image/gif", "image/svg+xml"]
|
||||||
|
if (!validTypes.includes(file.type)) {
|
||||||
|
alert("Please upload a PNG, JPG, GIF, or SVG image")
|
||||||
|
this.clear()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate file size (2MB)
|
||||||
|
if (file.size > 2 * 1024 * 1024) {
|
||||||
|
alert("File size must be less than 2MB")
|
||||||
|
this.clear()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show preview
|
||||||
|
this.filenameTarget.textContent = file.name
|
||||||
|
this.filesizeTarget.textContent = this.formatFileSize(file.size)
|
||||||
|
|
||||||
|
// Create preview image
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = (e) => {
|
||||||
|
this.previewImageTarget.src = e.target.result
|
||||||
|
this.previewTarget.classList.remove("hidden")
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(e) {
|
||||||
|
if (e) {
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
this.inputTarget.value = ""
|
||||||
|
this.previewTarget.classList.add("hidden")
|
||||||
|
}
|
||||||
|
|
||||||
|
formatFileSize(bytes) {
|
||||||
|
if (bytes === 0) return "0 Bytes"
|
||||||
|
const k = 1024
|
||||||
|
const sizes = ["Bytes", "KB", "MB"]
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||||
|
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + " " + sizes[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
121
app/javascript/controllers/image_paste_controller.js
Normal file
121
app/javascript/controllers/image_paste_controller.js
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = ["input", "dropzone"]
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
// Listen for paste events on the dropzone
|
||||||
|
this.dropzoneTarget.addEventListener("paste", this.handlePaste.bind(this))
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
this.dropzoneTarget.removeEventListener("paste", this.handlePaste.bind(this))
|
||||||
|
}
|
||||||
|
|
||||||
|
handlePaste(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
|
||||||
|
const clipboardData = e.clipboardData || e.originalEvent.clipboardData
|
||||||
|
|
||||||
|
// First, try to get image data
|
||||||
|
for (let item of clipboardData.items) {
|
||||||
|
if (item.type.indexOf("image") !== -1) {
|
||||||
|
const blob = item.getAsFile()
|
||||||
|
this.handleImageBlob(blob)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no image found, check for SVG text
|
||||||
|
const text = clipboardData.getData("text/plain")
|
||||||
|
if (text && this.isSVG(text)) {
|
||||||
|
this.handleSVGText(text)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isSVG(text) {
|
||||||
|
// Check if the text looks like SVG code
|
||||||
|
const trimmed = text.trim()
|
||||||
|
return trimmed.startsWith("<svg") && trimmed.includes("</svg>")
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSVGText(svgText) {
|
||||||
|
// Validate file size (2MB)
|
||||||
|
const size = new Blob([svgText]).size
|
||||||
|
if (size > 2 * 1024 * 1024) {
|
||||||
|
alert("SVG code is too large (must be less than 2MB)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a blob from the SVG text
|
||||||
|
const blob = new Blob([svgText], { type: "image/svg+xml" })
|
||||||
|
|
||||||
|
// Create a File object
|
||||||
|
const file = new File([blob], `pasted-svg-${Date.now()}.svg`, {
|
||||||
|
type: "image/svg+xml"
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create a DataTransfer object to set files on the input
|
||||||
|
const dataTransfer = new DataTransfer()
|
||||||
|
dataTransfer.items.add(file)
|
||||||
|
this.inputTarget.files = dataTransfer.files
|
||||||
|
|
||||||
|
// Trigger change event to update preview (file-drop controller will handle it)
|
||||||
|
const event = new Event("change", { bubbles: true })
|
||||||
|
this.inputTarget.dispatchEvent(event)
|
||||||
|
|
||||||
|
// Visual feedback
|
||||||
|
this.dropzoneTarget.classList.add("border-green-500", "bg-green-50")
|
||||||
|
setTimeout(() => {
|
||||||
|
this.dropzoneTarget.classList.remove("border-green-500", "bg-green-50")
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
handleImageBlob(blob) {
|
||||||
|
// Validate file type
|
||||||
|
const validTypes = ["image/png", "image/jpg", "image/jpeg", "image/gif", "image/svg+xml"]
|
||||||
|
if (!validTypes.includes(blob.type)) {
|
||||||
|
alert("Please paste a PNG, JPG, GIF, or SVG image")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate file size (2MB)
|
||||||
|
if (blob.size > 2 * 1024 * 1024) {
|
||||||
|
alert("Image size must be less than 2MB")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a File object from the blob with a default name
|
||||||
|
const file = new File([blob], `pasted-image-${Date.now()}.${this.getExtension(blob.type)}`, {
|
||||||
|
type: blob.type
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create a DataTransfer object to set files on the input
|
||||||
|
const dataTransfer = new DataTransfer()
|
||||||
|
dataTransfer.items.add(file)
|
||||||
|
this.inputTarget.files = dataTransfer.files
|
||||||
|
|
||||||
|
// Trigger change event to update preview (file-drop controller will handle it)
|
||||||
|
const event = new Event("change", { bubbles: true })
|
||||||
|
this.inputTarget.dispatchEvent(event)
|
||||||
|
|
||||||
|
// Visual feedback
|
||||||
|
this.dropzoneTarget.classList.add("border-green-500", "bg-green-50")
|
||||||
|
setTimeout(() => {
|
||||||
|
this.dropzoneTarget.classList.remove("border-green-500", "bg-green-50")
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
getExtension(mimeType) {
|
||||||
|
const extensions = {
|
||||||
|
"image/png": "png",
|
||||||
|
"image/jpeg": "jpg",
|
||||||
|
"image/jpg": "jpg",
|
||||||
|
"image/gif": "gif",
|
||||||
|
"image/svg+xml": "svg"
|
||||||
|
}
|
||||||
|
return extensions[mimeType] || "png"
|
||||||
|
}
|
||||||
|
}
|
||||||
52
app/jobs/backchannel_logout_job.rb
Normal file
52
app/jobs/backchannel_logout_job.rb
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
class BackchannelLogoutJob < ApplicationJob
|
||||||
|
queue_as :default
|
||||||
|
|
||||||
|
# Retry with exponential backoff: 1s, 5s, 25s
|
||||||
|
retry_on StandardError, wait: :exponentially_longer, attempts: 3
|
||||||
|
|
||||||
|
def perform(user_id:, application_id:, consent_sid:)
|
||||||
|
# Find the records
|
||||||
|
user = User.find_by(id: user_id)
|
||||||
|
application = Application.find_by(id: application_id)
|
||||||
|
consent = OidcUserConsent.find_by(sid: consent_sid)
|
||||||
|
|
||||||
|
# Validate we have all required data
|
||||||
|
unless user && application && consent
|
||||||
|
Rails.logger.warn "BackchannelLogout: Missing data - user: #{user.present?}, app: #{application.present?}, consent: #{consent.present?}"
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Skip if application doesn't support backchannel logout
|
||||||
|
unless application.supports_backchannel_logout?
|
||||||
|
Rails.logger.debug "BackchannelLogout: Application #{application.name} doesn't support backchannel logout"
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Generate the logout token
|
||||||
|
logout_token = OidcJwtService.generate_logout_token(user, application, consent)
|
||||||
|
|
||||||
|
# Send HTTP POST to the application's backchannel logout URI
|
||||||
|
uri = URI.parse(application.backchannel_logout_uri)
|
||||||
|
|
||||||
|
begin
|
||||||
|
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 5, read_timeout: 5) do |http|
|
||||||
|
request = Net::HTTP::Post.new(uri.path.presence || "/")
|
||||||
|
request["Content-Type"] = "application/x-www-form-urlencoded"
|
||||||
|
request.set_form_data({logout_token: logout_token})
|
||||||
|
http.request(request)
|
||||||
|
end
|
||||||
|
|
||||||
|
if response.code.to_i == 200
|
||||||
|
Rails.logger.info "BackchannelLogout: Successfully sent logout notification to #{application.name} (#{application.backchannel_logout_uri})"
|
||||||
|
else
|
||||||
|
Rails.logger.warn "BackchannelLogout: Application #{application.name} returned HTTP #{response.code} from #{application.backchannel_logout_uri}"
|
||||||
|
end
|
||||||
|
rescue Net::OpenTimeout, Net::ReadTimeout => e
|
||||||
|
Rails.logger.warn "BackchannelLogout: Timeout sending logout to #{application.name} (#{application.backchannel_logout_uri}): #{e.message}"
|
||||||
|
raise # Retry on timeout
|
||||||
|
rescue => 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
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
class ApplicationMailer < ActionMailer::Base
|
class ApplicationMailer < ActionMailer::Base
|
||||||
default from: ENV.fetch('CLINCH_FROM_EMAIL', 'clinch@example.com')
|
default from: ENV.fetch("CLINCH_FROM_EMAIL", "clinch@example.com")
|
||||||
layout "mailer"
|
layout "mailer"
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,43 +1,66 @@
|
|||||||
class Application < ApplicationRecord
|
class Application < ApplicationRecord
|
||||||
has_secure_password :client_secret, validations: false
|
has_secure_password :client_secret, validations: false
|
||||||
|
|
||||||
|
# Virtual attribute to control client type during creation
|
||||||
|
# When true, no client_secret will be generated (public client)
|
||||||
|
attr_accessor :is_public_client
|
||||||
|
|
||||||
|
has_one_attached :icon
|
||||||
|
|
||||||
|
# Fix SVG content type after attachment
|
||||||
|
after_save :fix_icon_content_type, if: -> { icon.attached? && saved_change_to_attribute?(:id) == false }
|
||||||
|
|
||||||
has_many :application_groups, dependent: :destroy
|
has_many :application_groups, dependent: :destroy
|
||||||
has_many :allowed_groups, through: :application_groups, source: :group
|
has_many :allowed_groups, through: :application_groups, source: :group
|
||||||
|
has_many :application_user_claims, dependent: :destroy
|
||||||
has_many :oidc_authorization_codes, dependent: :destroy
|
has_many :oidc_authorization_codes, dependent: :destroy
|
||||||
has_many :oidc_access_tokens, dependent: :destroy
|
has_many :oidc_access_tokens, dependent: :destroy
|
||||||
has_many :oidc_refresh_tokens, dependent: :destroy
|
has_many :oidc_refresh_tokens, dependent: :destroy
|
||||||
has_many :oidc_user_consents, dependent: :destroy
|
has_many :oidc_user_consents, dependent: :destroy
|
||||||
|
|
||||||
validates :name, presence: true
|
validates :name, presence: true
|
||||||
validates :slug, presence: true, uniqueness: { case_sensitive: false },
|
validates :slug, presence: true, uniqueness: {case_sensitive: false},
|
||||||
format: { with: /\A[a-z0-9\-]+\z/, message: "only lowercase letters, numbers, and hyphens" }
|
format: {with: /\A[a-z0-9-]+\z/, message: "only lowercase letters, numbers, and hyphens"}
|
||||||
validates :app_type, presence: true,
|
validates :app_type, presence: true,
|
||||||
inclusion: { in: %w[oidc forward_auth] }
|
inclusion: {in: %w[oidc forward_auth]}
|
||||||
validates :client_id, uniqueness: { allow_nil: true }
|
validates :client_id, uniqueness: {allow_nil: true}
|
||||||
validates :client_secret, presence: true, on: :create, if: -> { oidc? }
|
validates :client_secret, presence: true, on: :create, if: -> { oidc? && confidential_client? }
|
||||||
validates :domain_pattern, presence: true, uniqueness: { case_sensitive: false }, if: :forward_auth?
|
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 :landing_url, format: {with: URI::RFC2396_PARSER.make_regexp(%w[http https]), allow_nil: true, message: "must be a valid URL"}
|
||||||
|
validates :backchannel_logout_uri, format: {
|
||||||
|
with: URI::RFC2396_PARSER.make_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)
|
# 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 :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 :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
|
validates :id_token_ttl, numericality: {greater_than_or_equal_to: 300, less_than_or_equal_to: 86400}, if: :oidc? # 5 min - 24 hours
|
||||||
|
|
||||||
normalizes :slug, with: ->(slug) { slug.strip.downcase }
|
normalizes :slug, with: ->(slug) { slug.strip.downcase }
|
||||||
normalizes :domain_pattern, with: ->(pattern) {
|
normalizes :domain_pattern, with: ->(pattern) {
|
||||||
normalized = pattern&.strip&.downcase
|
normalized = pattern&.strip&.downcase
|
||||||
normalized.blank? ? nil : normalized
|
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?
|
before_validation :generate_client_credentials, on: :create, if: :oidc?
|
||||||
|
|
||||||
# Default header configuration for ForwardAuth
|
# Default header configuration for ForwardAuth
|
||||||
DEFAULT_HEADERS = {
|
DEFAULT_HEADERS = {
|
||||||
user: 'X-Remote-User',
|
user: "X-Remote-User",
|
||||||
email: 'X-Remote-Email',
|
email: "X-Remote-Email",
|
||||||
name: 'X-Remote-Name',
|
name: "X-Remote-Name",
|
||||||
groups: 'X-Remote-Groups',
|
groups: "X-Remote-Groups",
|
||||||
admin: 'X-Remote-Admin'
|
admin: "X-Remote-Admin"
|
||||||
}.freeze
|
}.freeze
|
||||||
|
|
||||||
# Scopes
|
# Scopes
|
||||||
@@ -55,6 +78,24 @@ class Application < ApplicationRecord
|
|||||||
app_type == "forward_auth"
|
app_type == "forward_auth"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Client type checks (for OIDC)
|
||||||
|
def public_client?
|
||||||
|
client_secret_digest.blank?
|
||||||
|
end
|
||||||
|
|
||||||
|
def confidential_client?
|
||||||
|
!public_client?
|
||||||
|
end
|
||||||
|
|
||||||
|
# PKCE requirement check
|
||||||
|
# Public clients MUST use PKCE (no client secret to protect auth code)
|
||||||
|
# Confidential clients can optionally require PKCE (OAuth 2.1 recommendation)
|
||||||
|
def requires_pkce?
|
||||||
|
return false unless oidc?
|
||||||
|
return true if public_client? # Always require PKCE for public clients
|
||||||
|
require_pkce? # Check the flag for confidential clients
|
||||||
|
end
|
||||||
|
|
||||||
# Access control
|
# Access control
|
||||||
def user_allowed?(user)
|
def user_allowed?(user)
|
||||||
return false unless active?
|
return false unless active?
|
||||||
@@ -94,8 +135,8 @@ class Application < ApplicationRecord
|
|||||||
def matches_domain?(domain)
|
def matches_domain?(domain)
|
||||||
return false if domain.blank? || !forward_auth?
|
return false if domain.blank? || !forward_auth?
|
||||||
|
|
||||||
pattern = domain_pattern.gsub('.', '\.')
|
pattern = domain_pattern.gsub(".", '\.')
|
||||||
pattern = pattern.gsub('*', '[^.]*')
|
pattern = pattern.gsub("*", "[^.]*")
|
||||||
|
|
||||||
regex = Regexp.new("^#{pattern}$", Regexp::IGNORECASE)
|
regex = Regexp.new("^#{pattern}$", Regexp::IGNORECASE)
|
||||||
regex.match?(domain.downcase)
|
regex.match?(domain.downcase)
|
||||||
@@ -103,18 +144,18 @@ class Application < ApplicationRecord
|
|||||||
|
|
||||||
# Policy determination based on user status (for ForwardAuth)
|
# Policy determination based on user status (for ForwardAuth)
|
||||||
def policy_for_user(user)
|
def policy_for_user(user)
|
||||||
return 'deny' unless active?
|
return "deny" unless active?
|
||||||
return 'deny' unless user.active?
|
return "deny" unless user.active?
|
||||||
|
|
||||||
# If no groups specified, bypass authentication
|
# If no groups specified, bypass authentication
|
||||||
return 'bypass' if allowed_groups.empty?
|
return "bypass" if allowed_groups.empty?
|
||||||
|
|
||||||
# If user is in allowed groups, determine auth level
|
# If user is in allowed groups, determine auth level
|
||||||
if user_allowed?(user)
|
if user_allowed?(user)
|
||||||
# Require 2FA if user has TOTP configured, otherwise one factor
|
# Require 2FA if user has TOTP configured, otherwise one factor
|
||||||
user.totp_enabled? ? 'two_factor' : 'one_factor'
|
user.totp_enabled? ? "two_factor" : "one_factor"
|
||||||
else
|
else
|
||||||
'deny'
|
"deny"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -156,7 +197,7 @@ class Application < ApplicationRecord
|
|||||||
def generate_new_client_secret!
|
def generate_new_client_secret!
|
||||||
secret = SecureRandom.urlsafe_base64(48)
|
secret = SecureRandom.urlsafe_base64(48)
|
||||||
self.client_secret = secret
|
self.client_secret = secret
|
||||||
self.save!
|
save!
|
||||||
secret
|
secret
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -186,8 +227,50 @@ class Application < ApplicationRecord
|
|||||||
duration_to_human(id_token_ttl || 3600)
|
duration_to_human(id_token_ttl || 3600)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Get app-specific custom claims for a user
|
||||||
|
def custom_claims_for_user(user)
|
||||||
|
app_claim = application_user_claims.find_by(user: user)
|
||||||
|
app_claim&.parsed_custom_claims || {}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if this application supports backchannel logout
|
||||||
|
def supports_backchannel_logout?
|
||||||
|
backchannel_logout_uri.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if a user has an active session with this application
|
||||||
|
# (i.e., has valid, non-revoked tokens)
|
||||||
|
def user_has_active_session?(user)
|
||||||
|
oidc_access_tokens.where(user: user).valid.exists? ||
|
||||||
|
oidc_refresh_tokens.where(user: user).valid.exists?
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def fix_icon_content_type
|
||||||
|
return unless icon.attached?
|
||||||
|
|
||||||
|
# Fix SVG content type if it was detected incorrectly
|
||||||
|
if icon.filename.extension == "svg" && icon.content_type == "application/octet-stream"
|
||||||
|
icon.blob.update(content_type: "image/svg+xml")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def icon_validation
|
||||||
|
return unless icon.attached?
|
||||||
|
|
||||||
|
# Check content type
|
||||||
|
allowed_types = ["image/png", "image/jpg", "image/jpeg", "image/gif", "image/svg+xml"]
|
||||||
|
unless allowed_types.include?(icon.content_type)
|
||||||
|
errors.add(:icon, "must be a PNG, JPG, GIF, or SVG image")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check file size (2MB limit)
|
||||||
|
if icon.blob.byte_size > 2.megabytes
|
||||||
|
errors.add(:icon, "must be less than 2MB")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def duration_to_human(seconds)
|
def duration_to_human(seconds)
|
||||||
if seconds < 3600
|
if seconds < 3600
|
||||||
"#{seconds / 60} minutes"
|
"#{seconds / 60} minutes"
|
||||||
@@ -200,10 +283,30 @@ class Application < ApplicationRecord
|
|||||||
|
|
||||||
def generate_client_credentials
|
def generate_client_credentials
|
||||||
self.client_id ||= SecureRandom.urlsafe_base64(32)
|
self.client_id ||= SecureRandom.urlsafe_base64(32)
|
||||||
# Generate and hash the client secret
|
# Generate client secret only for confidential clients
|
||||||
if new_record? && client_secret.blank?
|
# Public clients (is_public_client checked) don't get a secret - they use PKCE only
|
||||||
|
if new_record? && client_secret.blank? && !is_public_client_selected?
|
||||||
secret = SecureRandom.urlsafe_base64(48)
|
secret = SecureRandom.urlsafe_base64(48)
|
||||||
self.client_secret = secret
|
self.client_secret = secret
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Check if the user selected public client option
|
||||||
|
def is_public_client_selected?
|
||||||
|
ActiveModel::Type::Boolean.new.cast(is_public_client)
|
||||||
|
end
|
||||||
|
|
||||||
|
def backchannel_logout_uri_must_be_https_in_production
|
||||||
|
return unless Rails.env.production?
|
||||||
|
return unless backchannel_logout_uri.present?
|
||||||
|
|
||||||
|
begin
|
||||||
|
uri = URI.parse(backchannel_logout_uri)
|
||||||
|
unless uri.scheme == "https"
|
||||||
|
errors.add(:backchannel_logout_uri, "must use HTTPS in production")
|
||||||
|
end
|
||||||
|
rescue URI::InvalidURIError
|
||||||
|
# Let the format validator handle invalid URIs
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -2,5 +2,5 @@ class ApplicationGroup < ApplicationRecord
|
|||||||
belongs_to :application
|
belongs_to :application
|
||||||
belongs_to :group
|
belongs_to :group
|
||||||
|
|
||||||
validates :application_id, uniqueness: { scope: :group_id }
|
validates :application_id, uniqueness: {scope: :group_id}
|
||||||
end
|
end
|
||||||
|
|||||||
31
app/models/application_user_claim.rb
Normal file
31
app/models/application_user_claim.rb
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
class ApplicationUserClaim < ApplicationRecord
|
||||||
|
belongs_to :application
|
||||||
|
belongs_to :user
|
||||||
|
|
||||||
|
# Reserved OIDC claim names that should not be overridden
|
||||||
|
RESERVED_CLAIMS = %w[
|
||||||
|
iss sub aud exp iat nbf jti nonce azp
|
||||||
|
email email_verified preferred_username name
|
||||||
|
groups
|
||||||
|
].freeze
|
||||||
|
|
||||||
|
validates :user_id, uniqueness: {scope: :application_id}
|
||||||
|
validate :no_reserved_claim_names
|
||||||
|
|
||||||
|
# Parse custom_claims JSON field
|
||||||
|
def parsed_custom_claims
|
||||||
|
return {} if custom_claims.blank?
|
||||||
|
custom_claims.is_a?(Hash) ? custom_claims : {}
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def no_reserved_claim_names
|
||||||
|
return if custom_claims.blank?
|
||||||
|
|
||||||
|
reserved_used = parsed_custom_claims.keys.map(&:to_s) & RESERVED_CLAIMS
|
||||||
|
if reserved_used.any?
|
||||||
|
errors.add(:custom_claims, "cannot override reserved OIDC claims: #{reserved_used.join(", ")}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -4,11 +4,31 @@ class Group < ApplicationRecord
|
|||||||
has_many :application_groups, dependent: :destroy
|
has_many :application_groups, dependent: :destroy
|
||||||
has_many :applications, through: :application_groups
|
has_many :applications, through: :application_groups
|
||||||
|
|
||||||
validates :name, presence: true, uniqueness: { case_sensitive: false }
|
# 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 }
|
normalizes :name, with: ->(name) { name.strip.downcase }
|
||||||
|
validate :no_reserved_claim_names
|
||||||
|
|
||||||
# Parse custom_claims JSON field
|
# Parse custom_claims JSON field
|
||||||
def parsed_custom_claims
|
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
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ class OidcAccessToken < ApplicationRecord
|
|||||||
before_validation :generate_token, on: :create
|
before_validation :generate_token, on: :create
|
||||||
before_validation :set_expiry, on: :create
|
before_validation :set_expiry, on: :create
|
||||||
|
|
||||||
validates :token, uniqueness: true, presence: true
|
validates :token_hmac, presence: true, uniqueness: true
|
||||||
|
|
||||||
scope :valid, -> { where("expires_at > ?", Time.current).where(revoked_at: nil) }
|
scope :valid, -> { where("expires_at > ?", Time.current).where(revoked_at: nil) }
|
||||||
scope :expired, -> { where("expires_at <= ?", Time.current) }
|
scope :expired, -> { where("expires_at <= ?", Time.current) }
|
||||||
@@ -15,6 +15,19 @@ class OidcAccessToken < ApplicationRecord
|
|||||||
|
|
||||||
attr_accessor :plaintext_token # Store plaintext temporarily for returning to client
|
attr_accessor :plaintext_token # Store plaintext temporarily for returning to client
|
||||||
|
|
||||||
|
# Find access token by plaintext token using HMAC verification
|
||||||
|
def self.find_by_token(plaintext_token)
|
||||||
|
return nil if plaintext_token.blank?
|
||||||
|
|
||||||
|
token_hmac = compute_token_hmac(plaintext_token)
|
||||||
|
find_by(token_hmac: token_hmac)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Compute HMAC for token lookup
|
||||||
|
def self.compute_token_hmac(plaintext_token)
|
||||||
|
OpenSSL::HMAC.hexdigest("SHA256", TokenHmac::KEY, plaintext_token)
|
||||||
|
end
|
||||||
|
|
||||||
def expired?
|
def expired?
|
||||||
expires_at <= Time.current
|
expires_at <= Time.current
|
||||||
end
|
end
|
||||||
@@ -33,48 +46,13 @@ class OidcAccessToken < ApplicationRecord
|
|||||||
oidc_refresh_tokens.each(&:revoke!)
|
oidc_refresh_tokens.each(&:revoke!)
|
||||||
end
|
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
|
private
|
||||||
|
|
||||||
def generate_token
|
def generate_token
|
||||||
return if token.present?
|
# Generate random plaintext token
|
||||||
|
self.plaintext_token ||= SecureRandom.urlsafe_base64(48)
|
||||||
# Generate opaque access token
|
# Store HMAC in database (not plaintext)
|
||||||
plaintext = SecureRandom.urlsafe_base64(48)
|
self.token_hmac ||= self.class.compute_token_hmac(plaintext_token)
|
||||||
self.plaintext_token = plaintext # Store temporarily for returning to client
|
|
||||||
self.token_digest = BCrypt::Password.create(plaintext)
|
|
||||||
# Keep token column for backward compatibility during migration
|
|
||||||
self.token = plaintext
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_expiry
|
def set_expiry
|
||||||
|
|||||||
@@ -2,17 +2,32 @@ class OidcAuthorizationCode < ApplicationRecord
|
|||||||
belongs_to :application
|
belongs_to :application
|
||||||
belongs_to :user
|
belongs_to :user
|
||||||
|
|
||||||
|
attr_accessor :plaintext_code
|
||||||
|
|
||||||
before_validation :generate_code, on: :create
|
before_validation :generate_code, on: :create
|
||||||
before_validation :set_expiry, on: :create
|
before_validation :set_expiry, on: :create
|
||||||
|
|
||||||
validates :code, presence: true, uniqueness: true
|
validates :code_hmac, presence: true, uniqueness: true
|
||||||
validates :redirect_uri, presence: true
|
validates :redirect_uri, presence: true
|
||||||
validates :code_challenge_method, inclusion: { in: %w[plain S256], allow_nil: true }
|
validates :code_challenge_method, inclusion: {in: %w[plain S256], allow_nil: true}
|
||||||
validate :validate_code_challenge_format, if: -> { code_challenge.present? }
|
validate :validate_code_challenge_format, if: -> { code_challenge.present? }
|
||||||
|
|
||||||
scope :valid, -> { where(used: false).where("expires_at > ?", Time.current) }
|
scope :valid, -> { where(used: false).where("expires_at > ?", Time.current) }
|
||||||
scope :expired, -> { where("expires_at <= ?", Time.current) }
|
scope :expired, -> { where("expires_at <= ?", Time.current) }
|
||||||
|
|
||||||
|
# Find authorization code by plaintext code using HMAC verification
|
||||||
|
def self.find_by_plaintext(plaintext_code)
|
||||||
|
return nil if plaintext_code.blank?
|
||||||
|
|
||||||
|
code_hmac = compute_code_hmac(plaintext_code)
|
||||||
|
find_by(code_hmac: code_hmac)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Compute HMAC for code lookup
|
||||||
|
def self.compute_code_hmac(plaintext_code)
|
||||||
|
OpenSSL::HMAC.hexdigest("SHA256", TokenHmac::KEY, plaintext_code)
|
||||||
|
end
|
||||||
|
|
||||||
def expired?
|
def expired?
|
||||||
expires_at <= Time.current
|
expires_at <= Time.current
|
||||||
end
|
end
|
||||||
@@ -32,7 +47,10 @@ class OidcAuthorizationCode < ApplicationRecord
|
|||||||
private
|
private
|
||||||
|
|
||||||
def generate_code
|
def generate_code
|
||||||
self.code ||= SecureRandom.urlsafe_base64(32)
|
# Generate random plaintext code
|
||||||
|
self.plaintext_code ||= SecureRandom.urlsafe_base64(32)
|
||||||
|
# Store HMAC in database (not plaintext)
|
||||||
|
self.code_hmac ||= self.class.compute_code_hmac(plaintext_code)
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_expiry
|
def set_expiry
|
||||||
|
|||||||
@@ -2,13 +2,12 @@ class OidcRefreshToken < ApplicationRecord
|
|||||||
belongs_to :application
|
belongs_to :application
|
||||||
belongs_to :user
|
belongs_to :user
|
||||||
belongs_to :oidc_access_token
|
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 :generate_token, on: :create
|
||||||
before_validation :set_expiry, on: :create
|
before_validation :set_expiry, on: :create
|
||||||
before_validation :set_token_family_id, on: :create
|
before_validation :set_token_family_id, on: :create
|
||||||
|
|
||||||
validates :token_digest, presence: true, uniqueness: true
|
validates :token_hmac, presence: true, uniqueness: true
|
||||||
|
|
||||||
scope :valid, -> { where("expires_at > ?", Time.current).where(revoked_at: nil) }
|
scope :valid, -> { where("expires_at > ?", Time.current).where(revoked_at: nil) }
|
||||||
scope :expired, -> { where("expires_at <= ?", Time.current) }
|
scope :expired, -> { where("expires_at <= ?", Time.current) }
|
||||||
@@ -20,6 +19,19 @@ class OidcRefreshToken < ApplicationRecord
|
|||||||
|
|
||||||
attr_accessor :token # Store plaintext token temporarily for returning to client
|
attr_accessor :token # Store plaintext token temporarily for returning to client
|
||||||
|
|
||||||
|
# Find refresh token by plaintext token using HMAC verification
|
||||||
|
def self.find_by_token(plaintext_token)
|
||||||
|
return nil if plaintext_token.blank?
|
||||||
|
|
||||||
|
token_hmac = compute_token_hmac(plaintext_token)
|
||||||
|
find_by(token_hmac: token_hmac)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Compute HMAC for token lookup
|
||||||
|
def self.compute_token_hmac(plaintext_token)
|
||||||
|
OpenSSL::HMAC.hexdigest("SHA256", TokenHmac::KEY, plaintext_token)
|
||||||
|
end
|
||||||
|
|
||||||
def expired?
|
def expired?
|
||||||
expires_at <= Time.current
|
expires_at <= Time.current
|
||||||
end
|
end
|
||||||
@@ -43,35 +55,13 @@ class OidcRefreshToken < ApplicationRecord
|
|||||||
OidcRefreshToken.in_family(token_family_id).update_all(revoked_at: Time.current)
|
OidcRefreshToken.in_family(token_family_id).update_all(revoked_at: Time.current)
|
||||||
end
|
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
|
private
|
||||||
|
|
||||||
def generate_token
|
def generate_token
|
||||||
# Generate a secure random token
|
# Generate random plaintext token
|
||||||
plaintext = SecureRandom.urlsafe_base64(48)
|
self.token ||= SecureRandom.urlsafe_base64(48)
|
||||||
self.token = plaintext # Store temporarily for returning to client
|
# Store HMAC in database (not plaintext)
|
||||||
|
self.token_hmac ||= self.class.compute_token_hmac(token)
|
||||||
# Hash it with BCrypt for storage
|
|
||||||
self.token_digest = BCrypt::Password.create(plaintext)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_expiry
|
def set_expiry
|
||||||
|
|||||||
@@ -3,18 +3,19 @@ class OidcUserConsent < ApplicationRecord
|
|||||||
belongs_to :application
|
belongs_to :application
|
||||||
|
|
||||||
validates :user, :application, :scopes_granted, :granted_at, presence: true
|
validates :user, :application, :scopes_granted, :granted_at, presence: true
|
||||||
validates :user_id, uniqueness: { scope: :application_id }
|
validates :user_id, uniqueness: {scope: :application_id}
|
||||||
|
|
||||||
before_validation :set_granted_at, on: :create
|
before_validation :set_granted_at, on: :create
|
||||||
|
before_validation :set_sid, on: :create
|
||||||
|
|
||||||
# Parse scopes_granted into an array
|
# Parse scopes_granted into an array
|
||||||
def scopes
|
def scopes
|
||||||
scopes_granted.split(' ')
|
scopes_granted.split(" ")
|
||||||
end
|
end
|
||||||
|
|
||||||
# Set scopes from an array
|
# Set scopes from an array
|
||||||
def scopes=(scope_array)
|
def scopes=(scope_array)
|
||||||
self.scopes_granted = Array(scope_array).uniq.join(' ')
|
self.scopes_granted = Array(scope_array).uniq.join(" ")
|
||||||
end
|
end
|
||||||
|
|
||||||
# Check if this consent covers the requested scopes
|
# Check if this consent covers the requested scopes
|
||||||
@@ -30,18 +31,23 @@ class OidcUserConsent < ApplicationRecord
|
|||||||
def formatted_scopes
|
def formatted_scopes
|
||||||
scopes.map do |scope|
|
scopes.map do |scope|
|
||||||
case scope
|
case scope
|
||||||
when 'openid'
|
when "openid"
|
||||||
'Basic authentication'
|
"Basic authentication"
|
||||||
when 'profile'
|
when "profile"
|
||||||
'Profile information'
|
"Profile information"
|
||||||
when 'email'
|
when "email"
|
||||||
'Email address'
|
"Email address"
|
||||||
when 'groups'
|
when "groups"
|
||||||
'Group membership'
|
"Group membership"
|
||||||
else
|
else
|
||||||
scope.humanize
|
scope.humanize
|
||||||
end
|
end
|
||||||
end.join(', ')
|
end.join(", ")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Find consent by SID
|
||||||
|
def self.find_by_sid(sid)
|
||||||
|
find_by(sid: sid)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
@@ -49,4 +55,8 @@ class OidcUserConsent < ApplicationRecord
|
|||||||
def set_granted_at
|
def set_granted_at
|
||||||
self.granted_at ||= Time.current
|
self.granted_at ||= Time.current
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def set_sid
|
||||||
|
self.sid ||= SecureRandom.uuid
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
class User < ApplicationRecord
|
class User < ApplicationRecord
|
||||||
|
# Encrypt TOTP secrets at rest (key derived from SECRET_KEY_BASE)
|
||||||
|
encrypts :totp_secret
|
||||||
|
|
||||||
has_secure_password
|
has_secure_password
|
||||||
has_many :sessions, dependent: :destroy
|
has_many :sessions, dependent: :destroy
|
||||||
has_many :user_groups, dependent: :destroy
|
has_many :user_groups, dependent: :destroy
|
||||||
has_many :groups, through: :user_groups
|
has_many :groups, through: :user_groups
|
||||||
|
has_many :application_user_claims, dependent: :destroy
|
||||||
has_many :oidc_user_consents, dependent: :destroy
|
has_many :oidc_user_consents, dependent: :destroy
|
||||||
has_many :webauthn_credentials, dependent: :destroy
|
has_many :webauthn_credentials, dependent: :destroy
|
||||||
|
|
||||||
@@ -15,18 +19,26 @@ class User < ApplicationRecord
|
|||||||
updated_at
|
updated_at
|
||||||
end
|
end
|
||||||
|
|
||||||
generates_token_for :magic_login, expires_in: 15.minutes do
|
|
||||||
last_sign_in_at
|
|
||||||
end
|
|
||||||
|
|
||||||
normalizes :email_address, with: ->(e) { e.strip.downcase }
|
normalizes :email_address, with: ->(e) { e.strip.downcase }
|
||||||
|
normalizes :username, with: ->(u) { u.strip.downcase if u.present? }
|
||||||
|
|
||||||
validates :email_address, presence: true, uniqueness: { case_sensitive: false },
|
# Reserved OIDC claim names that should not be overridden
|
||||||
format: { with: URI::MailTo::EMAIL_REGEXP }
|
RESERVED_CLAIMS = %w[
|
||||||
validates :password, length: { minimum: 8 }, allow_nil: true
|
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 - automatically creates scopes (User.active, User.disabled, etc.)
|
||||||
enum :status, { active: 0, disabled: 1, pending_invitation: 2 }
|
enum :status, {active: 0, disabled: 1, pending_invitation: 2}
|
||||||
|
|
||||||
# Scopes
|
# Scopes
|
||||||
scope :admins, -> { where(admin: true) }
|
scope :admins, -> { where(admin: true) }
|
||||||
@@ -44,7 +56,9 @@ class User < ApplicationRecord
|
|||||||
end
|
end
|
||||||
|
|
||||||
def disable_totp!
|
def disable_totp!
|
||||||
update!(totp_secret: nil, totp_required: false, backup_codes: nil)
|
# Note: This does NOT clear totp_required flag
|
||||||
|
# Admins control that flag via admin panel, users cannot remove admin-required 2FA
|
||||||
|
update!(totp_secret: nil, backup_codes: nil)
|
||||||
end
|
end
|
||||||
|
|
||||||
def totp_provisioning_uri(issuer: "Clinch")
|
def totp_provisioning_uri(issuer: "Clinch")
|
||||||
@@ -63,6 +77,14 @@ class User < ApplicationRecord
|
|||||||
totp.verify(code, drift_behind: 30, drift_ahead: 30)
|
totp.verify(code, drift_behind: 30, drift_ahead: 30)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Console/debug helper: get current TOTP code
|
||||||
|
def console_totp
|
||||||
|
return nil unless totp_enabled?
|
||||||
|
|
||||||
|
require "rotp"
|
||||||
|
ROTP::TOTP.new(totp_secret).now
|
||||||
|
end
|
||||||
|
|
||||||
def verify_backup_code(code)
|
def verify_backup_code(code)
|
||||||
return false unless backup_codes.present?
|
return false unless backup_codes.present?
|
||||||
|
|
||||||
@@ -100,12 +122,7 @@ class User < ApplicationRecord
|
|||||||
cache_key = "backup_code_failed_attempts_#{id}"
|
cache_key = "backup_code_failed_attempts_#{id}"
|
||||||
attempts = Rails.cache.read(cache_key) || 0
|
attempts = Rails.cache.read(cache_key) || 0
|
||||||
|
|
||||||
if attempts >= 5 # Allow max 5 failed attempts per hour
|
attempts >= 5
|
||||||
true
|
|
||||||
else
|
|
||||||
# Don't increment here - increment only on failed attempts
|
|
||||||
false
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Increment failed attempt counter
|
# Increment failed attempt counter
|
||||||
@@ -180,11 +197,39 @@ class User < ApplicationRecord
|
|||||||
|
|
||||||
# Parse custom_claims JSON field
|
# Parse custom_claims JSON field
|
||||||
def parsed_custom_claims
|
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
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def no_reserved_claim_names
|
||||||
|
return if custom_claims.blank?
|
||||||
|
|
||||||
|
reserved_used = parsed_custom_claims.keys.map(&:to_s) & RESERVED_CLAIMS
|
||||||
|
if reserved_used.any?
|
||||||
|
errors.add(:custom_claims, "cannot override reserved OIDC claims: #{reserved_used.join(", ")}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def generate_backup_codes
|
def generate_backup_codes
|
||||||
# Generate plain codes for user to see/save
|
# Generate plain codes for user to see/save
|
||||||
plain_codes = Array.new(10) { SecureRandom.alphanumeric(8).upcase }
|
plain_codes = Array.new(10) { SecureRandom.alphanumeric(8).upcase }
|
||||||
|
|||||||
@@ -2,5 +2,5 @@ class UserGroup < ApplicationRecord
|
|||||||
belongs_to :user
|
belongs_to :user
|
||||||
belongs_to :group
|
belongs_to :group
|
||||||
|
|
||||||
validates :user_id, uniqueness: { scope: :group_id }
|
validates :user_id, uniqueness: {scope: :group_id}
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ class WebauthnCredential < ApplicationRecord
|
|||||||
# Validations
|
# Validations
|
||||||
validates :external_id, presence: true, uniqueness: true
|
validates :external_id, presence: true, uniqueness: true
|
||||||
validates :public_key, presence: true
|
validates :public_key, presence: true
|
||||||
validates :sign_count, presence: true, numericality: { greater_than_or_equal_to: 0, only_integer: true }
|
validates :sign_count, presence: true, numericality: {greater_than_or_equal_to: 0, only_integer: true}
|
||||||
validates :nickname, presence: true
|
validates :nickname, presence: true
|
||||||
validates :authenticator_type, inclusion: { in: %w[platform cross-platform] }
|
validates :authenticator_type, inclusion: {in: %w[platform cross-platform]}
|
||||||
|
|
||||||
# Scopes for querying
|
# Scopes for querying
|
||||||
scope :active, -> { where(nil) } # All credentials are active (we can add revoked_at later if needed)
|
scope :active, -> { where(nil) } # All credentials are active (we can add revoked_at later if needed)
|
||||||
@@ -84,11 +84,11 @@ class WebauthnCredential < ApplicationRecord
|
|||||||
days = hours / 24
|
days = hours / 24
|
||||||
|
|
||||||
if days > 0
|
if days > 0
|
||||||
"#{days.floor} day#{'s' if days > 1} ago"
|
"#{days.floor} day#{"s" if days > 1} ago"
|
||||||
elsif hours > 0
|
elsif hours > 0
|
||||||
"#{hours.floor} hour#{'s' if hours > 1} ago"
|
"#{hours.floor} hour#{"s" if hours > 1} ago"
|
||||||
elsif minutes > 0
|
elsif minutes > 0
|
||||||
"#{minutes.floor} minute#{'s' if minutes > 1} ago"
|
"#{minutes.floor} minute#{"s" if minutes > 1} ago"
|
||||||
else
|
else
|
||||||
"Just now"
|
"Just now"
|
||||||
end
|
end
|
||||||
|
|||||||
35
app/services/concerns/claims_merger.rb
Normal file
35
app/services/concerns/claims_merger.rb
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
module ClaimsMerger
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
# Deep merge claims, combining arrays instead of overwriting them
|
||||||
|
# This ensures that array values (like roles) are combined across group/user/app claims
|
||||||
|
#
|
||||||
|
# Example:
|
||||||
|
# base = { "roles" => ["user"], "level" => 1 }
|
||||||
|
# incoming = { "roles" => ["admin"], "department" => "IT" }
|
||||||
|
# deep_merge_claims(base, incoming)
|
||||||
|
# # => { "roles" => ["user", "admin"], "level" => 1, "department" => "IT" }
|
||||||
|
def deep_merge_claims(base, incoming)
|
||||||
|
result = base.dup
|
||||||
|
|
||||||
|
incoming.each do |key, value|
|
||||||
|
result[key] = 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] + value).uniq
|
||||||
|
# If both values are hashes, recursively merge them
|
||||||
|
elsif result[key].is_a?(Hash) && value.is_a?(Hash)
|
||||||
|
deep_merge_claims(result[key], value)
|
||||||
|
else
|
||||||
|
# Otherwise, incoming value wins (override)
|
||||||
|
value
|
||||||
|
end
|
||||||
|
else
|
||||||
|
# New key, just add it
|
||||||
|
value
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
result
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,48 +1,95 @@
|
|||||||
class OidcJwtService
|
class OidcJwtService
|
||||||
|
extend ClaimsMerger
|
||||||
|
|
||||||
class << self
|
class << self
|
||||||
# Generate an ID token (JWT) for the user
|
# Generate an ID token (JWT) for the user
|
||||||
def generate_id_token(user, application, nonce: nil)
|
def generate_id_token(user, application, consent: nil, nonce: nil, access_token: nil, auth_time: nil, acr: nil)
|
||||||
now = Time.current.to_i
|
now = Time.current.to_i
|
||||||
# Use application's configured ID token TTL (defaults to 1 hour)
|
# Use application's configured ID token TTL (defaults to 1 hour)
|
||||||
ttl = application.id_token_expiry_seconds
|
ttl = application.id_token_expiry_seconds
|
||||||
|
|
||||||
|
# Use pairwise SID from consent if available, fallback to user ID
|
||||||
|
subject = consent&.sid || user.id.to_s
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
iss: issuer_url,
|
iss: issuer_url,
|
||||||
sub: user.id.to_s,
|
sub: subject,
|
||||||
aud: application.client_id,
|
aud: application.client_id,
|
||||||
exp: now + ttl,
|
exp: now + ttl,
|
||||||
iat: now,
|
iat: now,
|
||||||
email: user.email_address,
|
email: user.email_address,
|
||||||
email_verified: true,
|
email_verified: true,
|
||||||
preferred_username: user.email_address,
|
preferred_username: user.username.presence || user.email_address,
|
||||||
name: user.name.presence || user.email_address
|
name: user.name.presence || user.email_address
|
||||||
}
|
}
|
||||||
|
|
||||||
# Add nonce if provided (OIDC requires this for implicit flow)
|
# Add nonce if provided (OIDC requires this for implicit flow)
|
||||||
payload[:nonce] = nonce if nonce.present?
|
payload[:nonce] = nonce if nonce.present?
|
||||||
|
|
||||||
|
# Add auth_time if provided (OIDC Core §2 - required when max_age is used)
|
||||||
|
payload[:auth_time] = auth_time if auth_time.present?
|
||||||
|
|
||||||
|
# Add acr if provided (OIDC Core §2 - authentication context class reference)
|
||||||
|
payload[:acr] = acr if acr.present?
|
||||||
|
|
||||||
|
# Add azp (authorized party) - the client_id this token was issued to
|
||||||
|
# OIDC Core §2 - required when aud has multiple values, optional but useful for single
|
||||||
|
payload[:azp] = application.client_id
|
||||||
|
|
||||||
|
# Add at_hash if access token is provided (OIDC Core spec §3.1.3.6)
|
||||||
|
# at_hash = left-most 128 bits of SHA-256 hash of access token, base64url encoded
|
||||||
|
if access_token.present?
|
||||||
|
sha256 = Digest::SHA256.digest(access_token)
|
||||||
|
at_hash = Base64.urlsafe_encode64(sha256[0..15], padding: false)
|
||||||
|
payload[:at_hash] = at_hash
|
||||||
|
end
|
||||||
|
|
||||||
# Add groups if user has any
|
# Add groups if user has any
|
||||||
if user.groups.any?
|
if user.groups.any?
|
||||||
payload[:groups] = user.groups.pluck(:name)
|
payload[:groups] = user.groups.pluck(:name)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Add admin claim if user is admin
|
# Merge custom claims from groups (arrays are combined, not overwritten)
|
||||||
payload[:admin] = true if user.admin?
|
|
||||||
|
|
||||||
# Merge custom claims from groups
|
|
||||||
user.groups.each do |group|
|
user.groups.each do |group|
|
||||||
payload.merge!(group.parsed_custom_claims)
|
payload = deep_merge_claims(payload, group.parsed_custom_claims)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Merge custom claims from user (overrides group claims)
|
# Merge custom claims from user (arrays are combined, other values override)
|
||||||
payload.merge!(user.parsed_custom_claims)
|
payload = deep_merge_claims(payload, user.parsed_custom_claims)
|
||||||
|
|
||||||
JWT.encode(payload, private_key, "RS256", { kid: key_id, typ: "JWT" })
|
# 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
|
end
|
||||||
|
|
||||||
# Decode and verify an ID token
|
# Decode and verify an ID token
|
||||||
def decode_id_token(token)
|
def decode_id_token(token)
|
||||||
JWT.decode(token, public_key, true, { algorithm: "RS256" })
|
JWT.decode(token, public_key, true, {algorithm: "RS256"})
|
||||||
end
|
end
|
||||||
|
|
||||||
# Get the public key in JWK format for the JWKS endpoint
|
# Get the public key in JWK format for the JWKS endpoint
|
||||||
@@ -66,8 +113,13 @@ class OidcJwtService
|
|||||||
# In production, this should come from ENV or config
|
# In production, this should come from ENV or config
|
||||||
# For now, we'll use a placeholder that can be overridden
|
# For now, we'll use a placeholder that can be overridden
|
||||||
host = ENV.fetch("CLINCH_HOST", "localhost:3000")
|
host = ENV.fetch("CLINCH_HOST", "localhost:3000")
|
||||||
# Ensure URL has https:// protocol
|
# Ensure URL has protocol - use https:// in production, http:// in development
|
||||||
host.match?(/^https?:\/\//) ? host : "https://#{host}"
|
if host.match?(/^https?:\/\//)
|
||||||
|
host
|
||||||
|
else
|
||||||
|
protocol = Rails.env.production? ? "https" : "http"
|
||||||
|
"#{protocol}://#{host}"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
@@ -75,17 +127,37 @@ class OidcJwtService
|
|||||||
# Get or generate RSA private key
|
# Get or generate RSA private key
|
||||||
def private_key
|
def private_key
|
||||||
@private_key ||= begin
|
@private_key ||= begin
|
||||||
|
key_source = nil
|
||||||
|
|
||||||
# Try ENV variable first (best for Docker/Kamal)
|
# Try ENV variable first (best for Docker/Kamal)
|
||||||
if ENV["OIDC_PRIVATE_KEY"].present?
|
if ENV["OIDC_PRIVATE_KEY"].present?
|
||||||
OpenSSL::PKey::RSA.new(ENV["OIDC_PRIVATE_KEY"])
|
key_source = ENV["OIDC_PRIVATE_KEY"]
|
||||||
# Then try Rails credentials
|
# Then try Rails credentials
|
||||||
elsif Rails.application.credentials.oidc_private_key.present?
|
elsif Rails.application.credentials.oidc_private_key.present?
|
||||||
OpenSSL::PKey::RSA.new(Rails.application.credentials.oidc_private_key)
|
key_source = Rails.application.credentials.oidc_private_key
|
||||||
|
end
|
||||||
|
|
||||||
|
if key_source.present?
|
||||||
|
begin
|
||||||
|
# Handle both actual newlines and escaped \n sequences
|
||||||
|
# Some .env loaders may escape newlines, so we need to convert them back
|
||||||
|
key_data = key_source.gsub("\\n", "\n")
|
||||||
|
OpenSSL::PKey::RSA.new(key_data)
|
||||||
|
rescue OpenSSL::PKey::RSAError => e
|
||||||
|
Rails.logger.error "OIDC: Failed to load private key: #{e.message}"
|
||||||
|
Rails.logger.error "OIDC: Key source length: #{key_source.length}, starts with: #{key_source[0..50]}"
|
||||||
|
raise "Invalid OIDC private key format. Please ensure the key is in PEM format with proper newlines."
|
||||||
|
end
|
||||||
else
|
else
|
||||||
# Generate a new key for development
|
# In production, we should never generate a key on the fly
|
||||||
# In production, you MUST set OIDC_PRIVATE_KEY env var or add to credentials
|
# because it would be different across servers/deployments
|
||||||
|
if Rails.env.production?
|
||||||
|
raise "OIDC private key not configured. Set OIDC_PRIVATE_KEY environment variable or add to Rails credentials."
|
||||||
|
end
|
||||||
|
|
||||||
|
# Generate a new key for development/test only
|
||||||
Rails.logger.warn "OIDC: No private key found in ENV or credentials, generating new key (development only)"
|
Rails.logger.warn "OIDC: No private key found in ENV or credentials, generating new key (development only)"
|
||||||
Rails.logger.warn "OIDC: Set OIDC_PRIVATE_KEY environment variable in production!"
|
Rails.logger.warn "OIDC: Set OIDC_PRIVATE_KEY environment variable for consistency across restarts"
|
||||||
OpenSSL::PKey::RSA.new(2048)
|
OpenSSL::PKey::RSA.new(2048)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -17,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" %>
|
<%= 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>
|
||||||
|
<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>
|
<div>
|
||||||
<%= form.label :landing_url, "Landing URL", class: "block text-sm font-medium text-gray-700" %>
|
<%= 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" %>
|
<%= 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" %>
|
||||||
@@ -39,12 +120,67 @@
|
|||||||
<div id="oidc-fields" class="space-y-6 border-t border-gray-200 pt-6 <%= 'hidden' unless application.oidc? || !application.persisted? %>" data-application-form-target="oidcFields">
|
<div id="oidc-fields" class="space-y-6 border-t border-gray-200 pt-6 <%= 'hidden' unless application.oidc? || !application.persisted? %>" data-application-form-target="oidcFields">
|
||||||
<h3 class="text-base font-semibold text-gray-900">OIDC Configuration</h3>
|
<h3 class="text-base font-semibold text-gray-900">OIDC Configuration</h3>
|
||||||
|
|
||||||
|
<!-- Client Type Selection (only for new applications) -->
|
||||||
|
<% unless application.persisted? %>
|
||||||
|
<div class="border border-gray-200 rounded-lg p-4 bg-gray-50">
|
||||||
|
<h4 class="text-sm font-semibold text-gray-900 mb-3">Client Type</h4>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex items-start">
|
||||||
|
<%= form.radio_button :is_public_client, "false", checked: !application.is_public_client, class: "mt-1 h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-500", data: { action: "change->application-form#updatePkceVisibility" } %>
|
||||||
|
<div class="ml-3">
|
||||||
|
<label for="application_is_public_client_false" class="block text-sm font-medium text-gray-900">Confidential Client (Recommended)</label>
|
||||||
|
<p class="text-sm text-gray-500">Backend server app that can securely store a client secret. Examples: traditional web apps, server-to-server APIs.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start">
|
||||||
|
<%= form.radio_button :is_public_client, "true", checked: application.is_public_client, class: "mt-1 h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-500", data: { action: "change->application-form#updatePkceVisibility" } %>
|
||||||
|
<div class="ml-3">
|
||||||
|
<label for="application_is_public_client_true" class="block text-sm font-medium text-gray-900">Public Client</label>
|
||||||
|
<p class="text-sm text-gray-500">Frontend-only app that cannot store secrets securely. Examples: SPAs (React/Vue), mobile apps, CLI tools. <strong class="text-amber-600">PKCE is required.</strong></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<!-- Show client type for existing applications (read-only) -->
|
||||||
|
<div class="flex items-center gap-2 text-sm">
|
||||||
|
<span class="font-medium text-gray-700">Client Type:</span>
|
||||||
|
<% if application.public_client? %>
|
||||||
|
<span class="inline-flex items-center rounded-md bg-amber-50 px-2 py-1 text-xs font-medium text-amber-700 ring-1 ring-inset ring-amber-600/20">Public Client (PKCE Required)</span>
|
||||||
|
<% else %>
|
||||||
|
<span class="inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/20">Confidential Client</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<!-- PKCE Requirement (only for confidential clients) -->
|
||||||
|
<div id="pkce-options" data-application-form-target="pkceOptions" class="<%= 'hidden' if application.persisted? && application.public_client? %>">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<%= form.check_box :require_pkce, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %>
|
||||||
|
<%= form.label :require_pkce, "Require PKCE (Proof Key for Code Exchange)", class: "ml-2 block text-sm font-medium text-gray-900" %>
|
||||||
|
</div>
|
||||||
|
<p class="ml-6 text-sm text-gray-500">
|
||||||
|
Recommended for enhanced security (OAuth 2.1 best practice).
|
||||||
|
<br><span class="text-xs text-gray-400">Note: Public clients always require PKCE regardless of this setting.</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<%= form.label :redirect_uris, "Redirect URIs", class: "block text-sm font-medium text-gray-700" %>
|
<%= form.label :redirect_uris, "Redirect URIs", class: "block text-sm font-medium text-gray-700" %>
|
||||||
<%= form.text_area :redirect_uris, rows: 4, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono", placeholder: "https://example.com/callback\nhttps://app.example.com/auth/callback" %>
|
<%= form.text_area :redirect_uris, rows: 4, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono", placeholder: "https://example.com/callback\nhttps://app.example.com/auth/callback" %>
|
||||||
<p class="mt-1 text-sm text-gray-500">One URI per line. These are the allowed callback URLs for your application.</p>
|
<p class="mt-1 text-sm text-gray-500">One URI per line. These are the allowed callback URLs for your application.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= form.label :backchannel_logout_uri, "Backchannel Logout URI (Optional)", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.url_field :backchannel_logout_uri, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono", placeholder: "https://app.example.com/oidc/backchannel-logout" %>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">
|
||||||
|
If the application supports OpenID Connect Backchannel Logout, enter the logout endpoint URL.
|
||||||
|
When users log out, Clinch will send logout notifications to this endpoint for immediate session termination.
|
||||||
|
Leave blank if the application doesn't support backchannel logout.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="border-t border-gray-200 pt-4 mt-4">
|
<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>
|
<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>
|
<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>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
<table class="min-w-full divide-y divide-gray-300">
|
<table class="min-w-full divide-y divide-gray-300">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-0">Name</th>
|
<th scope="col" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-0">Application</th>
|
||||||
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Slug</th>
|
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Slug</th>
|
||||||
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Type</th>
|
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Type</th>
|
||||||
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Status</th>
|
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Status</th>
|
||||||
@@ -28,7 +28,18 @@
|
|||||||
<% @applications.each do |application| %>
|
<% @applications.each do |application| %>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-0">
|
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-0">
|
||||||
<%= 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>
|
||||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||||
<code class="text-xs bg-gray-100 px-2 py-1 rounded"><%= application.slug %></code>
|
<code class="text-xs bg-gray-100 px-2 py-1 rounded"><%= application.slug %></code>
|
||||||
|
|||||||
@@ -1,25 +1,49 @@
|
|||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<% if flash[:client_id] && flash[:client_secret] %>
|
<% if flash[:client_id] %>
|
||||||
<div class="bg-yellow-50 border border-yellow-200 rounded-md p-4 mb-6">
|
<div class="bg-yellow-50 border border-yellow-200 rounded-md p-4 mb-6">
|
||||||
<h4 class="text-sm font-medium text-yellow-800 mb-2">🔐 OIDC Client Credentials</h4>
|
<h4 class="text-sm font-medium text-yellow-800 mb-2">🔐 OIDC Client Credentials</h4>
|
||||||
<p class="text-xs text-yellow-700 mb-3">Copy these credentials now. The client secret will not be shown again.</p>
|
<% if flash[:public_client] %>
|
||||||
|
<p class="text-xs text-yellow-700 mb-3">This is a public client. Copy the client ID below.</p>
|
||||||
|
<% else %>
|
||||||
|
<p class="text-xs text-yellow-700 mb-3">Copy these credentials now. The client secret will not be shown again.</p>
|
||||||
|
<% end %>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div>
|
<div>
|
||||||
<span class="text-xs font-medium text-yellow-700">Client ID:</span>
|
<span class="text-xs font-medium text-yellow-700">Client ID:</span>
|
||||||
</div>
|
</div>
|
||||||
<code class="block bg-yellow-100 px-3 py-2 rounded font-mono text-xs break-all"><%= flash[:client_id] %></code>
|
<code class="block bg-yellow-100 px-3 py-2 rounded font-mono text-xs break-all"><%= flash[:client_id] %></code>
|
||||||
<div class="mt-3">
|
<% if flash[:client_secret] %>
|
||||||
<span class="text-xs font-medium text-yellow-700">Client Secret:</span>
|
<div class="mt-3">
|
||||||
</div>
|
<span class="text-xs font-medium text-yellow-700">Client Secret:</span>
|
||||||
<code class="block bg-yellow-100 px-3 py-2 rounded font-mono text-xs break-all"><%= flash[:client_secret] %></code>
|
</div>
|
||||||
|
<code class="block bg-yellow-100 px-3 py-2 rounded font-mono text-xs break-all"><%= flash[:client_secret] %></code>
|
||||||
|
<% elsif flash[:public_client] %>
|
||||||
|
<div class="mt-3">
|
||||||
|
<span class="text-xs font-medium text-yellow-700">Client Secret:</span>
|
||||||
|
</div>
|
||||||
|
<div class="bg-yellow-100 px-3 py-2 rounded text-xs text-yellow-600">
|
||||||
|
Public clients do not have a client secret. PKCE is required.
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<div class="sm:flex sm:items-center sm:justify-between">
|
<div class="sm:flex sm:items-start sm:justify-between">
|
||||||
<div>
|
<div class="flex items-start gap-4">
|
||||||
<h1 class="text-2xl font-semibold text-gray-900"><%= @application.name %></h1>
|
<% if @application.icon.attached? %>
|
||||||
<p class="mt-1 text-sm text-gray-500"><%= @application.description %></p>
|
<%= 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>
|
||||||
<div class="mt-4 sm:mt-0 flex gap-3">
|
<div class="mt-4 sm:mt-0 flex gap-3">
|
||||||
<%= link_to "Edit", edit_admin_application_path(@application), class: "rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50" %>
|
<%= link_to "Edit", edit_admin_application_path(@application), class: "rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50" %>
|
||||||
@@ -78,27 +102,62 @@
|
|||||||
<div class="bg-white shadow sm:rounded-lg">
|
<div class="bg-white shadow sm:rounded-lg">
|
||||||
<div class="px-4 py-5 sm:p-6">
|
<div class="px-4 py-5 sm:p-6">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h3 class="text-base font-semibold leading-6 text-gray-900">OIDC Credentials</h3>
|
<h3 class="text-base font-semibold leading-6 text-gray-900">OIDC Configuration</h3>
|
||||||
<%= button_to "Regenerate Credentials", regenerate_credentials_admin_application_path(@application), method: :post, data: { turbo_confirm: "This will invalidate the current credentials. Continue?" }, class: "text-sm text-red-600 hover:text-red-900" %>
|
<%= button_to "Regenerate Credentials", regenerate_credentials_admin_application_path(@application), method: :post, data: { turbo_confirm: "This will invalidate the current credentials. Continue?" }, class: "text-sm text-red-600 hover:text-red-900" %>
|
||||||
</div>
|
</div>
|
||||||
<dl class="space-y-4">
|
<dl class="space-y-4">
|
||||||
<div>
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<dt class="text-sm font-medium text-gray-500">Client ID</dt>
|
<div>
|
||||||
<dd class="mt-1 text-sm text-gray-900">
|
<dt class="text-sm font-medium text-gray-500">Client Type</dt>
|
||||||
<code class="block bg-gray-100 px-3 py-2 rounded font-mono text-xs break-all"><%= @application.client_id %></code>
|
<dd class="mt-1 text-sm text-gray-900">
|
||||||
</dd>
|
<% if @application.public_client? %>
|
||||||
|
<span class="inline-flex items-center rounded-full bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700">Public</span>
|
||||||
|
<% else %>
|
||||||
|
<span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700">Confidential</span>
|
||||||
|
<% end %>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">PKCE</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900">
|
||||||
|
<% if @application.requires_pkce? %>
|
||||||
|
<span class="inline-flex items-center rounded-full bg-green-100 px-2 py-1 text-xs font-medium text-green-700">Required</span>
|
||||||
|
<% else %>
|
||||||
|
<span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700">Optional</span>
|
||||||
|
<% end %>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<% unless flash[:client_id] %>
|
||||||
<dt class="text-sm font-medium text-gray-500">Client Secret</dt>
|
<div>
|
||||||
<dd class="mt-1 text-sm text-gray-900">
|
<dt class="text-sm font-medium text-gray-500">Client ID</dt>
|
||||||
<div class="bg-gray-100 px-3 py-2 rounded text-xs text-gray-500 italic">
|
<dd class="mt-1 text-sm text-gray-900">
|
||||||
🔒 Client secret is stored securely and cannot be displayed
|
<code class="block bg-gray-100 px-3 py-2 rounded font-mono text-xs break-all"><%= @application.client_id %></code>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<% if @application.confidential_client? %>
|
||||||
|
<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>
|
</div>
|
||||||
<p class="mt-2 text-xs text-gray-500">
|
<% else %>
|
||||||
To get a new client secret, use the "Regenerate Credentials" button above.
|
<div>
|
||||||
</p>
|
<dt class="text-sm font-medium text-gray-500">Client Secret</dt>
|
||||||
</dd>
|
<dd class="mt-1 text-sm text-gray-900">
|
||||||
</div>
|
<div class="bg-blue-50 px-3 py-2 rounded text-xs text-blue-600">
|
||||||
|
Public clients do not use a client secret. PKCE is required for authorization.
|
||||||
|
</div>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-sm font-medium text-gray-500">Redirect URIs</dt>
|
<dt class="text-sm font-medium text-gray-500">Redirect URIs</dt>
|
||||||
<dd class="mt-1 text-sm text-gray-900">
|
<dd class="mt-1 text-sm text-gray-900">
|
||||||
@@ -111,6 +170,27 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">
|
||||||
|
Backchannel Logout URI
|
||||||
|
<% if @application.supports_backchannel_logout? %>
|
||||||
|
<span class="ml-2 inline-flex items-center rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700">Enabled</span>
|
||||||
|
<% end %>
|
||||||
|
</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900">
|
||||||
|
<% if @application.backchannel_logout_uri.present? %>
|
||||||
|
<code class="block bg-gray-100 px-3 py-2 rounded font-mono text-xs break-all"><%= @application.backchannel_logout_uri %></code>
|
||||||
|
<p class="mt-2 text-xs text-gray-500">
|
||||||
|
When users log out, Clinch will send logout notifications to this endpoint for immediate session termination.
|
||||||
|
</p>
|
||||||
|
<% else %>
|
||||||
|
<span class="text-gray-400 italic">Not configured</span>
|
||||||
|
<p class="mt-1 text-xs text-gray-500">
|
||||||
|
Backchannel logout is optional. Configure it if the application supports OpenID Connect Backchannel Logout.
|
||||||
|
</p>
|
||||||
|
<% end %>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
185
app/views/admin/users/_application_claims.html.erb
Normal file
185
app/views/admin/users/_application_claims.html.erb
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
<% oidc_apps = applications.select(&:oidc?) %>
|
||||||
|
<% forward_auth_apps = applications.select(&:forward_auth?) %>
|
||||||
|
|
||||||
|
<!-- OIDC Apps: Custom Claims -->
|
||||||
|
<% if oidc_apps.any? %>
|
||||||
|
<div class="mt-12 border-t pt-8">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 mb-4">OIDC App-Specific Claims</h2>
|
||||||
|
<p class="text-sm text-gray-600 mb-6">
|
||||||
|
Configure custom claims that apply only to specific OIDC applications. These override both group and user global claims and are included in ID tokens.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<% oidc_apps.each do |app| %>
|
||||||
|
<% app_claim = user.application_user_claims.find_by(application: app) %>
|
||||||
|
<details class="border rounded-lg" <%= "open" if app_claim&.custom_claims&.any? %>>
|
||||||
|
<summary class="cursor-pointer bg-gray-50 px-4 py-3 hover:bg-gray-100 rounded-t-lg flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="font-medium text-gray-900"><%= app.name %></span>
|
||||||
|
<span class="text-xs px-2 py-1 rounded-full bg-blue-100 text-blue-700">
|
||||||
|
OIDC
|
||||||
|
</span>
|
||||||
|
<% if app_claim&.custom_claims&.any? %>
|
||||||
|
<span class="text-xs px-2 py-1 rounded-full bg-amber-100 text-amber-700">
|
||||||
|
<%= app_claim.custom_claims.keys.count %> claim(s)
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<svg class="h-5 w-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
<div class="p-4 space-y-4">
|
||||||
|
<%= form_with url: update_application_claims_admin_user_path(user), method: :post, class: "space-y-4", data: { controller: "json-validator" } do |form| %>
|
||||||
|
<%= hidden_field_tag :application_id, app.id %>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Custom Claims (JSON)</label>
|
||||||
|
<%= text_area_tag :custom_claims,
|
||||||
|
(app_claim&.custom_claims.present? ? JSON.pretty_generate(app_claim.custom_claims) : ""),
|
||||||
|
rows: 8,
|
||||||
|
class: "w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono",
|
||||||
|
placeholder: '{"kavita_groups": ["admin"], "library_access": "all"}',
|
||||||
|
data: {
|
||||||
|
action: "input->json-validator#validate blur->json-validator#format",
|
||||||
|
json_validator_target: "textarea"
|
||||||
|
} %>
|
||||||
|
<div class="mt-2 space-y-1">
|
||||||
|
<p class="text-xs text-gray-600">
|
||||||
|
Example for <%= app.name %>: Add claims that this app specifically needs to read.
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-amber-600">
|
||||||
|
<strong>Note:</strong> Do not use reserved claim names (<code class="bg-amber-50 px-1 rounded">groups</code>, <code class="bg-amber-50 px-1 rounded">email</code>, <code class="bg-amber-50 px-1 rounded">name</code>, etc.). Use app-specific names like <code class="bg-amber-50 px-1 rounded">kavita_groups</code> instead.
|
||||||
|
</p>
|
||||||
|
<div data-json-validator-target="status" class="text-xs font-medium"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<%= button_tag type: :submit, class: "rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500" do %>
|
||||||
|
<%= app_claim ? "Update" : "Add" %> Claims
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<% if app_claim %>
|
||||||
|
<%= button_to "Remove Override",
|
||||||
|
delete_application_claims_admin_user_path(user, application_id: app.id),
|
||||||
|
method: :delete,
|
||||||
|
data: { turbo_confirm: "Remove app-specific claims for #{app.name}?" },
|
||||||
|
class: "rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50" %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<!-- Preview merged claims -->
|
||||||
|
<div class="mt-4 border-t pt-4">
|
||||||
|
<h4 class="text-sm font-medium text-gray-700 mb-2">Preview: Final ID Token Claims for <%= app.name %></h4>
|
||||||
|
<div class="bg-gray-50 rounded-lg p-3">
|
||||||
|
<pre class="text-xs font-mono text-gray-800 overflow-x-auto"><%= JSON.pretty_generate(preview_user_claims(user, app)) %></pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details class="mt-2">
|
||||||
|
<summary class="cursor-pointer text-xs text-gray-600 hover:text-gray-900">Show claim sources</summary>
|
||||||
|
<div class="mt-2 space-y-1">
|
||||||
|
<% claim_sources(user, app).each do |source| %>
|
||||||
|
<div class="flex gap-2 items-start text-xs">
|
||||||
|
<span class="px-2 py-1 rounded <%= source[:type] == :group ? 'bg-blue-100 text-blue-700' : (source[:type] == :user ? 'bg-green-100 text-green-700' : 'bg-amber-100 text-amber-700') %>">
|
||||||
|
<%= source[:name] %>
|
||||||
|
</span>
|
||||||
|
<code class="text-gray-700"><%= source[:claims].to_json %></code>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<!-- ForwardAuth Apps: Headers Preview -->
|
||||||
|
<% if forward_auth_apps.any? %>
|
||||||
|
<div class="mt-12 border-t pt-8">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 mb-4">ForwardAuth Headers Preview</h2>
|
||||||
|
<p class="text-sm text-gray-600 mb-6">
|
||||||
|
ForwardAuth applications receive HTTP headers (not OIDC tokens). Headers are based on user's email, name, groups, and admin status.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<% forward_auth_apps.each do |app| %>
|
||||||
|
<details class="border rounded-lg">
|
||||||
|
<summary class="cursor-pointer bg-gray-50 px-4 py-3 hover:bg-gray-100 rounded-t-lg flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="font-medium text-gray-900"><%= app.name %></span>
|
||||||
|
<span class="text-xs px-2 py-1 rounded-full bg-green-100 text-green-700">
|
||||||
|
FORWARD AUTH
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-gray-500">
|
||||||
|
<%= app.domain_pattern %>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<svg class="h-5 w-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
<div class="p-4 space-y-4">
|
||||||
|
<div class="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||||
|
<div class="flex items-start">
|
||||||
|
<svg class="h-5 w-5 text-blue-400 mr-2 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 class="text-sm font-medium text-gray-700 mb-2">Headers Sent to <%= app.name %></h4>
|
||||||
|
<div class="bg-gray-50 rounded-lg p-3 border">
|
||||||
|
<% headers = app.headers_for_user(user) %>
|
||||||
|
<% if headers.any? %>
|
||||||
|
<dl class="space-y-2 text-xs font-mono">
|
||||||
|
<% headers.each do |header_name, value| %>
|
||||||
|
<div class="flex">
|
||||||
|
<dt class="text-blue-600 font-semibold w-48"><%= header_name %>:</dt>
|
||||||
|
<dd class="text-gray-800 flex-1"><%= value %></dd>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</dl>
|
||||||
|
<% else %>
|
||||||
|
<p class="text-xs text-gray-500 italic">All headers disabled for this application.</p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-xs text-gray-500">
|
||||||
|
These headers are configured in the application settings and sent by your reverse proxy (Caddy/Traefik) to the upstream application.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if user.groups.any? %>
|
||||||
|
<div>
|
||||||
|
<h4 class="text-sm font-medium text-gray-700 mb-2">User's Groups</h4>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<% user.groups.each do |group| %>
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||||
|
<%= group.name %>
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<% if oidc_apps.empty? && forward_auth_apps.empty? %>
|
||||||
|
<div class="mt-12 border-t pt-8">
|
||||||
|
<div class="text-center py-12 bg-gray-50 rounded-lg">
|
||||||
|
<p class="text-gray-500">No active applications found.</p>
|
||||||
|
<p class="text-sm text-gray-400 mt-1">Create applications in the Admin panel first.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
@@ -6,10 +6,16 @@
|
|||||||
<%= form.email_field :email_address, required: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "user@example.com" %>
|
<%= form.email_field :email_address, required: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "user@example.com" %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= form.label :username, "Username (Optional)", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.text_field :username, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "jsmith" %>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">Optional: Short username/handle for login. Can only contain letters, numbers, underscores, and hyphens.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<%= form.label :name, "Display Name (Optional)", class: "block text-sm font-medium text-gray-700" %>
|
<%= 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" %>
|
<%= 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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -35,6 +41,25 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<%= form.check_box :totp_required, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %>
|
||||||
|
<%= form.label :totp_required, "Require Two-Factor Authentication", class: "ml-2 block text-sm text-gray-900" %>
|
||||||
|
<% if user.totp_required? && !user.totp_enabled? %>
|
||||||
|
<span class="ml-2 text-xs text-amber-600">(User has not set up 2FA yet)</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% if user.totp_required? && !user.totp_enabled? %>
|
||||||
|
<p class="mt-1 text-sm text-amber-600">
|
||||||
|
<svg class="inline h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
Warning: This user will be prompted to set up 2FA on their next login.
|
||||||
|
</p>
|
||||||
|
<% end %>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">When enabled, this user must use two-factor authentication to sign in.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div data-controller="json-validator" data-json-validator-valid-class="border-green-500 focus:border-green-500 focus:ring-green-500" data-json-validator-invalid-class="border-red-500 focus:border-red-500 focus:ring-red-500" data-json-validator-valid-status-class="text-green-600" data-json-validator-invalid-status-class="text-red-600">
|
<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.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,
|
<%= form.text_area :custom_claims, value: (user.custom_claims.present? ? JSON.pretty_generate(user.custom_claims) : ""), rows: 8,
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
<div class="max-w-2xl">
|
<div class="max-w-4xl">
|
||||||
<h1 class="text-2xl font-semibold text-gray-900 mb-6">Edit User</h1>
|
<h1 class="text-2xl font-semibold text-gray-900 mb-6">Edit User</h1>
|
||||||
<p class="text-sm text-gray-600 mb-6">Editing: <%= @user.email_address %></p>
|
<p class="text-sm text-gray-600 mb-6">Editing: <%= @user.email_address %></p>
|
||||||
<%= 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>
|
</div>
|
||||||
|
|||||||
@@ -85,15 +85,20 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</td>
|
</td>
|
||||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||||
<% if user.totp_enabled? %>
|
<div class="flex items-center gap-2">
|
||||||
<svg class="h-5 w-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<% if user.totp_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 class="h-5 w-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" title="2FA Enabled">
|
||||||
</svg>
|
<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>
|
||||||
<% else %>
|
</svg>
|
||||||
<svg class="h-5 w-5 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<% else %>
|
||||||
<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 class="h-5 w-5 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24" title="2FA Not Enabled">
|
||||||
</svg>
|
<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>
|
||||||
<% end %>
|
</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>
|
||||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||||
<%= user.groups.count %>
|
<%= user.groups.count %>
|
||||||
|
|||||||
@@ -102,38 +102,56 @@
|
|||||||
<% @applications.each do |app| %>
|
<% @applications.each do |app| %>
|
||||||
<div class="bg-white rounded-lg border border-gray-200 shadow-sm hover:shadow-md transition">
|
<div class="bg-white rounded-lg border border-gray-200 shadow-sm hover:shadow-md transition">
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<div class="flex items-center justify-between mb-3">
|
<div class="flex items-start gap-3 mb-4">
|
||||||
<h3 class="text-lg font-semibold text-gray-900 truncate">
|
<% if app.icon.attached? %>
|
||||||
<%= app.name %>
|
<%= image_tag app.icon, class: "h-12 w-12 rounded-lg object-cover border border-gray-200 shrink-0", alt: "#{app.name} icon" %>
|
||||||
</h3>
|
<% else %>
|
||||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
|
<div class="h-12 w-12 rounded-lg bg-gray-100 border border-gray-200 flex items-center justify-center shrink-0">
|
||||||
<% if app.oidc? %>
|
<svg class="h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
bg-blue-100 text-blue-800
|
<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" />
|
||||||
<% else %>
|
</svg>
|
||||||
bg-green-100 text-green-800
|
</div>
|
||||||
<% end %>">
|
<% end %>
|
||||||
<%= app.app_type.humanize %>
|
<div class="flex-1 min-w-0">
|
||||||
</span>
|
<div class="flex items-start justify-between">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 truncate">
|
||||||
|
<%= app.name %>
|
||||||
|
</h3>
|
||||||
|
<span class="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium shrink-0
|
||||||
|
<% if app.oidc? %>
|
||||||
|
bg-blue-100 text-blue-800
|
||||||
|
<% else %>
|
||||||
|
bg-green-100 text-green-800
|
||||||
|
<% end %>">
|
||||||
|
<%= app.app_type.humanize %>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<% if app.description.present? %>
|
||||||
|
<p class="text-sm text-gray-600 mt-1 line-clamp-2">
|
||||||
|
<%= app.description %>
|
||||||
|
</p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="text-sm text-gray-600 mb-4">
|
<div class="space-y-2">
|
||||||
<% if app.oidc? %>
|
<% if app.landing_url.present? %>
|
||||||
OIDC Application
|
<%= 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 %>
|
<% else %>
|
||||||
ForwardAuth Protected Application
|
<div class="text-sm text-gray-500 italic">
|
||||||
|
No landing URL configured
|
||||||
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</p>
|
|
||||||
|
|
||||||
<% if app.landing_url.present? %>
|
<% if app.user_has_active_session?(@user) %>
|
||||||
<%= link_to "Open Application", app.landing_url,
|
<%= button_to "Logout", logout_from_app_active_sessions_path(application_id: app.id), method: :delete,
|
||||||
target: "_blank",
|
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",
|
||||||
rel: "noopener noreferrer",
|
form: { data: { turbo_confirm: "This will log you out of #{app.name}. You can sign back in without re-authorizing. Continue?" } } %>
|
||||||
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" %>
|
<% end %>
|
||||||
<% else %>
|
</div>
|
||||||
<div class="text-sm text-gray-500 italic">
|
|
||||||
No landing URL configured
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
<div class="mx-auto max-w-md">
|
<div class="mx-auto max-w-md">
|
||||||
<div class="bg-white py-8 px-6 shadow rounded-lg sm:px-10">
|
<div class="bg-white py-8 px-6 shadow rounded-lg sm:px-10">
|
||||||
<div class="mb-8">
|
<div class="mb-8 text-center">
|
||||||
|
<% if @application.icon.attached? %>
|
||||||
|
<%= image_tag @application.icon, class: "mx-auto h-20 w-20 rounded-xl object-cover border-2 border-gray-200 shadow-sm mb-4", alt: "#{@application.name} icon" %>
|
||||||
|
<% else %>
|
||||||
|
<div class="mx-auto h-20 w-20 rounded-xl bg-gray-100 border-2 border-gray-200 flex items-center justify-center mb-4">
|
||||||
|
<svg class="h-10 w-10 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
<h2 class="text-2xl font-bold text-gray-900">Authorize Application</h2>
|
<h2 class="text-2xl font-bold text-gray-900">Authorize Application</h2>
|
||||||
<p class="mt-2 text-sm text-gray-600">
|
<p class="mt-2 text-sm text-gray-600">
|
||||||
<strong><%= @application.name %></strong> is requesting access to your account.
|
<strong><%= @application.name %></strong> is requesting access to your account.
|
||||||
|
|||||||
@@ -31,6 +31,15 @@
|
|||||||
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= form.label :current_password, "Current Password", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.password_field :current_password,
|
||||||
|
autocomplete: "current-password",
|
||||||
|
placeholder: "Required to change email",
|
||||||
|
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">Enter your current password to confirm this change</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<%= form.submit "Update Email", 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" %>
|
<%= form.submit "Update Email", 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" %>
|
||||||
</div>
|
</div>
|
||||||
@@ -98,23 +107,52 @@
|
|||||||
<p class="text-sm font-medium text-green-800">
|
<p class="text-sm font-medium text-green-800">
|
||||||
Two-factor authentication is enabled
|
Two-factor authentication is enabled
|
||||||
</p>
|
</p>
|
||||||
|
<% if @user.totp_required? %>
|
||||||
|
<p class="mt-1 text-sm text-green-700">
|
||||||
|
<svg class="inline h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
Required by administrator
|
||||||
|
</p>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4 flex gap-3">
|
<% if @user.totp_required? %>
|
||||||
<button type="button"
|
<div class="mt-4 rounded-md bg-blue-50 p-4">
|
||||||
data-action="click->modal#show"
|
<div class="flex">
|
||||||
data-modal-id="disable-2fa-modal"
|
<svg class="h-5 w-5 text-blue-400 mr-2 flex-shrink-0" viewBox="0 0 20 20" fill="currentColor">
|
||||||
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">
|
<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" />
|
||||||
Disable 2FA
|
</svg>
|
||||||
</button>
|
<p class="text-sm text-blue-800">
|
||||||
<button type="button"
|
Your administrator requires two-factor authentication. You cannot disable it.
|
||||||
data-action="click->modal#show"
|
</p>
|
||||||
data-modal-id="view-backup-codes-modal"
|
</div>
|
||||||
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">
|
</div>
|
||||||
View Backup Codes
|
<div class="mt-4 flex gap-3">
|
||||||
</button>
|
<button type="button"
|
||||||
</div>
|
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 %>
|
<% else %>
|
||||||
<%= link_to new_totp_path, class: "inline-flex items-center rounded-md border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" do %>
|
<%= link_to new_totp_path, class: "inline-flex items-center rounded-md border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" do %>
|
||||||
Enable 2FA
|
Enable 2FA
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<%# Enhanced Flash Messages with Support for Multiple Types and Auto-Dismiss %>
|
<%# Enhanced Flash Messages with Support for Multiple Types and Auto-Dismiss %>
|
||||||
<% flash.each do |type, message| %>
|
<% flash.each do |type, message| %>
|
||||||
<% next if message.blank? %>
|
<% next if message.blank? %>
|
||||||
|
<%# Skip credential-related flash messages - they're displayed in a special credentials box %>
|
||||||
|
<% next if %w[client_id client_secret public_client].include?(type.to_s) %>
|
||||||
|
|
||||||
<%
|
<%
|
||||||
# Map flash types to styling
|
# Map flash types to styling
|
||||||
|
|||||||
@@ -45,8 +45,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-8">
|
<div class="mt-8">
|
||||||
<%= link_to "Done", profile_path,
|
<% if @auto_signin_pending %>
|
||||||
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" %>
|
<%= 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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,4 @@
|
|||||||
require "rubygems"
|
require "rubygems"
|
||||||
require "bundler/setup"
|
require "bundler/setup"
|
||||||
|
|
||||||
ARGV.unshift("--ensure-latest")
|
|
||||||
|
|
||||||
load Gem.bin_path("brakeman", "brakeman")
|
load Gem.bin_path("brakeman", "brakeman")
|
||||||
|
|||||||
@@ -24,16 +24,16 @@ module Clinch
|
|||||||
# config.time_zone = "Central Time (US & Canada)"
|
# config.time_zone = "Central Time (US & Canada)"
|
||||||
# config.eager_load_paths << Rails.root.join("extras")
|
# config.eager_load_paths << Rails.root.join("extras")
|
||||||
|
|
||||||
# Configure SMTP settings using environment variables
|
# Configure SMTP settings using environment variables
|
||||||
config.action_mailer.delivery_method = :smtp
|
config.action_mailer.delivery_method = :smtp
|
||||||
config.action_mailer.smtp_settings = {
|
config.action_mailer.smtp_settings = {
|
||||||
address: ENV.fetch('SMTP_ADDRESS', 'localhost'),
|
address: ENV.fetch("SMTP_ADDRESS", "localhost"),
|
||||||
port: ENV.fetch('SMTP_PORT', 587),
|
port: ENV.fetch("SMTP_PORT", 587),
|
||||||
domain: ENV.fetch('SMTP_DOMAIN', 'localhost'),
|
domain: ENV.fetch("SMTP_DOMAIN", "localhost"),
|
||||||
user_name: ENV.fetch('SMTP_USERNAME', nil),
|
user_name: ENV.fetch("SMTP_USERNAME", nil),
|
||||||
password: ENV.fetch('SMTP_PASSWORD', nil),
|
password: ENV.fetch("SMTP_PASSWORD", nil),
|
||||||
authentication: ENV.fetch('SMTP_AUTHENTICATION', 'plain').to_sym,
|
authentication: ENV.fetch("SMTP_AUTHENTICATION", "plain").to_sym,
|
||||||
enable_starttls_auto: ENV.fetch('SMTP_STARTTLS_AUTO', 'true') == 'true',
|
enable_starttls_auto: ENV.fetch("SMTP_STARTTLS_AUTO", "true") == "true",
|
||||||
openssl_verify_mode: OpenSSL::SSL::VERIFY_PEER
|
openssl_verify_mode: OpenSSL::SSL::VERIFY_PEER
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ Rails.application.configure do
|
|||||||
if Rails.root.join("tmp/caching-dev.txt").exist?
|
if Rails.root.join("tmp/caching-dev.txt").exist?
|
||||||
config.action_controller.perform_caching = true
|
config.action_controller.perform_caching = true
|
||||||
config.action_controller.enable_fragment_cache_logging = true
|
config.action_controller.enable_fragment_cache_logging = true
|
||||||
config.public_file_server.headers = { "cache-control" => "public, max-age=#{2.days.to_i}" }
|
config.public_file_server.headers = {"cache-control" => "public, max-age=#{2.days.to_i}"}
|
||||||
else
|
else
|
||||||
config.action_controller.perform_caching = false
|
config.action_controller.perform_caching = false
|
||||||
end
|
end
|
||||||
@@ -39,10 +39,10 @@ Rails.application.configure do
|
|||||||
config.action_mailer.perform_caching = false
|
config.action_mailer.perform_caching = false
|
||||||
|
|
||||||
# Set localhost to be used by links generated in mailer templates.
|
# Set localhost to be used by links generated in mailer templates.
|
||||||
config.action_mailer.default_url_options = { host: "localhost", port: 3000 }
|
config.action_mailer.default_url_options = {host: "localhost", port: 3000}
|
||||||
|
|
||||||
# Log with request_id as a tag (same as production).
|
# Log with request_id as a tag (same as production).
|
||||||
config.log_tags = [ :request_id ]
|
config.log_tags = [:request_id]
|
||||||
|
|
||||||
# Print deprecation notices to the Rails logger.
|
# Print deprecation notices to the Rails logger.
|
||||||
config.active_support.deprecation = :log
|
config.active_support.deprecation = :log
|
||||||
@@ -62,7 +62,6 @@ Rails.application.configure do
|
|||||||
# Use async processor for background jobs in development
|
# Use async processor for background jobs in development
|
||||||
config.active_job.queue_adapter = :async
|
config.active_job.queue_adapter = :async
|
||||||
|
|
||||||
|
|
||||||
# Highlight code that triggered redirect in logs.
|
# Highlight code that triggered redirect in logs.
|
||||||
config.action_dispatch.verbose_redirect_logs = true
|
config.action_dispatch.verbose_redirect_logs = true
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ Rails.application.configure do
|
|||||||
config.action_controller.perform_caching = true
|
config.action_controller.perform_caching = true
|
||||||
|
|
||||||
# Cache assets for far-future expiry since they are all digest stamped.
|
# Cache assets for far-future expiry since they are all digest stamped.
|
||||||
config.public_file_server.headers = { "cache-control" => "public, max-age=#{1.year.to_i}" }
|
config.public_file_server.headers = {"cache-control" => "public, max-age=#{1.year.to_i}"}
|
||||||
|
|
||||||
# Enable serving of images, stylesheets, and JavaScripts from an asset server.
|
# Enable serving of images, stylesheets, and JavaScripts from an asset server.
|
||||||
# config.asset_host = "http://assets.example.com"
|
# config.asset_host = "http://assets.example.com"
|
||||||
@@ -30,12 +30,20 @@ Rails.application.configure do
|
|||||||
# Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies.
|
# Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies.
|
||||||
config.force_ssl = true
|
config.force_ssl = true
|
||||||
|
|
||||||
|
# Additional security headers (beyond Rails defaults)
|
||||||
|
# Note: Rails already sets X-Content-Type-Options: nosniff by default
|
||||||
|
# Note: Permissions-Policy is configured in config/initializers/permissions_policy.rb
|
||||||
|
config.action_dispatch.default_headers.merge!(
|
||||||
|
"X-Frame-Options" => "DENY", # Override default SAMEORIGIN to prevent clickjacking
|
||||||
|
"Referrer-Policy" => "strict-origin-when-cross-origin" # Control referrer information
|
||||||
|
)
|
||||||
|
|
||||||
# Skip http-to-https redirect for the default health check endpoint.
|
# Skip http-to-https redirect for the default health check endpoint.
|
||||||
# config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } }
|
# config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } }
|
||||||
|
|
||||||
# Log to STDOUT with the current request id as a default log tag.
|
# Log to STDOUT with the current request id as a default log tag.
|
||||||
config.log_tags = [ :request_id ]
|
config.log_tags = [:request_id]
|
||||||
config.logger = ActiveSupport::TaggedLogging.logger(STDOUT)
|
config.logger = ActiveSupport::TaggedLogging.logger($stdout)
|
||||||
|
|
||||||
# Change to "debug" to log everything (including potentially personally-identifiable information!).
|
# Change to "debug" to log everything (including potentially personally-identifiable information!).
|
||||||
config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info")
|
config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info")
|
||||||
@@ -49,8 +57,8 @@ Rails.application.configure do
|
|||||||
# Replace the default in-process memory cache store with a durable alternative.
|
# Replace the default in-process memory cache store with a durable alternative.
|
||||||
config.cache_store = :solid_cache_store
|
config.cache_store = :solid_cache_store
|
||||||
|
|
||||||
# Use async processor for background jobs (modify as needed for production)
|
# Use Solid Queue for background jobs
|
||||||
config.active_job.queue_adapter = :async
|
config.active_job.queue_adapter = :solid_queue
|
||||||
|
|
||||||
# Ignore bad email addresses and do not raise email delivery errors.
|
# Ignore bad email addresses and do not raise email delivery errors.
|
||||||
# Set this to true and configure the email server for immediate delivery to raise delivery errors.
|
# Set this to true and configure the email server for immediate delivery to raise delivery errors.
|
||||||
@@ -58,7 +66,7 @@ Rails.application.configure do
|
|||||||
|
|
||||||
# Set host to be used by links generated in mailer templates.
|
# Set host to be used by links generated in mailer templates.
|
||||||
config.action_mailer.default_url_options = {
|
config.action_mailer.default_url_options = {
|
||||||
host: ENV.fetch('CLINCH_HOST', 'example.com')
|
host: ENV.fetch("CLINCH_HOST", "example.com")
|
||||||
}
|
}
|
||||||
|
|
||||||
# Specify outgoing SMTP server. Remember to add smtp/* credentials via bin/rails credentials:edit.
|
# Specify outgoing SMTP server. Remember to add smtp/* credentials via bin/rails credentials:edit.
|
||||||
@@ -78,13 +86,13 @@ Rails.application.configure do
|
|||||||
config.active_record.dump_schema_after_migration = false
|
config.active_record.dump_schema_after_migration = false
|
||||||
|
|
||||||
# Only use :id for inspections in production.
|
# Only use :id for inspections in production.
|
||||||
config.active_record.attributes_for_inspect = [ :id ]
|
config.active_record.attributes_for_inspect = [:id]
|
||||||
|
|
||||||
# Helper method to extract domain from CLINCH_HOST (removes protocol if present)
|
# Helper method to extract domain from CLINCH_HOST (removes protocol if present)
|
||||||
def self.extract_domain(host)
|
def self.extract_domain(host)
|
||||||
return host if host.blank?
|
return host if host.blank?
|
||||||
# Remove protocol (http:// or https://) if present
|
# Remove protocol (http:// or https://) if present
|
||||||
host.gsub(/^https?:\/\//, '')
|
host.gsub(/^https?:\/\//, "")
|
||||||
end
|
end
|
||||||
|
|
||||||
# Helper method to ensure URL has https:// protocol
|
# Helper method to ensure URL has https:// protocol
|
||||||
@@ -97,11 +105,11 @@ Rails.application.configure do
|
|||||||
# Enable DNS rebinding protection and other `Host` header attacks.
|
# Enable DNS rebinding protection and other `Host` header attacks.
|
||||||
# Configure allowed hosts based on deployment scenario
|
# Configure allowed hosts based on deployment scenario
|
||||||
allowed_hosts = [
|
allowed_hosts = [
|
||||||
extract_domain(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
|
# Use PublicSuffix to extract registrable domain and allow all subdomains
|
||||||
host_domain = extract_domain(ENV.fetch('CLINCH_HOST', 'auth.example.com'))
|
host_domain = extract_domain(ENV.fetch("CLINCH_HOST", "auth.example.com"))
|
||||||
if host_domain.present?
|
if host_domain.present?
|
||||||
begin
|
begin
|
||||||
# Use PublicSuffix to properly extract the domain
|
# Use PublicSuffix to properly extract the domain
|
||||||
@@ -115,20 +123,20 @@ Rails.application.configure do
|
|||||||
rescue PublicSuffix::DomainInvalid
|
rescue PublicSuffix::DomainInvalid
|
||||||
# Fallback to simple domain extraction if PublicSuffix fails
|
# Fallback to simple domain extraction if PublicSuffix fails
|
||||||
Rails.logger.warn "Could not parse domain '#{host_domain}' with PublicSuffix, using fallback"
|
Rails.logger.warn "Could not parse domain '#{host_domain}' with PublicSuffix, using fallback"
|
||||||
base_domain = host_domain.split('.').last(2).join('.')
|
base_domain = host_domain.split(".").last(2).join(".")
|
||||||
allowed_hosts << /.*#{Regexp.escape(base_domain)}/
|
allowed_hosts << /.*#{Regexp.escape(base_domain)}/
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Allow Docker service names if running in same compose
|
# Allow Docker service names if running in same compose
|
||||||
if ENV['CLINCH_DOCKER_SERVICE_NAME']
|
if ENV["CLINCH_DOCKER_SERVICE_NAME"]
|
||||||
allowed_hosts << ENV['CLINCH_DOCKER_SERVICE_NAME']
|
allowed_hosts << ENV["CLINCH_DOCKER_SERVICE_NAME"]
|
||||||
end
|
end
|
||||||
|
|
||||||
# Allow internal IP access for cross-compose or host networking
|
# Allow internal IP access for cross-compose or host networking
|
||||||
if ENV['CLINCH_ALLOW_INTERNAL_IPS'] == 'true'
|
if ENV["CLINCH_ALLOW_INTERNAL_IPS"] == "true"
|
||||||
# Specific host IP
|
# Specific host IP
|
||||||
allowed_hosts << '192.168.2.246'
|
allowed_hosts << "192.168.2.246"
|
||||||
|
|
||||||
# Private IP ranges for internal network access
|
# Private IP ranges for internal network access
|
||||||
allowed_hosts += [
|
allowed_hosts += [
|
||||||
@@ -139,14 +147,14 @@ Rails.application.configure do
|
|||||||
end
|
end
|
||||||
|
|
||||||
# Local development fallbacks
|
# Local development fallbacks
|
||||||
if ENV['CLINCH_ALLOW_LOCALHOST'] == 'true'
|
if ENV["CLINCH_ALLOW_LOCALHOST"] == "true"
|
||||||
allowed_hosts += ['localhost', '127.0.0.1', '0.0.0.0']
|
allowed_hosts += ["localhost", "127.0.0.1", "0.0.0.0"]
|
||||||
end
|
end
|
||||||
|
|
||||||
config.hosts = allowed_hosts
|
config.hosts = allowed_hosts
|
||||||
|
|
||||||
# Skip DNS rebinding protection for the default health check endpoint.
|
# Skip DNS rebinding protection for the default health check endpoint.
|
||||||
config.host_authorization = { exclude: ->(request) { request.path == "/up" } }
|
config.host_authorization = {exclude: ->(request) { request.path == "/up" }}
|
||||||
|
|
||||||
# Sentry configuration for production
|
# Sentry configuration for production
|
||||||
# Only enabled if SENTRY_DSN environment variable is set
|
# Only enabled if SENTRY_DSN environment variable is set
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ Rails.application.configure do
|
|||||||
config.eager_load = ENV["CI"].present?
|
config.eager_load = ENV["CI"].present?
|
||||||
|
|
||||||
# Configure public file server for tests with cache-control for performance.
|
# Configure public file server for tests with cache-control for performance.
|
||||||
config.public_file_server.headers = { "cache-control" => "public, max-age=3600" }
|
config.public_file_server.headers = {"cache-control" => "public, max-age=3600"}
|
||||||
|
|
||||||
# Show full error reports.
|
# Show full error reports.
|
||||||
config.consider_all_requests_local = true
|
config.consider_all_requests_local = true
|
||||||
@@ -37,7 +37,7 @@ Rails.application.configure do
|
|||||||
config.action_mailer.delivery_method = :test
|
config.action_mailer.delivery_method = :test
|
||||||
|
|
||||||
# Set host to be used by links generated in mailer templates.
|
# Set host to be used by links generated in mailer templates.
|
||||||
config.action_mailer.default_url_options = { host: "example.com" }
|
config.action_mailer.default_url_options = {host: "example.com"}
|
||||||
|
|
||||||
# Print deprecation notices to the stderr.
|
# Print deprecation notices to the stderr.
|
||||||
config.active_support.deprecation = :stderr
|
config.active_support.deprecation = :stderr
|
||||||
|
|||||||
28
config/initializers/active_record_encryption.rb
Normal file
28
config/initializers/active_record_encryption.rb
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# ActiveRecord Encryption Configuration
|
||||||
|
# Encryption keys derived from SECRET_KEY_BASE (no separate key storage needed)
|
||||||
|
# Used for encrypting sensitive columns (currently: TOTP secrets)
|
||||||
|
#
|
||||||
|
# Optional: Override with env vars (for key rotation or explicit key management):
|
||||||
|
# - ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY
|
||||||
|
# - ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY
|
||||||
|
# - ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT
|
||||||
|
|
||||||
|
# Use env vars if set, otherwise derive from SECRET_KEY_BASE (deterministic)
|
||||||
|
primary_key = ENV.fetch("ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY") do
|
||||||
|
Rails.application.key_generator.generate_key("active_record_encryption_primary", 32)
|
||||||
|
end
|
||||||
|
deterministic_key = ENV.fetch("ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY") do
|
||||||
|
Rails.application.key_generator.generate_key("active_record_encryption_deterministic", 32)
|
||||||
|
end
|
||||||
|
key_derivation_salt = ENV.fetch("ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT") do
|
||||||
|
Rails.application.key_generator.generate_key("active_record_encryption_salt", 32)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Configure Rails 7.1+ ActiveRecord encryption
|
||||||
|
Rails.application.config.active_record.encryption.primary_key = primary_key
|
||||||
|
Rails.application.config.active_record.encryption.deterministic_key = deterministic_key
|
||||||
|
Rails.application.config.active_record.encryption.key_derivation_salt = key_derivation_salt
|
||||||
|
|
||||||
|
# Allow unencrypted data for existing records (new/updated records will be encrypted)
|
||||||
|
# Set to false after all existing encrypted columns have been migrated
|
||||||
|
Rails.application.config.active_record.encryption.support_unencrypted_data = true
|
||||||
14
config/initializers/active_storage.rb
Normal file
14
config/initializers/active_storage.rb
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# Configure ActiveStorage content type resolution
|
||||||
|
Rails.application.config.after_initialize do
|
||||||
|
# Ensure SVG files are served with the correct content type
|
||||||
|
ActiveStorage::Blob.class_eval do
|
||||||
|
def content_type_for_serving
|
||||||
|
# Override content type for SVG files
|
||||||
|
if filename.extension == "svg" && content_type == "application/octet-stream"
|
||||||
|
"image/svg+xml"
|
||||||
|
else
|
||||||
|
content_type
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -56,10 +56,9 @@ Rails.application.configure do
|
|||||||
policy.require_trusted_types_for :none
|
policy.require_trusted_types_for :none
|
||||||
|
|
||||||
# CSP reporting using report_uri (supported method)
|
# CSP reporting using report_uri (supported method)
|
||||||
policy.report_uri "/api/csp-violation-report"
|
policy.report_uri "/api/csp-violation-report"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
||||||
# Start with CSP in report-only mode for testing
|
# Start with CSP in report-only mode for testing
|
||||||
# Set to false after verifying everything works in production
|
# Set to false after verifying everything works in production
|
||||||
config.content_security_policy_report_only = Rails.env.development?
|
config.content_security_policy_report_only = Rails.env.development?
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ Rails.application.config.after_initialize do
|
|||||||
# Configure log rotation
|
# Configure log rotation
|
||||||
csp_logger = Logger.new(
|
csp_logger = Logger.new(
|
||||||
csp_log_path,
|
csp_log_path,
|
||||||
'daily', # Rotate daily
|
"daily", # Rotate daily
|
||||||
30 # Keep 30 old log files
|
30 # Keep 30 old log files
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ Rails.application.config.after_initialize do
|
|||||||
|
|
||||||
# Format: [TIMESTAMP] LEVEL MESSAGE
|
# Format: [TIMESTAMP] LEVEL MESSAGE
|
||||||
csp_logger.formatter = proc do |severity, datetime, progname, msg|
|
csp_logger.formatter = proc do |severity, datetime, progname, msg|
|
||||||
"[#{datetime.strftime('%Y-%m-%d %H:%M:%S')}] #{severity} #{msg}\n"
|
"[#{datetime.strftime("%Y-%m-%d %H:%M:%S")}] #{severity} #{msg}\n"
|
||||||
end
|
end
|
||||||
|
|
||||||
module CspViolationLocalLogger
|
module CspViolationLocalLogger
|
||||||
@@ -25,9 +25,9 @@ Rails.application.config.after_initialize do
|
|||||||
|
|
||||||
# Skip logging if there's no meaningful violation data
|
# Skip logging if there's no meaningful violation data
|
||||||
return if csp_data.empty? ||
|
return if csp_data.empty? ||
|
||||||
(csp_data[:violated_directive].nil? &&
|
(csp_data[:violated_directive].nil? &&
|
||||||
csp_data[:blocked_uri].nil? &&
|
csp_data[:blocked_uri].nil? &&
|
||||||
csp_data[:document_uri].nil?)
|
csp_data[:document_uri].nil?)
|
||||||
|
|
||||||
# Build a structured log message
|
# Build a structured log message
|
||||||
violated_directive = csp_data[:violated_directive] || "unknown"
|
violated_directive = csp_data[:violated_directive] || "unknown"
|
||||||
@@ -69,7 +69,6 @@ Rails.application.config.after_initialize do
|
|||||||
|
|
||||||
# Also log to main Rails logger for visibility
|
# Also log to main Rails logger for visibility
|
||||||
Rails.logger.info "CSP violation logged to csp_violations.log: #{violated_directive} - #{blocked_uri}"
|
Rails.logger.info "CSP violation logged to csp_violations.log: #{violated_directive} - #{blocked_uri}"
|
||||||
|
|
||||||
rescue => e
|
rescue => e
|
||||||
# Ensure logger errors don't break the CSP reporting flow
|
# 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 "Failed to log CSP violation to file: #{e.message}"
|
||||||
@@ -81,12 +80,12 @@ Rails.application.config.after_initialize do
|
|||||||
csp_log_path = Rails.root.join("log", "csp_violations.log")
|
csp_log_path = Rails.root.join("log", "csp_violations.log")
|
||||||
logger = Logger.new(
|
logger = Logger.new(
|
||||||
csp_log_path,
|
csp_log_path,
|
||||||
'daily', # Rotate daily
|
"daily", # Rotate daily
|
||||||
30 # Keep 30 old log files
|
30 # Keep 30 old log files
|
||||||
)
|
)
|
||||||
logger.level = Logger::INFO
|
logger.level = Logger::INFO
|
||||||
logger.formatter = proc do |severity, datetime, progname, msg|
|
logger.formatter = proc do |severity, datetime, progname, msg|
|
||||||
"[#{datetime.strftime('%Y-%m-%d %H:%M:%S')}] #{severity} #{msg}\n"
|
"[#{datetime.strftime("%Y-%m-%d %H:%M:%S")}] #{severity} #{msg}\n"
|
||||||
end
|
end
|
||||||
logger
|
logger
|
||||||
end
|
end
|
||||||
@@ -120,7 +119,6 @@ Rails.application.config.after_initialize do
|
|||||||
|
|
||||||
# Test write to ensure permissions are correct
|
# Test write to ensure permissions are correct
|
||||||
csp_logger.info "CSP Logger initialized at #{Time.current}"
|
csp_logger.info "CSP Logger initialized at #{Time.current}"
|
||||||
|
|
||||||
rescue => e
|
rescue => e
|
||||||
Rails.logger.error "Failed to initialize CSP local logger: #{e.message}"
|
Rails.logger.error "Failed to initialize CSP local logger: #{e.message}"
|
||||||
Rails.logger.error "CSP violations will only be sent to Sentry (if configured)"
|
Rails.logger.error "CSP violations will only be sent to Sentry (if configured)"
|
||||||
|
|||||||
19
config/initializers/permissions_policy.rb
Normal file
19
config/initializers/permissions_policy.rb
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Configure the Permissions-Policy header
|
||||||
|
# See https://api.rubyonrails.org/classes/ActionDispatch/PermissionsPolicy.html
|
||||||
|
|
||||||
|
Rails.application.config.permissions_policy do |f|
|
||||||
|
# Disable sensitive browser features for security
|
||||||
|
f.camera :none
|
||||||
|
f.gyroscope :none
|
||||||
|
f.microphone :none
|
||||||
|
f.payment :none
|
||||||
|
f.usb :none
|
||||||
|
f.magnetometer :none
|
||||||
|
|
||||||
|
# You can enable specific features as needed:
|
||||||
|
# f.fullscreen :self
|
||||||
|
# f.geolocation :self
|
||||||
|
|
||||||
|
# You can also allow specific origins:
|
||||||
|
# f.payment :self, "https://secure.example.com"
|
||||||
|
end
|
||||||
@@ -74,7 +74,7 @@ Rails.application.configure do
|
|||||||
app_environment: Rails.env,
|
app_environment: Rails.env,
|
||||||
# Add CSP policy status
|
# Add CSP policy status
|
||||||
csp_enabled: defined?(Rails.application.config.content_security_policy) &&
|
csp_enabled: defined?(Rails.application.config.content_security_policy) &&
|
||||||
Rails.application.config.content_security_policy.present?
|
Rails.application.config.content_security_policy.present?
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -120,13 +120,13 @@ Rails.application.configure do
|
|||||||
if breadcrumb[:data]
|
if breadcrumb[:data]
|
||||||
breadcrumb[:data].reject! { |key, value|
|
breadcrumb[:data].reject! { |key, value|
|
||||||
key.to_s.match?(/password|secret|token|key|authorization/i) ||
|
key.to_s.match?(/password|secret|token|key|authorization/i) ||
|
||||||
value.to_s.match?(/password|secret/i)
|
value.to_s.match?(/password|secret/i)
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
# Mark CSP-related events
|
# Mark CSP-related events
|
||||||
if breadcrumb[:message]&.include?("CSP Violation") ||
|
if breadcrumb[:message]&.include?("CSP Violation") ||
|
||||||
breadcrumb[:category]&.include?("csp")
|
breadcrumb[:category]&.include?("csp")
|
||||||
breadcrumb[:data] ||= {}
|
breadcrumb[:data] ||= {}
|
||||||
breadcrumb[:data][:security_event] = true
|
breadcrumb[:data][:security_event] = true
|
||||||
breadcrumb[:data][:csp_violation] = true
|
breadcrumb[:data][:csp_violation] = true
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ Rails.application.config.after_initialize do
|
|||||||
timestamp: csp_data[:timestamp]
|
timestamp: csp_data[:timestamp]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
user: csp_data[:current_user_id] ? { id: csp_data[:current_user_id] } : nil
|
user: csp_data[:current_user_id] ? {id: csp_data[:current_user_id]} : nil
|
||||||
)
|
)
|
||||||
|
|
||||||
# Log to Rails logger for redundancy
|
# Log to Rails logger for redundancy
|
||||||
@@ -69,10 +69,10 @@ Rails.application.config.after_initialize do
|
|||||||
parsed.host
|
parsed.host
|
||||||
rescue URI::InvalidURIError
|
rescue URI::InvalidURIError
|
||||||
# Handle cases where URI might be malformed or just a path
|
# Handle cases where URI might be malformed or just a path
|
||||||
if uri.start_with?('/')
|
if uri.start_with?("/")
|
||||||
nil # It's a relative path, no domain
|
nil # It's a relative path, no domain
|
||||||
else
|
else
|
||||||
uri.split('/').first # Best effort extraction
|
uri.split("/").first # Best effort extraction
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
7
config/initializers/token_hmac.rb
Normal file
7
config/initializers/token_hmac.rb
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Token HMAC key derivation
|
||||||
|
# This key is used to compute HMAC-based token prefixes for fast lookup
|
||||||
|
# Derived from SECRET_KEY_BASE - no storage needed, deterministic output
|
||||||
|
# Optional: Set OIDC_TOKEN_PREFIX_HMAC env var to override with explicit key
|
||||||
|
module TokenHmac
|
||||||
|
KEY = ENV["OIDC_TOKEN_PREFIX_HMAC"] || Rails.application.key_generator.generate_key("oidc_token_prefix", 32)
|
||||||
|
end
|
||||||
5
config/initializers/version.rb
Normal file
5
config/initializers/version.rb
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Clinch
|
||||||
|
VERSION = "0.8.1"
|
||||||
|
end
|
||||||
@@ -31,7 +31,6 @@ threads threads_count, threads_count
|
|||||||
# Specifies the `port` that Puma will listen on to receive requests; default is 3000.
|
# Specifies the `port` that Puma will listen on to receive requests; default is 3000.
|
||||||
port ENV.fetch("PORT", 3000)
|
port ENV.fetch("PORT", 3000)
|
||||||
|
|
||||||
|
|
||||||
# Allow puma to be restarted by `bin/rails restart` command.
|
# Allow puma to be restarted by `bin/rails restart` command.
|
||||||
plugin :tmp_restart
|
plugin :tmp_restart
|
||||||
|
|
||||||
|
|||||||
17
config/recurring.yml
Normal file
17
config/recurring.yml
Normal 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
|
||||||
@@ -8,7 +8,7 @@ Rails.application.routes.draw do
|
|||||||
|
|
||||||
# Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.
|
# Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.
|
||||||
# Can be used by load balancers and uptime monitors to verify that the app is live.
|
# Can be used by load balancers and uptime monitors to verify that the app is live.
|
||||||
get "up" => "rails/health#show", as: :rails_health_check
|
get "up" => "rails/health#show", :as => :rails_health_check
|
||||||
|
|
||||||
# Authentication routes
|
# Authentication routes
|
||||||
get "/signup", to: "users#new", as: :signup
|
get "/signup", to: "users#new", as: :signup
|
||||||
@@ -49,6 +49,7 @@ Rails.application.routes.draw do
|
|||||||
end
|
end
|
||||||
resource :active_sessions, only: [:show] do
|
resource :active_sessions, only: [:show] do
|
||||||
member do
|
member do
|
||||||
|
delete :logout_from_app
|
||||||
delete :revoke_consent
|
delete :revoke_consent
|
||||||
delete :revoke_all_consents
|
delete :revoke_all_consents
|
||||||
end
|
end
|
||||||
@@ -60,20 +61,21 @@ Rails.application.routes.draw do
|
|||||||
end
|
end
|
||||||
|
|
||||||
# TOTP (2FA) routes
|
# TOTP (2FA) routes
|
||||||
get '/totp/new', to: 'totp#new', as: :new_totp
|
get "/totp/new", to: "totp#new", as: :new_totp
|
||||||
post '/totp', to: 'totp#create', as: :totp
|
post "/totp", to: "totp#create", as: :totp
|
||||||
delete '/totp', to: 'totp#destroy'
|
delete "/totp", to: "totp#destroy"
|
||||||
get '/totp/backup_codes', to: 'totp#backup_codes', as: :backup_codes_totp
|
get "/totp/backup_codes", to: "totp#backup_codes", as: :backup_codes_totp
|
||||||
post '/totp/verify_password', to: 'totp#verify_password', as: :verify_password_totp
|
post "/totp/verify_password", to: "totp#verify_password", as: :verify_password_totp
|
||||||
get '/totp/regenerate_backup_codes', to: 'totp#regenerate_backup_codes', as: :regenerate_backup_codes_totp
|
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/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
|
# WebAuthn (Passkeys) routes
|
||||||
get '/webauthn/new', to: 'webauthn#new', as: :new_webauthn
|
get "/webauthn/new", to: "webauthn#new", as: :new_webauthn
|
||||||
post '/webauthn/challenge', to: 'webauthn#challenge'
|
post "/webauthn/challenge", to: "webauthn#challenge"
|
||||||
post '/webauthn/create', to: 'webauthn#create'
|
post "/webauthn/create", to: "webauthn#create"
|
||||||
delete '/webauthn/:id', to: 'webauthn#destroy', as: :webauthn_credential
|
delete "/webauthn/:id", to: "webauthn#destroy", as: :webauthn_credential
|
||||||
get '/webauthn/check', to: 'webauthn#check'
|
get "/webauthn/check", to: "webauthn#check"
|
||||||
|
|
||||||
# Admin routes
|
# Admin routes
|
||||||
namespace :admin do
|
namespace :admin do
|
||||||
@@ -81,6 +83,8 @@ Rails.application.routes.draw do
|
|||||||
resources :users do
|
resources :users do
|
||||||
member do
|
member do
|
||||||
post :resend_invitation
|
post :resend_invitation
|
||||||
|
post :update_application_claims
|
||||||
|
delete :delete_application_claims
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
resources :applications do
|
resources :applications do
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ test:
|
|||||||
|
|
||||||
local:
|
local:
|
||||||
service: Disk
|
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)
|
# Use bin/rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key)
|
||||||
# amazon:
|
# amazon:
|
||||||
|
|||||||
@@ -7,6 +7,6 @@ class CreateUserGroups < ActiveRecord::Migration[8.1]
|
|||||||
t.timestamps
|
t.timestamps
|
||||||
end
|
end
|
||||||
|
|
||||||
add_index :user_groups, [ :user_id, :group_id ], unique: true
|
add_index :user_groups, [:user_id, :group_id], unique: true
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -7,6 +7,6 @@ class CreateApplicationGroups < ActiveRecord::Migration[8.1]
|
|||||||
t.timestamps
|
t.timestamps
|
||||||
end
|
end
|
||||||
|
|
||||||
add_index :application_groups, [ :application_id, :group_id ], unique: true
|
add_index :application_groups, [:application_id, :group_id], unique: true
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -13,6 +13,6 @@ class CreateOidcAuthorizationCodes < ActiveRecord::Migration[8.1]
|
|||||||
end
|
end
|
||||||
add_index :oidc_authorization_codes, :code, unique: true
|
add_index :oidc_authorization_codes, :code, unique: true
|
||||||
add_index :oidc_authorization_codes, :expires_at
|
add_index :oidc_authorization_codes, :expires_at
|
||||||
add_index :oidc_authorization_codes, [ :application_id, :user_id ]
|
add_index :oidc_authorization_codes, [:application_id, :user_id]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -11,6 +11,6 @@ class CreateOidcAccessTokens < ActiveRecord::Migration[8.1]
|
|||||||
end
|
end
|
||||||
add_index :oidc_access_tokens, :token, unique: true
|
add_index :oidc_access_tokens, :token, unique: true
|
||||||
add_index :oidc_access_tokens, :expires_at
|
add_index :oidc_access_tokens, :expires_at
|
||||||
add_index :oidc_access_tokens, [ :application_id, :user_id ]
|
add_index :oidc_access_tokens, [:application_id, :user_id]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
class AddRoleMappingToApplications < ActiveRecord::Migration[8.1]
|
class AddRoleMappingToApplications < ActiveRecord::Migration[8.1]
|
||||||
def change
|
def change
|
||||||
add_column :applications, :role_mapping_mode, :string, default: 'disabled', null: false
|
add_column :applications, :role_mapping_mode, :string, default: "disabled", null: false
|
||||||
add_column :applications, :role_prefix, :string
|
add_column :applications, :role_prefix, :string
|
||||||
add_column :applications, :managed_permissions, :json, default: {}
|
add_column :applications, :managed_permissions, :json, default: {}
|
||||||
add_column :applications, :role_claim_name, :string, default: 'roles'
|
add_column :applications, :role_claim_name, :string, default: "roles"
|
||||||
|
|
||||||
create_table :application_roles do |t|
|
create_table :application_roles do |t|
|
||||||
t.references :application, null: false, foreign_key: true
|
t.references :application, null: false, foreign_key: true
|
||||||
@@ -21,7 +21,7 @@ class AddRoleMappingToApplications < ActiveRecord::Migration[8.1]
|
|||||||
create_table :user_role_assignments do |t|
|
create_table :user_role_assignments do |t|
|
||||||
t.references :user, null: false, foreign_key: true
|
t.references :user, null: false, foreign_key: true
|
||||||
t.references :application_role, null: false, foreign_key: true
|
t.references :application_role, null: false, foreign_key: true
|
||||||
t.string :source, default: 'oidc' # 'oidc', 'manual', 'group_sync'
|
t.string :source, default: "oidc" # 'oidc', 'manual', 'group_sync'
|
||||||
t.json :metadata, default: {}
|
t.json :metadata, default: {}
|
||||||
|
|
||||||
t.timestamps
|
t.timestamps
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ class MigrateForwardAuthRulesToApplications < ActiveRecord::Migration[8.1]
|
|||||||
app = application_class.create!(
|
app = application_class.create!(
|
||||||
name: rule.domain_pattern.titleize,
|
name: rule.domain_pattern.titleize,
|
||||||
slug: rule.domain_pattern.parameterize.presence || "forward-auth-#{rule.id}",
|
slug: rule.domain_pattern.parameterize.presence || "forward-auth-#{rule.id}",
|
||||||
app_type: 'forward_auth',
|
app_type: "forward_auth",
|
||||||
domain_pattern: rule.domain_pattern,
|
domain_pattern: rule.domain_pattern,
|
||||||
headers_config: rule.headers_config || {},
|
headers_config: rule.headers_config || {},
|
||||||
active: rule.active
|
active: rule.active
|
||||||
@@ -59,7 +59,7 @@ class MigrateForwardAuthRulesToApplications < ActiveRecord::Migration[8.1]
|
|||||||
|
|
||||||
def down
|
def down
|
||||||
# Remove all forward_auth applications created by this migration
|
# Remove all forward_auth applications created by this migration
|
||||||
Application.where(app_type: 'forward_auth').destroy_all
|
Application.where(app_type: "forward_auth").destroy_all
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ class CreateWebauthnCredentials < ActiveRecord::Migration[8.1]
|
|||||||
t.references :user, null: false, foreign_key: true, index: true
|
t.references :user, null: false, foreign_key: true, index: true
|
||||||
|
|
||||||
# WebAuthn specification fields
|
# WebAuthn specification fields
|
||||||
t.string :external_id, null: false, index: { unique: true } # credential ID (base64)
|
t.string :external_id, null: false, index: {unique: true} # credential ID (base64)
|
||||||
t.string :public_key, null: false # public key (base64)
|
t.string :public_key, null: false # public key (base64)
|
||||||
t.integer :sign_count, null: false, default: 0 # signature counter (clone detection)
|
t.integer :sign_count, null: false, default: 0 # signature counter (clone detection)
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,6 @@ class CreateOidcRefreshTokens < ActiveRecord::Migration[8.1]
|
|||||||
add_index :oidc_refresh_tokens, :expires_at
|
add_index :oidc_refresh_tokens, :expires_at
|
||||||
add_index :oidc_refresh_tokens, :revoked_at
|
add_index :oidc_refresh_tokens, :revoked_at
|
||||||
add_index :oidc_refresh_tokens, :token_family_id
|
add_index :oidc_refresh_tokens, :token_family_id
|
||||||
add_index :oidc_refresh_tokens, [ :application_id, :user_id ]
|
add_index :oidc_refresh_tokens, [:application_id, :user_id]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
15
db/migrate/20251122235519_add_sid_to_oidc_user_consent.rb
Normal file
15
db/migrate/20251122235519_add_sid_to_oidc_user_consent.rb
Normal 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
|
||||||
13
db/migrate/20251123052026_create_application_user_claims.rb
Normal file
13
db/migrate/20251123052026_create_application_user_claims.rb
Normal 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
|
||||||
6
db/migrate/20251125012446_add_username_to_users.rb
Normal file
6
db/migrate/20251125012446_add_username_to_users.rb
Normal 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
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
class AddBackchannelLogoutUriToApplications < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
add_column :applications, :backchannel_logout_uri, :string
|
||||||
|
end
|
||||||
|
end
|
||||||
42
db/migrate/20251229220739_add_token_prefix_to_tokens.rb
Normal file
42
db/migrate/20251229220739_add_token_prefix_to_tokens.rb
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
class AddTokenPrefixToTokens < ActiveRecord::Migration[8.1]
|
||||||
|
def up
|
||||||
|
add_column :oidc_access_tokens, :token_prefix, :string, limit: 8
|
||||||
|
add_column :oidc_refresh_tokens, :token_prefix, :string, limit: 8
|
||||||
|
|
||||||
|
# Backfill existing tokens with prefix and digest
|
||||||
|
say_with_time "Backfilling token prefixes and digests..." do
|
||||||
|
[OidcAccessToken, OidcRefreshToken].each do |klass|
|
||||||
|
klass.reset_column_information # Ensure Rails knows about new column
|
||||||
|
|
||||||
|
klass.where(token_prefix: nil).find_each do |token|
|
||||||
|
next unless token.token.present?
|
||||||
|
|
||||||
|
updates = {}
|
||||||
|
|
||||||
|
# Compute HMAC prefix
|
||||||
|
prefix = klass.compute_token_prefix(token.token)
|
||||||
|
updates[:token_prefix] = prefix if prefix.present?
|
||||||
|
|
||||||
|
# Backfill digest if missing
|
||||||
|
if token.token_digest.nil?
|
||||||
|
updates[:token_digest] = BCrypt::Password.create(token.token)
|
||||||
|
end
|
||||||
|
|
||||||
|
token.update_columns(updates) if updates.any?
|
||||||
|
end
|
||||||
|
|
||||||
|
say " #{klass.name}: #{klass.where.not(token_prefix: nil).count} tokens backfilled"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
add_index :oidc_access_tokens, :token_prefix
|
||||||
|
add_index :oidc_refresh_tokens, :token_prefix
|
||||||
|
end
|
||||||
|
|
||||||
|
def down
|
||||||
|
remove_index :oidc_access_tokens, :token_prefix
|
||||||
|
remove_index :oidc_refresh_tokens, :token_prefix
|
||||||
|
remove_column :oidc_access_tokens, :token_prefix
|
||||||
|
remove_column :oidc_refresh_tokens, :token_prefix
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
class RemovePlaintextTokenFromOidcAccessTokens < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
# Remove the unique index first
|
||||||
|
remove_index :oidc_access_tokens, :token, if_exists: true
|
||||||
|
|
||||||
|
# Remove the plaintext token column - no longer needed
|
||||||
|
# Tokens are now stored as BCrypt-hashed token_digest with HMAC token_prefix
|
||||||
|
remove_column :oidc_access_tokens, :token, :string
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
class AddPkceOptionsToApplications < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
# Add require_pkce column for confidential clients
|
||||||
|
# Default true for new apps (secure by default), existing apps will be false
|
||||||
|
add_column :applications, :require_pkce, :boolean, default: true, null: false
|
||||||
|
|
||||||
|
# Set existing applications to not require PKCE (backwards compatibility)
|
||||||
|
reversible do |dir|
|
||||||
|
dir.up do
|
||||||
|
execute "UPDATE applications SET require_pkce = false WHERE id > 0"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
class RenameCodeToCodeHmacAndAddTokenHmac < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
# Authorization codes: rename code to code_hmac
|
||||||
|
rename_column :oidc_authorization_codes, :code, :code_hmac
|
||||||
|
|
||||||
|
# Access tokens: add token_hmac, remove old columns
|
||||||
|
add_column :oidc_access_tokens, :token_hmac, :string
|
||||||
|
add_index :oidc_access_tokens, :token_hmac, unique: true
|
||||||
|
|
||||||
|
remove_column :oidc_access_tokens, :token_prefix
|
||||||
|
remove_column :oidc_access_tokens, :token_digest
|
||||||
|
|
||||||
|
# Refresh tokens: add token_hmac, remove old columns
|
||||||
|
add_column :oidc_refresh_tokens, :token_hmac, :string
|
||||||
|
add_index :oidc_refresh_tokens, :token_hmac, unique: true
|
||||||
|
|
||||||
|
remove_column :oidc_refresh_tokens, :token_prefix
|
||||||
|
remove_column :oidc_refresh_tokens, :token_digest
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
class AddAuthTimeToOidcTokens < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
add_column :oidc_authorization_codes, :auth_time, :integer
|
||||||
|
add_column :oidc_refresh_tokens, :auth_time, :integer
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
class AddAcrToOidcTokensAndSessions < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
add_column :sessions, :acr, :string
|
||||||
|
add_column :oidc_authorization_codes, :acr, :string
|
||||||
|
add_column :oidc_refresh_tokens, :acr, :string
|
||||||
|
end
|
||||||
|
end
|
||||||
70
db/schema.rb
generated
70
db/schema.rb
generated
@@ -10,7 +10,35 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[8.1].define(version: 2025_11_12_120314) do
|
ActiveRecord::Schema[8.1].define(version: 2025_12_31_060112) 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|
|
create_table "application_groups", force: :cascade do |t|
|
||||||
t.integer "application_id", null: false
|
t.integer "application_id", null: false
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
@@ -21,10 +49,22 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_12_120314) do
|
|||||||
t.index ["group_id"], name: "index_application_groups_on_group_id"
|
t.index ["group_id"], name: "index_application_groups_on_group_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "application_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|
|
create_table "applications", force: :cascade do |t|
|
||||||
t.integer "access_token_ttl", default: 3600
|
t.integer "access_token_ttl", default: 3600
|
||||||
t.boolean "active", default: true, null: false
|
t.boolean "active", default: true, null: false
|
||||||
t.string "app_type", null: false
|
t.string "app_type", null: false
|
||||||
|
t.string "backchannel_logout_uri"
|
||||||
t.string "client_id"
|
t.string "client_id"
|
||||||
t.string "client_secret_digest"
|
t.string "client_secret_digest"
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
@@ -37,6 +77,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_12_120314) do
|
|||||||
t.string "name", null: false
|
t.string "name", null: false
|
||||||
t.text "redirect_uris"
|
t.text "redirect_uris"
|
||||||
t.integer "refresh_token_ttl", default: 2592000
|
t.integer "refresh_token_ttl", default: 2592000
|
||||||
|
t.boolean "require_pkce", default: true, null: false
|
||||||
t.string "slug", null: false
|
t.string "slug", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
t.index ["active"], name: "index_applications_on_active"
|
t.index ["active"], name: "index_applications_on_active"
|
||||||
@@ -60,24 +101,24 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_12_120314) do
|
|||||||
t.datetime "expires_at", null: false
|
t.datetime "expires_at", null: false
|
||||||
t.datetime "revoked_at"
|
t.datetime "revoked_at"
|
||||||
t.string "scope"
|
t.string "scope"
|
||||||
t.string "token"
|
t.string "token_hmac"
|
||||||
t.string "token_digest"
|
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
t.integer "user_id", 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", "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 ["application_id"], name: "index_oidc_access_tokens_on_application_id"
|
||||||
t.index ["expires_at"], name: "index_oidc_access_tokens_on_expires_at"
|
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 ["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_hmac"], name: "index_oidc_access_tokens_on_token_hmac", 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"
|
t.index ["user_id"], name: "index_oidc_access_tokens_on_user_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "oidc_authorization_codes", force: :cascade do |t|
|
create_table "oidc_authorization_codes", force: :cascade do |t|
|
||||||
|
t.string "acr"
|
||||||
t.integer "application_id", null: false
|
t.integer "application_id", null: false
|
||||||
t.string "code", null: false
|
t.integer "auth_time"
|
||||||
t.string "code_challenge"
|
t.string "code_challenge"
|
||||||
t.string "code_challenge_method"
|
t.string "code_challenge_method"
|
||||||
|
t.string "code_hmac", null: false
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "expires_at", null: false
|
t.datetime "expires_at", null: false
|
||||||
t.string "nonce"
|
t.string "nonce"
|
||||||
@@ -88,21 +129,23 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_12_120314) do
|
|||||||
t.integer "user_id", null: false
|
t.integer "user_id", null: false
|
||||||
t.index ["application_id", "user_id"], name: "index_oidc_authorization_codes_on_application_id_and_user_id"
|
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 ["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 ["code_challenge"], name: "index_oidc_authorization_codes_on_code_challenge"
|
||||||
|
t.index ["code_hmac"], name: "index_oidc_authorization_codes_on_code_hmac", unique: true
|
||||||
t.index ["expires_at"], name: "index_oidc_authorization_codes_on_expires_at"
|
t.index ["expires_at"], name: "index_oidc_authorization_codes_on_expires_at"
|
||||||
t.index ["user_id"], name: "index_oidc_authorization_codes_on_user_id"
|
t.index ["user_id"], name: "index_oidc_authorization_codes_on_user_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "oidc_refresh_tokens", force: :cascade do |t|
|
create_table "oidc_refresh_tokens", force: :cascade do |t|
|
||||||
|
t.string "acr"
|
||||||
t.integer "application_id", null: false
|
t.integer "application_id", null: false
|
||||||
|
t.integer "auth_time"
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "expires_at", null: false
|
t.datetime "expires_at", null: false
|
||||||
t.integer "oidc_access_token_id", null: false
|
t.integer "oidc_access_token_id", null: false
|
||||||
t.datetime "revoked_at"
|
t.datetime "revoked_at"
|
||||||
t.string "scope"
|
t.string "scope"
|
||||||
t.string "token_digest", null: false
|
|
||||||
t.integer "token_family_id"
|
t.integer "token_family_id"
|
||||||
|
t.string "token_hmac"
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
t.integer "user_id", 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", "user_id"], name: "index_oidc_refresh_tokens_on_application_id_and_user_id"
|
||||||
@@ -110,8 +153,8 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_12_120314) do
|
|||||||
t.index ["expires_at"], name: "index_oidc_refresh_tokens_on_expires_at"
|
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 ["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 ["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 ["token_family_id"], name: "index_oidc_refresh_tokens_on_token_family_id"
|
||||||
|
t.index ["token_hmac"], name: "index_oidc_refresh_tokens_on_token_hmac", unique: true
|
||||||
t.index ["user_id"], name: "index_oidc_refresh_tokens_on_user_id"
|
t.index ["user_id"], name: "index_oidc_refresh_tokens_on_user_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -120,15 +163,18 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_12_120314) do
|
|||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "granted_at", null: false
|
t.datetime "granted_at", null: false
|
||||||
t.text "scopes_granted", null: false
|
t.text "scopes_granted", null: false
|
||||||
|
t.string "sid"
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
t.integer "user_id", null: false
|
t.integer "user_id", null: false
|
||||||
t.index ["application_id"], name: "index_oidc_user_consents_on_application_id"
|
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 ["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", "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"
|
t.index ["user_id"], name: "index_oidc_user_consents_on_user_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "sessions", force: :cascade do |t|
|
create_table "sessions", force: :cascade do |t|
|
||||||
|
t.string "acr"
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.string "device_name"
|
t.string "device_name"
|
||||||
t.datetime "expires_at"
|
t.datetime "expires_at"
|
||||||
@@ -167,10 +213,12 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_12_120314) do
|
|||||||
t.boolean "totp_required", default: false, null: false
|
t.boolean "totp_required", default: false, null: false
|
||||||
t.string "totp_secret"
|
t.string "totp_secret"
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
|
t.string "username"
|
||||||
t.string "webauthn_id"
|
t.string "webauthn_id"
|
||||||
t.boolean "webauthn_required", default: false, null: false
|
t.boolean "webauthn_required", default: false, null: false
|
||||||
t.index ["email_address"], name: "index_users_on_email_address", unique: true
|
t.index ["email_address"], name: "index_users_on_email_address", unique: true
|
||||||
t.index ["status"], name: "index_users_on_status"
|
t.index ["status"], name: "index_users_on_status"
|
||||||
|
t.index ["username"], name: "index_users_on_username", unique: true
|
||||||
t.index ["webauthn_id"], name: "index_users_on_webauthn_id", unique: true
|
t.index ["webauthn_id"], name: "index_users_on_webauthn_id", unique: true
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -196,8 +244,12 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_12_120314) do
|
|||||||
t.index ["user_id"], name: "index_webauthn_credentials_on_user_id"
|
t.index ["user_id"], name: "index_webauthn_credentials_on_user_id"
|
||||||
end
|
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", "applications"
|
||||||
add_foreign_key "application_groups", "groups"
|
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", "applications"
|
||||||
add_foreign_key "oidc_access_tokens", "users"
|
add_foreign_key "oidc_access_tokens", "users"
|
||||||
add_foreign_key "oidc_authorization_codes", "applications"
|
add_foreign_key "oidc_authorization_codes", "applications"
|
||||||
|
|||||||
275
docs/README_RODAUTH_ANALYSIS.md
Normal file
275
docs/README_RODAUTH_ANALYSIS.md
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
# Rodauth-OAuth Analysis Documents
|
||||||
|
|
||||||
|
This directory contains a comprehensive analysis of rodauth-oauth and how it compares to your custom OIDC implementation in Clinch.
|
||||||
|
|
||||||
|
## Start Here
|
||||||
|
|
||||||
|
### 1. **RODAUTH_DECISION_GUIDE.md** (15-minute read)
|
||||||
|
**Purpose:** Help you make a decision about your OAuth/OIDC implementation
|
||||||
|
|
||||||
|
**Contains:**
|
||||||
|
- TL;DR of three options
|
||||||
|
- Decision flowchart
|
||||||
|
- Feature roadmap scenarios
|
||||||
|
- Effort estimates for each path
|
||||||
|
- Security comparison
|
||||||
|
- Real-world questions to ask your team
|
||||||
|
- Next actions for each option
|
||||||
|
|
||||||
|
**Best for:** Deciding whether to keep your implementation, migrate, or use a hybrid approach
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. **rodauth-oauth-quick-reference.md** (20-minute read)
|
||||||
|
**Purpose:** Quick lookup guide and architecture overview
|
||||||
|
|
||||||
|
**Contains:**
|
||||||
|
- What Rodauth-OAuth is (concise)
|
||||||
|
- Key statistics and certifications
|
||||||
|
- Feature advantages & disadvantages
|
||||||
|
- Architecture diagrams (text-based)
|
||||||
|
- Database schema comparison
|
||||||
|
- Feature matrix with implementation effort
|
||||||
|
- Performance considerations
|
||||||
|
- Getting started guide
|
||||||
|
- Code examples (minimal setup)
|
||||||
|
|
||||||
|
**Best for:** Understanding what you're looking at, quick decision support
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. **rodauth-oauth-analysis.md** (45-minute deep-dive)
|
||||||
|
**Purpose:** Comprehensive technical analysis for decision-making
|
||||||
|
|
||||||
|
**Contains:**
|
||||||
|
- Complete architecture breakdown (12 sections)
|
||||||
|
- All 34 features detailed and explained
|
||||||
|
- Full database schema documentation
|
||||||
|
- Request flow diagrams
|
||||||
|
- Feature dependency graphs
|
||||||
|
- Integration paths with Rails
|
||||||
|
- Security analysis
|
||||||
|
- Migration procedures
|
||||||
|
- Code comparisons
|
||||||
|
- Performance metrics
|
||||||
|
|
||||||
|
**Best for:** Deep understanding before making technical decisions, planning migrations
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How to Use These Documents
|
||||||
|
|
||||||
|
### Scenario 1: "I have 15 minutes"
|
||||||
|
1. Read: RODAUTH_DECISION_GUIDE.md (sections: TL;DR + Decision Matrix)
|
||||||
|
2. Go to: Next Actions for your chosen option
|
||||||
|
3. Done: You have a direction
|
||||||
|
|
||||||
|
### Scenario 2: "I have 45 minutes"
|
||||||
|
1. Read: RODAUTH_DECISION_GUIDE.md (complete)
|
||||||
|
2. Skim: rodauth-oauth-quick-reference.md (focus on code examples)
|
||||||
|
3. Decide: Which path interests you most
|
||||||
|
4. Plan: Team discussion using decision matrix
|
||||||
|
|
||||||
|
### Scenario 3: "I'm doing technical deep-dive"
|
||||||
|
1. Read: RODAUTH_DECISION_GUIDE.md (complete)
|
||||||
|
2. Read: rodauth-oauth-quick-reference.md (complete)
|
||||||
|
3. Read: rodauth-oauth-analysis.md (sections 1-6)
|
||||||
|
4. Reference: rodauth-oauth-analysis.md (sections 7-12 as needed)
|
||||||
|
|
||||||
|
### Scenario 4: "I'm planning a migration"
|
||||||
|
1. Read: RODAUTH_DECISION_GUIDE.md (effort estimates section)
|
||||||
|
2. Read: rodauth-oauth-analysis.md (migration path section)
|
||||||
|
3. Reference: rodauth-oauth-analysis.md (database schema section)
|
||||||
|
4. Plan: Detailed migration steps
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Three Options Explained (Very Brief)
|
||||||
|
|
||||||
|
### Option A: Keep Your Implementation
|
||||||
|
- **Time:** Ongoing (add features incrementally)
|
||||||
|
- **Effort:** 4-6 months to reach feature parity
|
||||||
|
- **Maintenance:** 8-10 hours/month
|
||||||
|
- **Best if:** Auth Code + PKCE is sufficient forever
|
||||||
|
|
||||||
|
### Option B: Switch to Rodauth-OAuth
|
||||||
|
- **Time:** 5-9 weeks (one-time migration)
|
||||||
|
- **Learning:** 1-2 weeks (Roda framework)
|
||||||
|
- **Maintenance:** 1-2 hours/month
|
||||||
|
- **Best if:** Need enterprise features, want low maintenance
|
||||||
|
|
||||||
|
### Option C: Hybrid Approach (Microservices)
|
||||||
|
- **Time:** 3-5 weeks (independent setup)
|
||||||
|
- **Learning:** Low (Roda is isolated)
|
||||||
|
- **Maintenance:** 2-3 hours/month
|
||||||
|
- **Best if:** Want Option B benefits without full Rails→Roda migration
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Findings
|
||||||
|
|
||||||
|
**What Rodauth-OAuth Provides That You Don't Have:**
|
||||||
|
- Refresh tokens
|
||||||
|
- Token revocation (RFC 7009)
|
||||||
|
- Token introspection (RFC 7662)
|
||||||
|
- Client Credentials grant (machine-to-machine)
|
||||||
|
- Device Code flow (IoT/smart TV)
|
||||||
|
- JWT Access Tokens (stateless)
|
||||||
|
- Session Management
|
||||||
|
- Front & Back-Channel Logout
|
||||||
|
- Token hashing (bcrypt security)
|
||||||
|
- DPoP support (token binding)
|
||||||
|
- TLS mutual authentication
|
||||||
|
- Dynamic Client Registration
|
||||||
|
- 20+ more optional features
|
||||||
|
|
||||||
|
**Security Differences:**
|
||||||
|
- Your impl: Tokens stored in plaintext (DB breach = token theft)
|
||||||
|
- Rodauth: Tokens hashed with bcrypt (secure even if DB breached)
|
||||||
|
|
||||||
|
**Maintenance Burden:**
|
||||||
|
- Your impl: YOU maintain everything
|
||||||
|
- Rodauth: Community maintains, you maintain config only
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Document Structure
|
||||||
|
|
||||||
|
### RODAUTH_DECISION_GUIDE.md Sections:
|
||||||
|
```
|
||||||
|
1. TL;DR - Three options
|
||||||
|
2. Decision Matrix - Flowchart
|
||||||
|
3. Feature Roadmap Comparison
|
||||||
|
4. Architecture Diagrams (visual)
|
||||||
|
5. Effort Estimates
|
||||||
|
6. Real-World Questions
|
||||||
|
7. Security Comparison
|
||||||
|
8. Cost-Benefit Summary
|
||||||
|
9. Decision Scorecard
|
||||||
|
10. Next Actions
|
||||||
|
```
|
||||||
|
|
||||||
|
### rodauth-oauth-quick-reference.md Sections:
|
||||||
|
```
|
||||||
|
1. What Is It? (overview)
|
||||||
|
2. Key Stats
|
||||||
|
3. Why Consider It? (advantages)
|
||||||
|
4. Architecture Overview (your impl vs rodauth)
|
||||||
|
5. Database Schema Comparison
|
||||||
|
6. Feature Comparison Matrix
|
||||||
|
7. Code Examples
|
||||||
|
8. Integration Paths
|
||||||
|
9. Getting Started
|
||||||
|
10. Next Steps
|
||||||
|
```
|
||||||
|
|
||||||
|
### rodauth-oauth-analysis.md Sections:
|
||||||
|
```
|
||||||
|
1. Executive Summary
|
||||||
|
2. What Rodauth-OAuth Is
|
||||||
|
3. File Structure & Organization
|
||||||
|
4. OIDC/OAuth Features
|
||||||
|
5. Architecture: How It Works
|
||||||
|
6. Database Schema Requirements
|
||||||
|
7. Integration with Rails
|
||||||
|
8. Architectural Comparison
|
||||||
|
9. Feature Matrix
|
||||||
|
10. Integration Complexity
|
||||||
|
11. Key Findings & Recommendations
|
||||||
|
12. Migration Path & Code Examples
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## For Your Team
|
||||||
|
|
||||||
|
### Sharing with Stakeholders
|
||||||
|
- **Non-technical:** Use RODAUTH_DECISION_GUIDE.md (TL;DR section)
|
||||||
|
- **Technical leads:** Use rodauth-oauth-quick-reference.md
|
||||||
|
- **Engineers:** Use rodauth-oauth-analysis.md (sections 1-6)
|
||||||
|
- **Security team:** Use rodauth-oauth-analysis.md (security sections)
|
||||||
|
|
||||||
|
### Team Discussion
|
||||||
|
Print out the decision matrix from RODAUTH_DECISION_GUIDE.md and:
|
||||||
|
1. Walk through each option
|
||||||
|
2. Discuss team comfort with framework learning
|
||||||
|
3. Check against feature roadmap
|
||||||
|
4. Decide on maintenance philosophy
|
||||||
|
5. Vote on preferred option
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next Steps After Reading
|
||||||
|
|
||||||
|
### If Choosing Option A (Keep Custom):
|
||||||
|
- [ ] Plan feature roadmap (refresh tokens first)
|
||||||
|
- [ ] Allocate team capacity
|
||||||
|
- [ ] Add token hashing security
|
||||||
|
- [ ] Set up security monitoring
|
||||||
|
|
||||||
|
### If Choosing Option B (Full Migration):
|
||||||
|
- [ ] Assign team member to learn Roda/Rodauth
|
||||||
|
- [ ] Run examples from `/tmp/rodauth-oauth/examples`
|
||||||
|
- [ ] Plan database migration
|
||||||
|
- [ ] Prepare rollback plan
|
||||||
|
- [ ] Schedule migration window
|
||||||
|
|
||||||
|
### If Choosing Option C (Hybrid):
|
||||||
|
- [ ] Evaluate microservices capability
|
||||||
|
- [ ] Review service communication plan
|
||||||
|
- [ ] Set up service infrastructure
|
||||||
|
- [ ] Plan gradual deployment
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bonus: Running the Example
|
||||||
|
|
||||||
|
Rodauth-OAuth includes a working OIDC server example you can run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/dkam/Development/clinch/tmp/rodauth-oauth/examples/oidc
|
||||||
|
ruby authentication_server.rb
|
||||||
|
|
||||||
|
# Then visit: http://localhost:9292
|
||||||
|
# Login with: foo@bar.com / password
|
||||||
|
# See: Full OIDC provider in action
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Questions?
|
||||||
|
|
||||||
|
These documents should answer:
|
||||||
|
- What is rodauth-oauth?
|
||||||
|
- How does it compare to my implementation?
|
||||||
|
- What features would we gain?
|
||||||
|
- What would we lose?
|
||||||
|
- How much effort is a migration?
|
||||||
|
- Should we switch?
|
||||||
|
|
||||||
|
If questions remain, reference the specific section in the analysis documents.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Document Generation Info
|
||||||
|
|
||||||
|
**Generated:** November 12, 2025
|
||||||
|
**Analysis Duration:** Complete codebase exploration of rodauth-oauth gem
|
||||||
|
**Sources Analyzed:**
|
||||||
|
- 34 feature files (10,000+ lines of code)
|
||||||
|
- 7 database migrations
|
||||||
|
- 6 complete example applications
|
||||||
|
- Comprehensive test suite
|
||||||
|
- README and migration guides
|
||||||
|
|
||||||
|
**Analysis Includes:**
|
||||||
|
- Line-by-line code structure review
|
||||||
|
- Database schema comparison
|
||||||
|
- Feature cross-reference analysis
|
||||||
|
- Integration complexity assessment
|
||||||
|
- Security analysis
|
||||||
|
- Effort estimation models
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Start with RODAUTH_DECISION_GUIDE.md and go from there!**
|
||||||
426
docs/RODAUTH_DECISION_GUIDE.md
Normal file
426
docs/RODAUTH_DECISION_GUIDE.md
Normal file
@@ -0,0 +1,426 @@
|
|||||||
|
# Rodauth-OAuth Decision Guide
|
||||||
|
|
||||||
|
## TL;DR - Make Your Choice Here
|
||||||
|
|
||||||
|
### Option A: Keep Your Rails Implementation
|
||||||
|
**Best if:** Authorization Code + PKCE is all you need, forever
|
||||||
|
- Keep your current 450 lines of OIDC controller code
|
||||||
|
- Maintain incrementally as needs change
|
||||||
|
- Stay 100% in Rails ecosystem
|
||||||
|
- Time investment: Ongoing (2-3 months to feature parity)
|
||||||
|
- Learning curve: None (already know Rails)
|
||||||
|
|
||||||
|
### Option B: Switch to Rodauth-OAuth
|
||||||
|
**Best if:** You need enterprise features, standards compliance, low maintenance
|
||||||
|
- Replace 450 lines with plugin config
|
||||||
|
- Get 34 optional features on demand
|
||||||
|
- OpenID Certified, production-hardened
|
||||||
|
- Time investment: 4-8 weeks (one-time)
|
||||||
|
- Learning curve: Medium (learn Roda/Rodauth)
|
||||||
|
|
||||||
|
### Option C: Hybrid (Recommended if Option B appeals you)
|
||||||
|
**Best if:** You want rodauth-oauth benefits without framework change
|
||||||
|
- Run Rodauth-OAuth as separate microservice
|
||||||
|
- Keep your Rails app unchanged
|
||||||
|
- Services talk via HTTP APIs
|
||||||
|
- Time investment: 2-3 weeks (independent services)
|
||||||
|
- Learning curve: Low (Roda is isolated)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decision Matrix
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Do you need features beyond Authorization Code + PKCE? │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ YES ─→ Go to Question 2 │
|
||||||
|
│ NO ─→ KEEP YOUR IMPLEMENTATION │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
↓
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Can your team learn Roda (different from Rails)? │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ YES ─→ SWITCH TO RODAUTH-OAUTH │
|
||||||
|
│ NO ─→ Go to Question 3 │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
↓
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Can you run separate services (microservices)? │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ YES ─→ USE HYBRID APPROACH │
|
||||||
|
│ NO ─→ KEEP YOUR IMPLEMENTATION │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature Roadmap Comparison
|
||||||
|
|
||||||
|
### Scenario 1: You Need Refresh Tokens (Common)
|
||||||
|
|
||||||
|
**Option A (Keep Custom):**
|
||||||
|
- Implement refresh token endpoints
|
||||||
|
- Add refresh_token columns to DB
|
||||||
|
- Token rotation logic
|
||||||
|
- Estimate: 1-2 weeks of work
|
||||||
|
- Ongoing: Maintain refresh token security
|
||||||
|
|
||||||
|
**Option B (Rodauth-OAuth):**
|
||||||
|
- Already built and tested
|
||||||
|
- Just enable: `:oauth_authorization_code_grant` (includes refresh)
|
||||||
|
- Token rotation: Configurable options
|
||||||
|
- Estimate: Already included
|
||||||
|
- Ongoing: Community maintains
|
||||||
|
|
||||||
|
**Option C (Hybrid):**
|
||||||
|
- Rodauth-OAuth handles it
|
||||||
|
- Your app unchanged
|
||||||
|
- Same as Option B for this feature
|
||||||
|
|
||||||
|
### Scenario 2: You Need Token Revocation
|
||||||
|
|
||||||
|
**Option A (Keep Custom):**
|
||||||
|
- Build `/oauth/revoke` endpoint
|
||||||
|
- Implement token blacklist or DB update
|
||||||
|
- Handle race conditions
|
||||||
|
- Estimate: 1-2 weeks
|
||||||
|
- Ongoing: Monitor revocation leaks
|
||||||
|
|
||||||
|
**Option B (Rodauth-OAuth):**
|
||||||
|
- Enable `:oauth_token_revocation` feature
|
||||||
|
- RFC 7009 compliant out of the box
|
||||||
|
- Estimate: Already included
|
||||||
|
- Ongoing: Community handles RFC updates
|
||||||
|
|
||||||
|
**Option C (Hybrid):**
|
||||||
|
- Same as Option B
|
||||||
|
|
||||||
|
### Scenario 3: You Need Client Credentials Grant
|
||||||
|
|
||||||
|
**Option A (Keep Custom):**
|
||||||
|
- New endpoint logic
|
||||||
|
- Client authentication (different from user auth)
|
||||||
|
- Token generation for apps without users
|
||||||
|
- Estimate: 2-3 weeks
|
||||||
|
- Ongoing: Test with external clients
|
||||||
|
|
||||||
|
**Option B (Rodauth-OAuth):**
|
||||||
|
- Enable `:oauth_client_credentials_grant` feature
|
||||||
|
- All edge cases handled
|
||||||
|
- Estimate: Already included
|
||||||
|
- Ongoing: Community maintains
|
||||||
|
|
||||||
|
**Option C (Hybrid):**
|
||||||
|
- Same as Option B
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Diagrams
|
||||||
|
|
||||||
|
### Current Setup (Your Implementation)
|
||||||
|
```
|
||||||
|
┌─────────────────────────────┐
|
||||||
|
│ Your Rails Application │
|
||||||
|
├─────────────────────────────┤
|
||||||
|
│ app/controllers/ │
|
||||||
|
│ oidc_controller.rb │ ← 450 lines of OAuth logic
|
||||||
|
│ │
|
||||||
|
│ app/models/ │
|
||||||
|
│ OidcAuthorizationCode │
|
||||||
|
│ OidcAccessToken │
|
||||||
|
│ OidcUserConsent │
|
||||||
|
│ │
|
||||||
|
│ app/services/ │
|
||||||
|
│ OidcJwtService │
|
||||||
|
├─────────────────────────────┤
|
||||||
|
│ Rails ActiveRecord │
|
||||||
|
├─────────────────────────────┤
|
||||||
|
│ PostgreSQL Database │
|
||||||
|
│ - oidc_authorization_codes
|
||||||
|
│ - oidc_access_tokens
|
||||||
|
│ - oidc_user_consents
|
||||||
|
│ - applications
|
||||||
|
└─────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option B: Full Migration
|
||||||
|
```
|
||||||
|
┌──────────────────────────────┐
|
||||||
|
│ Roda + Rodauth-OAuth App │
|
||||||
|
├──────────────────────────────┤
|
||||||
|
│ lib/rodauth_app.rb │ ← Config (not code!)
|
||||||
|
│ enable :oidc, │
|
||||||
|
│ enable :oauth_pkce, │
|
||||||
|
│ enable :oauth_token_... │
|
||||||
|
│ │
|
||||||
|
│ [Routes auto-mounted] │
|
||||||
|
│ /.well-known/config │
|
||||||
|
│ /oauth/authorize │
|
||||||
|
│ /oauth/token │
|
||||||
|
│ /oauth/userinfo │
|
||||||
|
│ /oauth/revoke │
|
||||||
|
│ /oauth/introspect │
|
||||||
|
├──────────────────────────────┤
|
||||||
|
│ Sequel ORM │
|
||||||
|
├──────────────────────────────┤
|
||||||
|
│ PostgreSQL Database │
|
||||||
|
│ - accounts (rodauth)
|
||||||
|
│ - oauth_applications
|
||||||
|
│ - oauth_grants (unified!)
|
||||||
|
│ - optional feature tables
|
||||||
|
└──────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option C: Microservices Architecture (Hybrid)
|
||||||
|
```
|
||||||
|
┌──────────────────────────┐ ┌──────────────────────────┐
|
||||||
|
│ Your Rails App │ │ Rodauth-OAuth Service │
|
||||||
|
├──────────────────────────┤ ├──────────────────────────┤
|
||||||
|
│ Normal Rails Controllers │ │ lib/rodauth_app.rb │
|
||||||
|
│ & Business Logic │ │ [OAuth Features] │
|
||||||
|
│ │ │ │
|
||||||
|
│ HTTP Calls to →──────────┼─────→ /.well-known/config │
|
||||||
|
│ OAuth Service OAuth │ │ /oauth/authorize │
|
||||||
|
│ HTTP API │ │ /oauth/token │
|
||||||
|
│ │ │ /oauth/userinfo │
|
||||||
|
│ Verify Tokens via →──────┼─────→ /oauth/introspect │
|
||||||
|
│ /oauth/introspect │ │ │
|
||||||
|
├──────────────────────────┤ ├──────────────────────────┤
|
||||||
|
│ Rails ActiveRecord │ │ Sequel ORM │
|
||||||
|
├──────────────────────────┤ ├──────────────────────────┤
|
||||||
|
│ PostgreSQL │ │ PostgreSQL │
|
||||||
|
│ [business tables] │ │ [oauth tables] │
|
||||||
|
└──────────────────────────┘ └──────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Effort Estimates
|
||||||
|
|
||||||
|
### Option A: Keep & Enhance Custom Implementation
|
||||||
|
```
|
||||||
|
Refresh Tokens: 1-2 weeks
|
||||||
|
Token Revocation: 1-2 weeks
|
||||||
|
Token Introspection: 1-2 weeks
|
||||||
|
Client Credentials: 2-3 weeks
|
||||||
|
Device Code: 3-4 weeks
|
||||||
|
JWT Access Tokens: 1-2 weeks
|
||||||
|
Session Management: 2-3 weeks
|
||||||
|
Front-Channel Logout: 1-2 weeks
|
||||||
|
Back-Channel Logout: 2-3 weeks
|
||||||
|
─────────────────────────────────
|
||||||
|
TOTAL FOR PARITY: 15-25 weeks
|
||||||
|
(4-6 months of work)
|
||||||
|
|
||||||
|
ONGOING MAINTENANCE: ~8-10 hours/month
|
||||||
|
(security updates, RFC changes, bug fixes)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option B: Migrate to Rodauth-OAuth
|
||||||
|
```
|
||||||
|
Learn Roda/Rodauth: 1-2 weeks
|
||||||
|
Migrate Database Schema: 1-2 weeks
|
||||||
|
Replace OIDC Code: 1-2 weeks
|
||||||
|
Test & Validation: 2-3 weeks
|
||||||
|
─────────────────────────────────
|
||||||
|
ONE-TIME EFFORT: 5-9 weeks
|
||||||
|
(1-2 months)
|
||||||
|
|
||||||
|
ONGOING MAINTENANCE: ~1-2 hours/month
|
||||||
|
(dependency updates, config tweaks)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option C: Hybrid Approach
|
||||||
|
```
|
||||||
|
Set up Rodauth service: 1-2 weeks
|
||||||
|
Configure integration: 1-2 weeks
|
||||||
|
Test both services: 1 week
|
||||||
|
─────────────────────────────────
|
||||||
|
ONE-TIME EFFORT: 3-5 weeks
|
||||||
|
(less than Option B)
|
||||||
|
|
||||||
|
ONGOING MAINTENANCE: ~2-3 hours/month
|
||||||
|
(maintain two services, but Roda handles OAuth)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Real-World Questions to Ask Your Team
|
||||||
|
|
||||||
|
### Question 1: Feature Needs
|
||||||
|
- "Do we need refresh tokens?"
|
||||||
|
- "Will clients ask for token revocation?"
|
||||||
|
- "Do we support service-to-service auth (client credentials)?"
|
||||||
|
- "Will we ever need device code flow (IoT)?"
|
||||||
|
|
||||||
|
If YES to any: **Option B or C makes sense**
|
||||||
|
|
||||||
|
### Question 2: Maintenance Philosophy
|
||||||
|
- "Do we want to own the OAuth code?"
|
||||||
|
- "Can we afford to maintain OAuth compliance?"
|
||||||
|
- "Do we have experts in OAuth/OIDC?"
|
||||||
|
|
||||||
|
If NO to all: **Option B or C is better**
|
||||||
|
|
||||||
|
### Question 3: Framework Flexibility
|
||||||
|
- "Is Rails non-negotiable for this company?"
|
||||||
|
- "Can our team learn a new framework?"
|
||||||
|
- "Can we run microservices?"
|
||||||
|
|
||||||
|
If Rails is required: **Option C (hybrid)**
|
||||||
|
|
||||||
|
### Question 4: Time Constraints
|
||||||
|
- "Do we have 4-8 weeks for a migration?"
|
||||||
|
- "Can we maintain OAuth for years?"
|
||||||
|
- "What if specs change?"
|
||||||
|
|
||||||
|
If time-constrained: **Option B is fastest path to full features**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Comparison
|
||||||
|
|
||||||
|
### Your Implementation
|
||||||
|
- ✓ PKCE support
|
||||||
|
- ✓ JWT signing
|
||||||
|
- ✓ HTTPS recommended
|
||||||
|
- ✗ Token hashing (stores tokens in plaintext)
|
||||||
|
- ✗ Token rotation
|
||||||
|
- ✗ DPoP (token binding)
|
||||||
|
- ✗ Automatic spec compliance
|
||||||
|
- Risk: Token theft if DB compromised
|
||||||
|
|
||||||
|
### Rodauth-OAuth
|
||||||
|
- ✓ PKCE support
|
||||||
|
- ✓ JWT signing
|
||||||
|
- ✓ Token hashing (bcrypt by default)
|
||||||
|
- ✓ Token rotation policies
|
||||||
|
- ✓ DPoP support (RFC 9449)
|
||||||
|
- ✓ TLS mutual authentication
|
||||||
|
- ✓ Automatic spec updates
|
||||||
|
- ✓ Certified compliance
|
||||||
|
- Risk: Minimal (industry-standard)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cost-Benefit Summary
|
||||||
|
|
||||||
|
### Keep Your Implementation
|
||||||
|
```
|
||||||
|
Costs:
|
||||||
|
- 15-25 weeks to feature parity
|
||||||
|
- Ongoing security monitoring
|
||||||
|
- Spec compliance tracking
|
||||||
|
- Bug fixes & edge cases
|
||||||
|
|
||||||
|
Benefits:
|
||||||
|
- No framework learning
|
||||||
|
- Full code understanding
|
||||||
|
- Rails-native patterns
|
||||||
|
- Minimal dependencies
|
||||||
|
```
|
||||||
|
|
||||||
|
### Switch to Rodauth-OAuth
|
||||||
|
```
|
||||||
|
Costs:
|
||||||
|
- 5-9 weeks migration effort
|
||||||
|
- Learn Roda/Rodauth
|
||||||
|
- Database schema changes
|
||||||
|
- Test all flows
|
||||||
|
|
||||||
|
Benefits:
|
||||||
|
- Get 34 features immediately
|
||||||
|
- Certified compliance
|
||||||
|
- Community-maintained
|
||||||
|
- Security best practices
|
||||||
|
- Ongoing support
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hybrid Approach
|
||||||
|
```
|
||||||
|
Costs:
|
||||||
|
- 3-5 weeks setup
|
||||||
|
- Learn Roda basics
|
||||||
|
- Operate two services
|
||||||
|
- Service communication
|
||||||
|
|
||||||
|
Benefits:
|
||||||
|
- All Rodauth-OAuth features
|
||||||
|
- Rails app unchanged
|
||||||
|
- Independent scaling
|
||||||
|
- Clear separation of concerns
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decision Scorecard
|
||||||
|
|
||||||
|
| Factor | Option A | Option B | Option C |
|
||||||
|
|--------|----------|----------|----------|
|
||||||
|
| Initial Time | Low | Medium | Medium-Low |
|
||||||
|
| Ongoing Effort | High | Low | Medium |
|
||||||
|
| Feature Completeness | Low | High | High |
|
||||||
|
| Framework Learning | None | Medium | Low |
|
||||||
|
| Standards Compliance | Manual | Auto | Auto |
|
||||||
|
| Deployment Complexity | Simple | Simple | Complex |
|
||||||
|
| Team Preference | ??? | ??? | ??? |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next Actions
|
||||||
|
|
||||||
|
### For Option A (Keep Custom):
|
||||||
|
1. Plan feature roadmap (refresh tokens first)
|
||||||
|
2. Allocate team capacity for implementation
|
||||||
|
3. Document OAuth decisions
|
||||||
|
4. Set up security monitoring
|
||||||
|
|
||||||
|
### For Option B (Full Migration):
|
||||||
|
1. Assign someone to learn Roda/Rodauth
|
||||||
|
2. Run rodauth-oauth examples
|
||||||
|
3. Plan database migration
|
||||||
|
4. Schedule migration window
|
||||||
|
5. Prepare rollback plan
|
||||||
|
|
||||||
|
### For Option C (Hybrid):
|
||||||
|
1. Evaluate microservices capability
|
||||||
|
2. Run Rodauth-OAuth example
|
||||||
|
3. Plan service boundaries
|
||||||
|
4. Set up service communication
|
||||||
|
5. Plan infrastructure for two services
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Still Can't Decide?
|
||||||
|
|
||||||
|
Ask these questions:
|
||||||
|
1. **Will you add features beyond Auth Code + PKCE in next 12 months?**
|
||||||
|
- YES → Option B or C
|
||||||
|
- NO → Option A
|
||||||
|
|
||||||
|
2. **Do you have maintenance bandwidth?**
|
||||||
|
- YES → Option A
|
||||||
|
- NO → Option B or C
|
||||||
|
|
||||||
|
3. **Can you run multiple services?**
|
||||||
|
- YES → Option C (best of both)
|
||||||
|
- NO → Option B (if framework is OK) or Option A (stay Rails)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Document Files
|
||||||
|
|
||||||
|
You now have three documents:
|
||||||
|
1. **rodauth-oauth-analysis.md** - Deep technical analysis (12 sections)
|
||||||
|
2. **rodauth-oauth-quick-reference.md** - Quick lookup guide
|
||||||
|
3. **RODAUTH_DECISION_GUIDE.md** - This decision framework
|
||||||
|
|
||||||
|
Read in this order:
|
||||||
|
1. This guide (make a decision)
|
||||||
|
2. Quick reference (understand architecture)
|
||||||
|
3. Analysis (deep dive on your choice)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Made Your Decision?** Create an issue/commit to document your choice and next steps!
|
||||||
316
docs/backchannel-logout.md
Normal file
316
docs/backchannel-logout.md
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
# OpenID Connect Backchannel Logout
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Backchannel logout is an OpenID Connect feature that enables Clinch to notify applications when a user logs out, ensuring sessions are terminated across all connected applications immediately.
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
When a user logs out from Clinch (or any connected application), Clinch sends server-to-server HTTP POST requests to all applications that have configured a backchannel logout endpoint. This happens automatically in the background.
|
||||||
|
|
||||||
|
### Logout Triggers
|
||||||
|
|
||||||
|
Backchannel logout notifications are sent when:
|
||||||
|
|
||||||
|
1. **User clicks "Sign Out" in Clinch** - All connected OIDC applications are notified, then the Clinch session is terminated
|
||||||
|
2. **User logs out via OIDC `/logout` endpoint** (RP-Initiated Logout) - All connected applications are notified, then the Clinch session is terminated
|
||||||
|
3. **User clicks "Logout" on an app (Dashboard)** - Backchannel logout is sent to that app, all access/refresh tokens are revoked, but OAuth consent is preserved (user can sign back in without re-authorizing)
|
||||||
|
4. **User clicks "Revoke Access" for a specific app (Active Sessions page)** - Backchannel logout is sent to that app to terminate its session, all access/refresh tokens are revoked, then the OAuth consent is permanently destroyed (user must re-authorize the app to use it again)
|
||||||
|
5. **User clicks "Revoke All App Access"** - All connected applications receive backchannel logout notifications, all tokens are revoked, then all OAuth consents are permanently destroyed
|
||||||
|
|
||||||
|
### The Logout Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
User logs out → Clinch finds all connected apps
|
||||||
|
↓
|
||||||
|
For each app with backchannel_logout_uri:
|
||||||
|
↓
|
||||||
|
Generate signed JWT logout token
|
||||||
|
↓
|
||||||
|
HTTP POST to app's logout endpoint
|
||||||
|
↓
|
||||||
|
App validates JWT and terminates session
|
||||||
|
↓
|
||||||
|
Clinch revokes access and refresh tokens
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logout vs Revoke Access
|
||||||
|
|
||||||
|
Clinch provides two distinct actions for managing application access:
|
||||||
|
|
||||||
|
| Action | Location | What Happens | When to Use |
|
||||||
|
|--------|----------|--------------|-------------|
|
||||||
|
| **Logout** | Dashboard | • Sends backchannel logout to app<br>• Revokes all access tokens<br>• Revokes all refresh tokens<br>• **Keeps OAuth consent intact** | You want to end your session with an app but still trust it. Next login will skip the authorization screen. |
|
||||||
|
| **Revoke Access** | Active Sessions page | • Sends backchannel logout to app<br>• Revokes all access tokens<br>• Revokes all refresh tokens<br>• **Destroys OAuth consent** | You want to completely de-authorize an app. Next login will require you to re-authorize the app. |
|
||||||
|
|
||||||
|
**Key Difference**: "Logout" preserves the authorization relationship while terminating the active session. "Revoke Access" completely removes the app's authorization to access your account.
|
||||||
|
|
||||||
|
**Example Use Cases**:
|
||||||
|
- **Logout**: "I left my Jellyfin session open at a friend's house. I want to kill that session but I still use Jellyfin."
|
||||||
|
- **Revoke Access**: "I no longer trust this app and want to remove its authorization completely."
|
||||||
|
|
||||||
|
**Technical Details**:
|
||||||
|
- Both actions revoke access tokens (opaque, database-backed, validated on each use)
|
||||||
|
- Both actions revoke refresh tokens (prevents obtaining new access tokens)
|
||||||
|
- ID tokens remain valid until expiry (stateless JWTs), but apps should honor backchannel logout
|
||||||
|
- Backchannel logout ensures the app clears its local session immediately
|
||||||
|
|
||||||
|
## Configuring Applications
|
||||||
|
|
||||||
|
### In Clinch Admin UI
|
||||||
|
|
||||||
|
1. Navigate to **Admin → Applications**
|
||||||
|
2. Edit or create an OIDC application
|
||||||
|
3. In the "Backchannel Logout URI" field, enter the application's logout endpoint
|
||||||
|
- Example: `https://kavita.local/oidc/backchannel-logout`
|
||||||
|
- Must be HTTPS in production
|
||||||
|
- Leave blank if the application doesn't support backchannel logout
|
||||||
|
|
||||||
|
### Checking Support
|
||||||
|
|
||||||
|
The OIDC discovery endpoint advertises backchannel logout support:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl https://clinch.local/.well-known/openid-configuration | jq
|
||||||
|
```
|
||||||
|
|
||||||
|
Look for:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"backchannel_logout_supported": true,
|
||||||
|
"backchannel_logout_session_supported": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementing a Backchannel Logout Endpoint (for RPs)
|
||||||
|
|
||||||
|
If you're developing an application that integrates with Clinch, here's how to implement backchannel logout support:
|
||||||
|
|
||||||
|
### 1. Create the Endpoint
|
||||||
|
|
||||||
|
The endpoint must:
|
||||||
|
- Accept HTTP POST requests
|
||||||
|
- Parse the `logout_token` parameter from the form body
|
||||||
|
- Validate the JWT signature
|
||||||
|
- Terminate the user's session
|
||||||
|
- Return 200 OK quickly (within 5 seconds)
|
||||||
|
|
||||||
|
### 2. Example Implementation (Ruby/Rails)
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
# config/routes.rb
|
||||||
|
post '/oidc/backchannel-logout', to: 'oidc_backchannel_logout#logout'
|
||||||
|
|
||||||
|
# app/controllers/oidc_backchannel_logout_controller.rb
|
||||||
|
class OidcBackchannelLogoutController < ApplicationController
|
||||||
|
skip_before_action :verify_authenticity_token # Server-to-server call
|
||||||
|
skip_before_action :authenticate_user! # No user session yet
|
||||||
|
|
||||||
|
def logout
|
||||||
|
logout_token = params[:logout_token]
|
||||||
|
|
||||||
|
unless logout_token.present?
|
||||||
|
head :bad_request
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
begin
|
||||||
|
# Decode and verify the JWT
|
||||||
|
# Get Clinch's public key from JWKS endpoint
|
||||||
|
jwks = fetch_clinch_jwks
|
||||||
|
decoded = JWT.decode(
|
||||||
|
logout_token,
|
||||||
|
nil, # Will be verified using JWKS
|
||||||
|
true,
|
||||||
|
{
|
||||||
|
algorithms: ['RS256'],
|
||||||
|
jwks: jwks,
|
||||||
|
verify_aud: true,
|
||||||
|
aud: YOUR_CLIENT_ID,
|
||||||
|
verify_iss: true,
|
||||||
|
iss: 'https://clinch.local' # Your Clinch URL
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
claims = decoded.first
|
||||||
|
|
||||||
|
# Validate required claims
|
||||||
|
unless claims['events']&.key?('http://schemas.openid.net/event/backchannel-logout')
|
||||||
|
head :bad_request
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Get session ID from the token
|
||||||
|
sid = claims['sid']
|
||||||
|
sub = claims['sub']
|
||||||
|
|
||||||
|
# Terminate sessions
|
||||||
|
if sid.present?
|
||||||
|
# Terminate specific session by SID (recommended)
|
||||||
|
Session.where(oidc_sid: sid).destroy_all
|
||||||
|
elsif sub.present?
|
||||||
|
# Terminate all sessions for this user
|
||||||
|
user = User.find_by(oidc_sub: sub)
|
||||||
|
user&.sessions&.destroy_all
|
||||||
|
end
|
||||||
|
|
||||||
|
Rails.logger.info "Backchannel logout: Terminated session for sid=#{sid}, sub=#{sub}"
|
||||||
|
head :ok
|
||||||
|
|
||||||
|
rescue JWT::DecodeError => e
|
||||||
|
Rails.logger.error "Backchannel logout: Invalid JWT - #{e.message}"
|
||||||
|
head :bad_request
|
||||||
|
rescue => e
|
||||||
|
Rails.logger.error "Backchannel logout: Error - #{e.class}: #{e.message}"
|
||||||
|
head :internal_server_error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def fetch_clinch_jwks
|
||||||
|
# Cache this in production!
|
||||||
|
response = HTTParty.get('https://clinch.local/.well-known/jwks.json')
|
||||||
|
JSON.parse(response.body, symbolize_names: true)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Required JWT Claims Validation
|
||||||
|
|
||||||
|
The logout token will contain:
|
||||||
|
|
||||||
|
| Claim | Description | Required |
|
||||||
|
|-------|-------------|----------|
|
||||||
|
| `iss` | Issuer (Clinch URL) | Yes |
|
||||||
|
| `aud` | Your application's client_id | Yes |
|
||||||
|
| `iat` | Issued at timestamp | Yes |
|
||||||
|
| `jti` | Unique token ID | Yes |
|
||||||
|
| `sub` | Pairwise subject identifier (user's SID) | Yes |
|
||||||
|
| `sid` | Session ID (same as sub) | Yes |
|
||||||
|
| `events` | Must contain `http://schemas.openid.net/event/backchannel-logout` | Yes |
|
||||||
|
| `nonce` | Must NOT be present (spec requirement) | No |
|
||||||
|
|
||||||
|
### 4. Session Tracking Requirements
|
||||||
|
|
||||||
|
To support backchannel logout, your application must:
|
||||||
|
|
||||||
|
1. **Store the `sid` claim from ID tokens**:
|
||||||
|
```ruby
|
||||||
|
# When user logs in via OIDC
|
||||||
|
id_token = decode_id_token(params[:id_token])
|
||||||
|
session[:oidc_sid] = id_token['sid'] # Store this!
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Associate sessions with SID**:
|
||||||
|
```ruby
|
||||||
|
# Create session with SID tracking
|
||||||
|
Session.create!(
|
||||||
|
user: current_user,
|
||||||
|
oidc_sid: id_token['sid'],
|
||||||
|
...
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Terminate sessions by SID**:
|
||||||
|
```ruby
|
||||||
|
# When backchannel logout is received
|
||||||
|
Session.where(oidc_sid: sid).destroy_all
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Testing Your Endpoint
|
||||||
|
|
||||||
|
Test with curl:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Get a valid logout token (you'll need to capture this from Clinch logs)
|
||||||
|
LOGOUT_TOKEN="eyJhbGc..."
|
||||||
|
|
||||||
|
curl -X POST https://your-app.local/oidc/backchannel-logout \
|
||||||
|
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||||
|
-d "logout_token=$LOGOUT_TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected response: `200 OK` (empty body)
|
||||||
|
|
||||||
|
## Monitoring and Troubleshooting
|
||||||
|
|
||||||
|
### Checking Logs
|
||||||
|
|
||||||
|
Clinch logs all backchannel logout attempts:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# In development
|
||||||
|
tail -f log/development.log | grep BackchannelLogout
|
||||||
|
|
||||||
|
# Example log output:
|
||||||
|
# BackchannelLogout: Successfully sent logout notification to Kavita (https://kavita.local/oidc/backchannel-logout)
|
||||||
|
# BackchannelLogout: Application Jellyfin doesn't support backchannel logout
|
||||||
|
# BackchannelLogout: Timeout sending logout to HomeAssistant (https://ha.local/logout): Connection timeout
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
**1. HTTP Timeout**
|
||||||
|
- Symptom: `Timeout sending logout to...` in logs
|
||||||
|
- Solution: Ensure the RP's backchannel logout endpoint responds within 5 seconds
|
||||||
|
- Note: Clinch will retry 3 times with exponential backoff
|
||||||
|
|
||||||
|
**2. HTTP Errors (Non-200 Status)**
|
||||||
|
- Symptom: `Application X returned HTTP 400/500...` in logs
|
||||||
|
- Solution: Check the RP's logs for JWT validation errors
|
||||||
|
- Common causes:
|
||||||
|
- Wrong JWKS (public key mismatch)
|
||||||
|
- Incorrect `aud` (client_id) validation
|
||||||
|
- Missing required claims validation
|
||||||
|
|
||||||
|
**3. Network Unreachable**
|
||||||
|
- Symptom: `Failed to send logout to...` with connection errors
|
||||||
|
- Solution: Ensure the RP's logout endpoint is accessible from Clinch server
|
||||||
|
- Check: Firewalls, DNS, SSL certificates
|
||||||
|
|
||||||
|
**4. Sessions Not Terminating**
|
||||||
|
- Symptom: User still logged into RP after logging out of Clinch
|
||||||
|
- Solution: Verify the RP is storing and checking `sid` correctly
|
||||||
|
- Debug: Add logging to the RP's backchannel logout handler
|
||||||
|
|
||||||
|
### Verification Checklist
|
||||||
|
|
||||||
|
For RPs (Application Developers):
|
||||||
|
- [ ] Endpoint accepts POST requests
|
||||||
|
- [ ] Endpoint validates JWT signature using Clinch's JWKS
|
||||||
|
- [ ] Endpoint validates all required claims
|
||||||
|
- [ ] Endpoint terminates sessions by SID
|
||||||
|
- [ ] Endpoint returns 200 OK quickly (< 5 seconds)
|
||||||
|
- [ ] Sessions store the `sid` claim from ID tokens
|
||||||
|
- [ ] Backchannel logout URI is configured in Clinch admin
|
||||||
|
|
||||||
|
For Administrators:
|
||||||
|
- [ ] Application has `backchannel_logout_uri` configured
|
||||||
|
- [ ] URI uses HTTPS (in production)
|
||||||
|
- [ ] URI is reachable from Clinch server
|
||||||
|
- [ ] Check logs for successful logout notifications
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
1. **JWT Signature Verification**: Always verify the logout token signature using Clinch's public key
|
||||||
|
2. **Audience Validation**: Ensure the `aud` claim matches your client_id
|
||||||
|
3. **Issuer Validation**: Ensure the `iss` claim matches your Clinch URL
|
||||||
|
4. **No Authentication Required**: The endpoint should not require user authentication (it's server-to-server)
|
||||||
|
5. **HTTPS Only**: Always use HTTPS in production (Clinch enforces this)
|
||||||
|
6. **Fire-and-Forget**: RPs should log failures but not block on errors
|
||||||
|
|
||||||
|
## Comparison with Other Logout Methods
|
||||||
|
|
||||||
|
| Method | Communication | When Sessions Terminate | Reliability |
|
||||||
|
|--------|--------------|------------------------|-------------|
|
||||||
|
| **Backchannel Logout** | Server-to-server POST | Immediately | High (retries on failure) |
|
||||||
|
| **Front-Channel Logout** | Browser iframes | When browser loads iframes | Low (blocked by privacy settings) |
|
||||||
|
| **RP-Initiated Logout** | User redirects to Clinch | Only affects Clinch session | N/A (just triggers other methods) |
|
||||||
|
| **Token Expiry** | None | When access token expires | Guaranteed but delayed |
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [OpenID Connect Back-Channel Logout 1.0](https://openid.net/specs/openid-connect-backchannel-1_0.html)
|
||||||
|
- [RFC 7009: OAuth 2.0 Token Revocation](https://tools.ietf.org/html/rfc7009)
|
||||||
|
- [Clinch OIDC Discovery](/.well-known/openid-configuration)
|
||||||
268
docs/beta-checklist.md
Normal file
268
docs/beta-checklist.md
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
# Beta Release Readiness Checklist
|
||||||
|
|
||||||
|
This checklist ensures Clinch meets security, quality, and documentation standards before moving from "experimental" to "Beta" status.
|
||||||
|
|
||||||
|
> **Security Implementation Status:** See [security-todo.md](security-todo.md) for detailed vulnerability tracking and fixes.
|
||||||
|
> **Outstanding Security Issues:** 3 (all MEDIUM/LOW priority) - Phases 1-4 complete ✅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Scanning
|
||||||
|
|
||||||
|
### Automated Security Tools
|
||||||
|
- [x] **Brakeman** - Static security analysis for Rails
|
||||||
|
- Status: ✅ Passing (2 weak warnings documented and accepted)
|
||||||
|
- Command: `bin/brakeman --no-pager`
|
||||||
|
- CI: Runs on every PR and push to main
|
||||||
|
- Warnings documented in `config/brakeman.ignore`
|
||||||
|
|
||||||
|
- [x] **bundler-audit** - Dependency vulnerability scanning
|
||||||
|
- Status: ✅ No vulnerabilities found
|
||||||
|
- Command: `bin/bundler-audit check --update`
|
||||||
|
- CI: Runs on every PR and push to main
|
||||||
|
|
||||||
|
- [x] **importmap audit** - JavaScript dependency scanning
|
||||||
|
- CI: Runs on every PR and push to main
|
||||||
|
|
||||||
|
- [x] **Test Coverage** - SimpleCov integration
|
||||||
|
- Command: `COVERAGE=1 bin/rails test`
|
||||||
|
- Coverage report: `coverage/index.html`
|
||||||
|
|
||||||
|
### Security Features Implemented
|
||||||
|
|
||||||
|
#### Authentication
|
||||||
|
- [x] Secure password storage (bcrypt with Rails defaults)
|
||||||
|
- [x] TOTP 2FA with backup codes
|
||||||
|
- [x] WebAuthn/Passkey support (FIDO2)
|
||||||
|
- [x] Session management with device tracking
|
||||||
|
- [x] Session revocation (individual and bulk)
|
||||||
|
- [x] Remember me with configurable expiry
|
||||||
|
- [x] Account invitation flow with expiring tokens
|
||||||
|
- [x] Password reset with expiring tokens
|
||||||
|
|
||||||
|
#### OIDC Security
|
||||||
|
- [x] Authorization code flow with PKCE support
|
||||||
|
- [x] Refresh token rotation
|
||||||
|
- [x] Token family tracking (detects replay attacks)
|
||||||
|
- [x] All tokens HMAC-SHA256 hashed in database
|
||||||
|
- [x] Configurable token expiry (access, refresh, ID)
|
||||||
|
- [x] One-time use authorization codes
|
||||||
|
- [x] Pairwise subject identifiers (privacy)
|
||||||
|
- [x] ID tokens signed with RS256
|
||||||
|
- [x] Token revocation endpoint (RFC 7009)
|
||||||
|
- [x] Proper `at_hash` validation
|
||||||
|
- [x] OIDC standard claims (auth_time, acr, azp)
|
||||||
|
- [x] Automatic cleanup of expired tokens
|
||||||
|
|
||||||
|
#### Access Control
|
||||||
|
- [x] Group-based authorization
|
||||||
|
- [x] Application-level access control
|
||||||
|
- [x] Admin vs. regular user roles
|
||||||
|
- [x] User status management (active, disabled, pending)
|
||||||
|
- [x] TOTP enforcement per-user
|
||||||
|
- [x] ForwardAuth policy enforcement
|
||||||
|
|
||||||
|
#### Input Validation
|
||||||
|
- [x] Strong parameter filtering
|
||||||
|
- [x] URL validation for redirect URIs and landing URLs
|
||||||
|
- [x] Email validation and normalization
|
||||||
|
- [x] Slug validation (alphanumeric + hyphens)
|
||||||
|
- [x] Domain pattern validation for ForwardAuth
|
||||||
|
- [x] JSON parsing with error handling
|
||||||
|
- [x] File upload validation (type, size for app icons)
|
||||||
|
|
||||||
|
#### Output Encoding
|
||||||
|
- [x] HTML escaping by default (Rails 8)
|
||||||
|
- [x] JSON encoding for API responses
|
||||||
|
- [x] JWT encoding for ID tokens
|
||||||
|
- [x] Proper content types for responses
|
||||||
|
|
||||||
|
#### Session Security
|
||||||
|
- [x] Secure, httponly cookies
|
||||||
|
- [x] SameSite cookie attribute
|
||||||
|
- [x] Session timeout
|
||||||
|
- [x] IP and User-Agent tracking
|
||||||
|
- [x] CSRF protection
|
||||||
|
|
||||||
|
#### Cryptography
|
||||||
|
- [x] SecureRandom for tokens
|
||||||
|
- [x] bcrypt for passwords
|
||||||
|
- [x] HMAC-SHA256 for token hashing
|
||||||
|
- [x] RS256 for JWT signing
|
||||||
|
- [x] Proper secret management (Rails credentials)
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Test Coverage
|
||||||
|
- [x] **341 tests** across integration, model, controller, service, and system tests
|
||||||
|
- [x] **1349 assertions**
|
||||||
|
- [x] **0 failures, 0 errors**
|
||||||
|
|
||||||
|
### Test Categories
|
||||||
|
- [x] Integration tests (invitation flow, forward auth, WebAuthn, session security)
|
||||||
|
- [x] Model tests (OIDC tokens, users, applications, groups, authorization codes)
|
||||||
|
- [x] Controller tests (TOTP, sessions, passwords, OIDC flows, input validation)
|
||||||
|
- [x] Service tests (JWT generation and validation)
|
||||||
|
- [x] System tests (forward auth, WebAuthn security)
|
||||||
|
|
||||||
|
### Security-Critical Test Coverage
|
||||||
|
- [x] OIDC authorization code flow
|
||||||
|
- [x] PKCE flow
|
||||||
|
- [x] Refresh token rotation
|
||||||
|
- [x] Token replay attack detection
|
||||||
|
- [x] Access control (group-based)
|
||||||
|
- [x] Input validation
|
||||||
|
- [x] Session security
|
||||||
|
- [x] WebAuthn credential handling
|
||||||
|
- [x] TOTP validation
|
||||||
|
|
||||||
|
## Code Quality
|
||||||
|
|
||||||
|
- [x] **RuboCop** - Code style and linting
|
||||||
|
- Configuration: Rails Omakase
|
||||||
|
- CI: Runs on every PR and push to main
|
||||||
|
|
||||||
|
- [x] **Documentation** - Comprehensive README
|
||||||
|
- Feature documentation
|
||||||
|
- Setup instructions
|
||||||
|
- Configuration guide
|
||||||
|
- Rails console guide
|
||||||
|
- API/protocol documentation
|
||||||
|
|
||||||
|
## Production Readiness
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
- [ ] Review all environment variables
|
||||||
|
- [ ] Document required vs. optional configuration
|
||||||
|
- [ ] Provide sensible defaults
|
||||||
|
- [ ] Validate production SMTP configuration
|
||||||
|
- [ ] Ensure OIDC private key generation process is documented
|
||||||
|
|
||||||
|
### Database
|
||||||
|
- [x] Migrations are idempotent
|
||||||
|
- [x] Indexes on foreign keys
|
||||||
|
- [x] Proper constraints and validations
|
||||||
|
- [x] SQLite production-ready (Rails 8)
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
- [ ] Review N+1 queries
|
||||||
|
- [ ] Add database indexes where needed
|
||||||
|
- [ ] Test with realistic data volumes
|
||||||
|
- [ ] Review token cleanup job performance
|
||||||
|
|
||||||
|
### Deployment
|
||||||
|
- [x] Docker support
|
||||||
|
- [x] Docker Compose example
|
||||||
|
- [ ] Production deployment guide
|
||||||
|
- [ ] Backup and restore documentation
|
||||||
|
- [ ] Migration strategy documentation
|
||||||
|
|
||||||
|
## Security Hardening
|
||||||
|
|
||||||
|
### Headers & CSP
|
||||||
|
- [ ] Review Content Security Policy
|
||||||
|
- [ ] HSTS configuration
|
||||||
|
- [ ] X-Frame-Options
|
||||||
|
- [ ] X-Content-Type-Options
|
||||||
|
- [ ] Referrer-Policy
|
||||||
|
|
||||||
|
### Rate Limiting
|
||||||
|
- [ ] Login attempt rate limiting
|
||||||
|
- [ ] API endpoint rate limiting
|
||||||
|
- [ ] Token endpoint rate limiting
|
||||||
|
- [ ] Password reset rate limiting
|
||||||
|
|
||||||
|
### Secrets Management
|
||||||
|
- [x] No secrets in code
|
||||||
|
- [x] Rails credentials for sensitive data
|
||||||
|
- [ ] Document secret rotation process
|
||||||
|
- [ ] Document OIDC key rotation process
|
||||||
|
|
||||||
|
### Logging & Monitoring
|
||||||
|
- [x] Sentry integration (optional)
|
||||||
|
- [ ] Document what should be logged
|
||||||
|
- [ ] Document what should NOT be logged (tokens, passwords)
|
||||||
|
- [ ] Audit log for admin actions
|
||||||
|
|
||||||
|
## Known Limitations & Risks
|
||||||
|
|
||||||
|
### Documented Risks
|
||||||
|
- [ ] Document that ForwardAuth requires same-domain setup
|
||||||
|
- [ ] Document HTTPS requirement for production
|
||||||
|
- [ ] Document backup code security (single-use, store securely)
|
||||||
|
- [ ] Document admin password security requirements
|
||||||
|
|
||||||
|
### Future Security Enhancements
|
||||||
|
- [ ] Rate limiting on authentication endpoints
|
||||||
|
- [ ] Account lockout after N failed attempts
|
||||||
|
- [ ] Admin audit logging
|
||||||
|
- [ ] Security event notifications
|
||||||
|
- [ ] Brute force detection
|
||||||
|
- [ ] Suspicious login detection
|
||||||
|
- [ ] IP allowlist/blocklist
|
||||||
|
|
||||||
|
## External Security Review
|
||||||
|
|
||||||
|
- [ ] Consider bug bounty or security audit
|
||||||
|
- [ ] Penetration testing for OIDC flows
|
||||||
|
- [ ] WebAuthn implementation review
|
||||||
|
- [ ] Token security review
|
||||||
|
|
||||||
|
## Documentation for Users
|
||||||
|
|
||||||
|
- [ ] Security best practices guide
|
||||||
|
- [ ] Incident response guide
|
||||||
|
- [ ] Backup and disaster recovery guide
|
||||||
|
- [ ] Upgrade guide
|
||||||
|
- [ ] Breaking change policy
|
||||||
|
|
||||||
|
## Beta Release Criteria
|
||||||
|
|
||||||
|
To move from "experimental" to "Beta", the following must be completed:
|
||||||
|
|
||||||
|
**Critical (Required for Beta):**
|
||||||
|
- [x] All automated security scans passing
|
||||||
|
- [x] All tests passing
|
||||||
|
- [x] Core features implemented and tested
|
||||||
|
- [x] Basic documentation complete
|
||||||
|
- [ ] At least one external security review or penetration test
|
||||||
|
- [ ] Production deployment guide
|
||||||
|
- [ ] Backup/restore documentation
|
||||||
|
|
||||||
|
**Important (Should have for Beta):**
|
||||||
|
- [ ] Rate limiting on auth endpoints
|
||||||
|
- [ ] Security headers configuration documented
|
||||||
|
- [ ] Admin audit logging
|
||||||
|
- [ ] Known limitations documented
|
||||||
|
|
||||||
|
**Nice to have (Can defer to post-Beta):**
|
||||||
|
- [ ] Bug bounty program
|
||||||
|
- [ ] Advanced monitoring/alerting
|
||||||
|
- [ ] Automated security testing in CI beyond brakeman/bundler-audit
|
||||||
|
|
||||||
|
## Status Summary
|
||||||
|
|
||||||
|
**Current Status:** Pre-Beta / Experimental
|
||||||
|
|
||||||
|
**Strengths:**
|
||||||
|
- ✅ Comprehensive security tooling in place
|
||||||
|
- ✅ Strong test coverage (341 tests, 1349 assertions)
|
||||||
|
- ✅ Modern security features (PKCE, token rotation, WebAuthn)
|
||||||
|
- ✅ Clean security scans (brakeman, bundler-audit)
|
||||||
|
- ✅ Well-documented codebase
|
||||||
|
|
||||||
|
**Before Beta Release:**
|
||||||
|
- 🔶 External security review recommended
|
||||||
|
- 🔶 Rate limiting implementation needed
|
||||||
|
- 🔶 Production deployment documentation
|
||||||
|
- 🔶 Security hardening checklist completion
|
||||||
|
|
||||||
|
**Recommendation:** Consider Beta status after:
|
||||||
|
1. External security review or penetration testing
|
||||||
|
2. Rate limiting implementation
|
||||||
|
3. Production hardening documentation
|
||||||
|
4. 1-2 months of real-world testing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Last updated: 2026-01-01
|
||||||
176
docs/caddy-example.md
Normal file
176
docs/caddy-example.md
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
# Caddy ForwardAuth Configuration Examples
|
||||||
|
|
||||||
|
## Basic Configuration (Protecting MEtube)
|
||||||
|
|
||||||
|
Assuming Caddy and Clinch are running in a docker compose, and we can use the sevice name `clinch`. Exterally, assume you're connecting to https://clinch.example.com/
|
||||||
|
|
||||||
|
```caddyfile
|
||||||
|
# Clinch SSO (main authentication server)
|
||||||
|
clinch.yourdomain.com {
|
||||||
|
reverse_proxy clinch:3000
|
||||||
|
}
|
||||||
|
|
||||||
|
# MEtube (protected by Clinch)
|
||||||
|
metube.yourdomain.com {
|
||||||
|
# Forward authentication to Clinch
|
||||||
|
forward_auth clinch:3000 {
|
||||||
|
uri /api/verify
|
||||||
|
# uri /api/verify?rd=https://clinch.yourdomain.com # Shouldn't need this, the rd value should be sent via headers
|
||||||
|
copy_headers Remote-User Remote-Email Remote-Groups Remote-Admin
|
||||||
|
}
|
||||||
|
|
||||||
|
# If authentication succeeds, proxy to MEtube
|
||||||
|
handle {
|
||||||
|
reverse_proxy * {
|
||||||
|
to http://<ip-address-of-metube>:8081
|
||||||
|
header_up X-Real-IP {remote_host}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
1. User visits `https://metube.yourdomain.com`
|
||||||
|
2. Caddy makes request to `http://clinch:3000/api/verify passing in the url destination for metueb
|
||||||
|
3. Clinch checks if user is authenticated and authorized:
|
||||||
|
- If **200**: Caddy forwards request to MEtube with user headers
|
||||||
|
- If **302**: User is redirected to clinch.yourdomain.com to login
|
||||||
|
- If **403**: Access denied
|
||||||
|
4. User signs into Clinch (with TOTP if enabled or Passkey)
|
||||||
|
5. Clinch redirects back to MEtube
|
||||||
|
6. User can now access MEtube!
|
||||||
|
|
||||||
|
## Protecting Multiple Applications
|
||||||
|
|
||||||
|
```caddyfile
|
||||||
|
# Clinch SSO
|
||||||
|
clinch.yourdomain.com {
|
||||||
|
reverse_proxy clinch:3000
|
||||||
|
}
|
||||||
|
|
||||||
|
# MEtube - Anyone can access (no groups required)
|
||||||
|
metube.yourdomain.com {
|
||||||
|
forward_auth clinch:3000 {
|
||||||
|
uri /api/verify
|
||||||
|
copy_headers Remote-User Remote-Email Remote-Groups Remote-Admin
|
||||||
|
}
|
||||||
|
|
||||||
|
handle {
|
||||||
|
reverse_proxy * {
|
||||||
|
to http://metube:8081
|
||||||
|
header_up X-Real-IP {remote_host}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Sonarr - Only "media-managers" group
|
||||||
|
sonarr.yourdomain.com {
|
||||||
|
forward_auth clinch:3000 {
|
||||||
|
uri /api/verify
|
||||||
|
copy_headers Remote-User Remote-Email Remote-Groups Remote-Admin
|
||||||
|
}
|
||||||
|
|
||||||
|
handle {
|
||||||
|
reverse_proxy * {
|
||||||
|
to http://sonarr:8989
|
||||||
|
header_up X-Real-IP {remote_host}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Grafana - Only "admins" group
|
||||||
|
grafana.yourdomain.com {
|
||||||
|
forward_auth clinch:3000 {
|
||||||
|
uri /api/verify
|
||||||
|
copy_headers Remote-User Remote-Email Remote-Groups Remote-Admin
|
||||||
|
}
|
||||||
|
|
||||||
|
handle {
|
||||||
|
reverse_proxy * {
|
||||||
|
to http://grafana:3001
|
||||||
|
header_up X-Real-IP {remote_host}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Setup Steps
|
||||||
|
|
||||||
|
### 1. Create Applications in Clinch
|
||||||
|
|
||||||
|
Create the Application within Clinch, making sure to set Forward Auth application type
|
||||||
|
|
||||||
|
### 2. Update Caddyfile
|
||||||
|
|
||||||
|
Add the forward_auth directives shown above.
|
||||||
|
|
||||||
|
### 3. Reload Caddy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
caddy reload
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Test
|
||||||
|
|
||||||
|
Visit https://metube.yourdomain.com - you should be redirected to Clinch login!
|
||||||
|
|
||||||
|
## Advanced: Passing Headers to Application
|
||||||
|
|
||||||
|
Some applications can use the forwarded headers for user identification:
|
||||||
|
|
||||||
|
```caddyfile
|
||||||
|
metube.yourdomain.com {
|
||||||
|
forward_auth clinch:3000 {
|
||||||
|
uri /api/verify
|
||||||
|
copy_headers Remote-User Remote-Email Remote-Groups Remote-Admin
|
||||||
|
}
|
||||||
|
|
||||||
|
# The headers are automatically passed to the backend
|
||||||
|
handle {
|
||||||
|
reverse_proxy * {
|
||||||
|
to http://metube:8081
|
||||||
|
header_up X-Real-IP {remote_host}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Now MEtube receives these headers with every request:
|
||||||
|
- `Remote-User`: user@example.com
|
||||||
|
- `Remote-Email`: user@example.com
|
||||||
|
- `Remote-Groups`: media-managers,users
|
||||||
|
- `Remote-Admin`: false
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Users not staying logged in
|
||||||
|
|
||||||
|
Ensure your Caddy configuration preserves cookies:
|
||||||
|
|
||||||
|
```caddyfile
|
||||||
|
clinch.yourdomain.com {
|
||||||
|
reverse_proxy localhost:3000 {
|
||||||
|
header_up X-Forwarded-Host {host}
|
||||||
|
header_up X-Forwarded-Proto {scheme}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Authentication loop
|
||||||
|
|
||||||
|
Check that the `/api/verify` endpoint is not itself protected:
|
||||||
|
- `/api/verify` must be accessible without authentication
|
||||||
|
- It returns 401/403 for unauthenticated users (this is expected)
|
||||||
|
|
||||||
|
### Check Clinch logs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tail -f log/production.log
|
||||||
|
```
|
||||||
|
|
||||||
|
You'll see ForwardAuth log messages like:
|
||||||
|
```
|
||||||
|
ForwardAuth: User user@example.com granted access to metube
|
||||||
|
ForwardAuth: Unauthorized - No session cookie
|
||||||
|
```
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user