Compare commits
22 Commits
bf104a9983
...
feature/en
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e882a4d6d1 | ||
|
|
ab0085e9c9 | ||
|
|
1ee3302319 | ||
|
|
67f28faaca | ||
|
|
33ad956508 | ||
|
|
11ec753c68 | ||
|
|
4df2eee4d9 | ||
|
|
d9f11abbbf | ||
|
|
c92e69fa4a | ||
|
|
038801f34b | ||
|
|
8e0b2c28eb | ||
|
|
f02665f690 | ||
|
|
631b2b53bb | ||
|
|
6049429a41 | ||
|
|
2b15aa2c40 | ||
|
|
4f5974dd37 | ||
|
|
5de53f1841 | ||
|
|
73b2ae2f02 | ||
|
|
4c5ac344bd | ||
|
|
044b9239d6 | ||
|
|
e9b1995e89 | ||
|
|
fb14ce032f |
24
.env.example
24
.env.example
@@ -68,3 +68,27 @@ CLINCH_ALLOW_LOCALHOST=true
|
|||||||
|
|
||||||
# Optional: Set custom port
|
# Optional: Set custom port
|
||||||
# PORT=9000
|
# PORT=9000
|
||||||
|
|
||||||
|
# Sentry Configuration (Optional)
|
||||||
|
# Enable error tracking and performance monitoring
|
||||||
|
# Leave SENTRY_DSN empty to disable Sentry completely
|
||||||
|
#
|
||||||
|
# Production: Get your DSN from https://sentry.io/settings/projects/
|
||||||
|
# SENTRY_DSN=https://your-dsn@sentry.io/project-id
|
||||||
|
#
|
||||||
|
# Optional: Override Sentry environment (defaults to Rails.env)
|
||||||
|
# SENTRY_ENVIRONMENT=production
|
||||||
|
#
|
||||||
|
# Optional: Override Sentry release (defaults to Git commit hash)
|
||||||
|
# SENTRY_RELEASE=v1.0.0
|
||||||
|
#
|
||||||
|
# Optional: Performance monitoring sample rate (0.0 to 1.0, default 0.2)
|
||||||
|
# Higher values provide more data but cost more
|
||||||
|
# SENTRY_TRACES_SAMPLE_RATE=0.2
|
||||||
|
#
|
||||||
|
# Optional: Continuous profiling sample rate (0.0 to 1.0, default 0.0)
|
||||||
|
# Very resource intensive, only enable for performance investigations
|
||||||
|
# SENTRY_PROFILES_SAMPLE_RATE=0.0
|
||||||
|
#
|
||||||
|
# Development: Enable Sentry in development for testing
|
||||||
|
# SENTRY_ENABLED_IN_DEVELOPMENT=true
|
||||||
|
|||||||
6
Gemfile
6
Gemfile
@@ -1,7 +1,7 @@
|
|||||||
source "https://rubygems.org"
|
source "https://rubygems.org"
|
||||||
|
|
||||||
# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main"
|
# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main"
|
||||||
gem "rails", "~> 8.1.0"
|
gem "rails", "~> 8.1.1"
|
||||||
# The modern asset pipeline for Rails [https://github.com/rails/propshaft]
|
# The modern asset pipeline for Rails [https://github.com/rails/propshaft]
|
||||||
gem "propshaft"
|
gem "propshaft"
|
||||||
# Use sqlite3 as the database for Active Record
|
# Use sqlite3 as the database for Active Record
|
||||||
@@ -37,6 +37,10 @@ gem "webauthn", "~> 3.0"
|
|||||||
# Public Suffix List for domain parsing
|
# Public Suffix List for domain parsing
|
||||||
gem "public_suffix", "~> 6.0"
|
gem "public_suffix", "~> 6.0"
|
||||||
|
|
||||||
|
# Error tracking and performance monitoring (optional, configured via SENTRY_DSN)
|
||||||
|
gem "sentry-ruby", "~> 5.18"
|
||||||
|
gem "sentry-rails", "~> 5.18"
|
||||||
|
|
||||||
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
|
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
|
||||||
gem "tzinfo-data", platforms: %i[ windows jruby ]
|
gem "tzinfo-data", platforms: %i[ windows jruby ]
|
||||||
|
|
||||||
|
|||||||
136
Gemfile.lock
136
Gemfile.lock
@@ -3,29 +3,29 @@ GEM
|
|||||||
specs:
|
specs:
|
||||||
action_text-trix (2.1.15)
|
action_text-trix (2.1.15)
|
||||||
railties
|
railties
|
||||||
actioncable (8.1.0)
|
actioncable (8.1.1)
|
||||||
actionpack (= 8.1.0)
|
actionpack (= 8.1.1)
|
||||||
activesupport (= 8.1.0)
|
activesupport (= 8.1.1)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
websocket-driver (>= 0.6.1)
|
websocket-driver (>= 0.6.1)
|
||||||
zeitwerk (~> 2.6)
|
zeitwerk (~> 2.6)
|
||||||
actionmailbox (8.1.0)
|
actionmailbox (8.1.1)
|
||||||
actionpack (= 8.1.0)
|
actionpack (= 8.1.1)
|
||||||
activejob (= 8.1.0)
|
activejob (= 8.1.1)
|
||||||
activerecord (= 8.1.0)
|
activerecord (= 8.1.1)
|
||||||
activestorage (= 8.1.0)
|
activestorage (= 8.1.1)
|
||||||
activesupport (= 8.1.0)
|
activesupport (= 8.1.1)
|
||||||
mail (>= 2.8.0)
|
mail (>= 2.8.0)
|
||||||
actionmailer (8.1.0)
|
actionmailer (8.1.1)
|
||||||
actionpack (= 8.1.0)
|
actionpack (= 8.1.1)
|
||||||
actionview (= 8.1.0)
|
actionview (= 8.1.1)
|
||||||
activejob (= 8.1.0)
|
activejob (= 8.1.1)
|
||||||
activesupport (= 8.1.0)
|
activesupport (= 8.1.1)
|
||||||
mail (>= 2.8.0)
|
mail (>= 2.8.0)
|
||||||
rails-dom-testing (~> 2.2)
|
rails-dom-testing (~> 2.2)
|
||||||
actionpack (8.1.0)
|
actionpack (8.1.1)
|
||||||
actionview (= 8.1.0)
|
actionview (= 8.1.1)
|
||||||
activesupport (= 8.1.0)
|
activesupport (= 8.1.1)
|
||||||
nokogiri (>= 1.8.5)
|
nokogiri (>= 1.8.5)
|
||||||
rack (>= 2.2.4)
|
rack (>= 2.2.4)
|
||||||
rack-session (>= 1.0.1)
|
rack-session (>= 1.0.1)
|
||||||
@@ -33,36 +33,36 @@ GEM
|
|||||||
rails-dom-testing (~> 2.2)
|
rails-dom-testing (~> 2.2)
|
||||||
rails-html-sanitizer (~> 1.6)
|
rails-html-sanitizer (~> 1.6)
|
||||||
useragent (~> 0.16)
|
useragent (~> 0.16)
|
||||||
actiontext (8.1.0)
|
actiontext (8.1.1)
|
||||||
action_text-trix (~> 2.1.15)
|
action_text-trix (~> 2.1.15)
|
||||||
actionpack (= 8.1.0)
|
actionpack (= 8.1.1)
|
||||||
activerecord (= 8.1.0)
|
activerecord (= 8.1.1)
|
||||||
activestorage (= 8.1.0)
|
activestorage (= 8.1.1)
|
||||||
activesupport (= 8.1.0)
|
activesupport (= 8.1.1)
|
||||||
globalid (>= 0.6.0)
|
globalid (>= 0.6.0)
|
||||||
nokogiri (>= 1.8.5)
|
nokogiri (>= 1.8.5)
|
||||||
actionview (8.1.0)
|
actionview (8.1.1)
|
||||||
activesupport (= 8.1.0)
|
activesupport (= 8.1.1)
|
||||||
builder (~> 3.1)
|
builder (~> 3.1)
|
||||||
erubi (~> 1.11)
|
erubi (~> 1.11)
|
||||||
rails-dom-testing (~> 2.2)
|
rails-dom-testing (~> 2.2)
|
||||||
rails-html-sanitizer (~> 1.6)
|
rails-html-sanitizer (~> 1.6)
|
||||||
activejob (8.1.0)
|
activejob (8.1.1)
|
||||||
activesupport (= 8.1.0)
|
activesupport (= 8.1.1)
|
||||||
globalid (>= 0.3.6)
|
globalid (>= 0.3.6)
|
||||||
activemodel (8.1.0)
|
activemodel (8.1.1)
|
||||||
activesupport (= 8.1.0)
|
activesupport (= 8.1.1)
|
||||||
activerecord (8.1.0)
|
activerecord (8.1.1)
|
||||||
activemodel (= 8.1.0)
|
activemodel (= 8.1.1)
|
||||||
activesupport (= 8.1.0)
|
activesupport (= 8.1.1)
|
||||||
timeout (>= 0.4.0)
|
timeout (>= 0.4.0)
|
||||||
activestorage (8.1.0)
|
activestorage (8.1.1)
|
||||||
actionpack (= 8.1.0)
|
actionpack (= 8.1.1)
|
||||||
activejob (= 8.1.0)
|
activejob (= 8.1.1)
|
||||||
activerecord (= 8.1.0)
|
activerecord (= 8.1.1)
|
||||||
activesupport (= 8.1.0)
|
activesupport (= 8.1.1)
|
||||||
marcel (~> 1.0)
|
marcel (~> 1.0)
|
||||||
activesupport (8.1.0)
|
activesupport (8.1.1)
|
||||||
base64
|
base64
|
||||||
bigdecimal
|
bigdecimal
|
||||||
concurrent-ruby (~> 1.0, >= 1.3.1)
|
concurrent-ruby (~> 1.0, >= 1.3.1)
|
||||||
@@ -112,14 +112,14 @@ GEM
|
|||||||
cbor (~> 0.5.9)
|
cbor (~> 0.5.9)
|
||||||
openssl-signature_algorithm (~> 1.0)
|
openssl-signature_algorithm (~> 1.0)
|
||||||
crass (1.0.6)
|
crass (1.0.6)
|
||||||
date (3.4.1)
|
date (3.5.0)
|
||||||
debug (1.11.0)
|
debug (1.11.0)
|
||||||
irb (~> 1.10)
|
irb (~> 1.10)
|
||||||
reline (>= 0.3.8)
|
reline (>= 0.3.8)
|
||||||
dotenv (3.1.8)
|
dotenv (3.1.8)
|
||||||
drb (2.2.3)
|
drb (2.2.3)
|
||||||
ed25519 (1.4.0)
|
ed25519 (1.4.0)
|
||||||
erb (5.1.1)
|
erb (5.1.3)
|
||||||
erubi (1.13.1)
|
erubi (1.13.1)
|
||||||
ffi (1.17.2-aarch64-linux-gnu)
|
ffi (1.17.2-aarch64-linux-gnu)
|
||||||
ffi (1.17.2-aarch64-linux-musl)
|
ffi (1.17.2-aarch64-linux-musl)
|
||||||
@@ -140,14 +140,14 @@ GEM
|
|||||||
activesupport (>= 6.0.0)
|
activesupport (>= 6.0.0)
|
||||||
railties (>= 6.0.0)
|
railties (>= 6.0.0)
|
||||||
io-console (0.8.1)
|
io-console (0.8.1)
|
||||||
irb (1.15.2)
|
irb (1.15.3)
|
||||||
pp (>= 0.6.0)
|
pp (>= 0.6.0)
|
||||||
rdoc (>= 4.0.0)
|
rdoc (>= 4.0.0)
|
||||||
reline (>= 0.4.2)
|
reline (>= 0.4.2)
|
||||||
jbuilder (2.14.1)
|
jbuilder (2.14.1)
|
||||||
actionview (>= 7.0.0)
|
actionview (>= 7.0.0)
|
||||||
activesupport (>= 7.0.0)
|
activesupport (>= 7.0.0)
|
||||||
json (2.15.1)
|
json (2.15.2)
|
||||||
jwt (3.1.2)
|
jwt (3.1.2)
|
||||||
base64
|
base64
|
||||||
kamal (2.8.1)
|
kamal (2.8.1)
|
||||||
@@ -200,7 +200,7 @@ GEM
|
|||||||
net-smtp (0.5.1)
|
net-smtp (0.5.1)
|
||||||
net-protocol
|
net-protocol
|
||||||
net-ssh (7.3.0)
|
net-ssh (7.3.0)
|
||||||
nio4r (2.7.4)
|
nio4r (2.7.5)
|
||||||
nokogiri (1.18.10-aarch64-linux-gnu)
|
nokogiri (1.18.10-aarch64-linux-gnu)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.18.10-aarch64-linux-musl)
|
nokogiri (1.18.10-aarch64-linux-musl)
|
||||||
@@ -238,7 +238,7 @@ GEM
|
|||||||
puma (7.1.0)
|
puma (7.1.0)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
racc (1.8.1)
|
racc (1.8.1)
|
||||||
rack (3.2.3)
|
rack (3.2.4)
|
||||||
rack-session (2.1.1)
|
rack-session (2.1.1)
|
||||||
base64 (>= 0.1.0)
|
base64 (>= 0.1.0)
|
||||||
rack (>= 3.0.0)
|
rack (>= 3.0.0)
|
||||||
@@ -246,20 +246,20 @@ GEM
|
|||||||
rack (>= 1.3)
|
rack (>= 1.3)
|
||||||
rackup (2.2.1)
|
rackup (2.2.1)
|
||||||
rack (>= 3)
|
rack (>= 3)
|
||||||
rails (8.1.0)
|
rails (8.1.1)
|
||||||
actioncable (= 8.1.0)
|
actioncable (= 8.1.1)
|
||||||
actionmailbox (= 8.1.0)
|
actionmailbox (= 8.1.1)
|
||||||
actionmailer (= 8.1.0)
|
actionmailer (= 8.1.1)
|
||||||
actionpack (= 8.1.0)
|
actionpack (= 8.1.1)
|
||||||
actiontext (= 8.1.0)
|
actiontext (= 8.1.1)
|
||||||
actionview (= 8.1.0)
|
actionview (= 8.1.1)
|
||||||
activejob (= 8.1.0)
|
activejob (= 8.1.1)
|
||||||
activemodel (= 8.1.0)
|
activemodel (= 8.1.1)
|
||||||
activerecord (= 8.1.0)
|
activerecord (= 8.1.1)
|
||||||
activestorage (= 8.1.0)
|
activestorage (= 8.1.1)
|
||||||
activesupport (= 8.1.0)
|
activesupport (= 8.1.1)
|
||||||
bundler (>= 1.15.0)
|
bundler (>= 1.15.0)
|
||||||
railties (= 8.1.0)
|
railties (= 8.1.1)
|
||||||
rails-dom-testing (2.3.0)
|
rails-dom-testing (2.3.0)
|
||||||
activesupport (>= 5.0.0)
|
activesupport (>= 5.0.0)
|
||||||
minitest
|
minitest
|
||||||
@@ -267,9 +267,9 @@ GEM
|
|||||||
rails-html-sanitizer (1.6.2)
|
rails-html-sanitizer (1.6.2)
|
||||||
loofah (~> 2.21)
|
loofah (~> 2.21)
|
||||||
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
|
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
|
||||||
railties (8.1.0)
|
railties (8.1.1)
|
||||||
actionpack (= 8.1.0)
|
actionpack (= 8.1.1)
|
||||||
activesupport (= 8.1.0)
|
activesupport (= 8.1.1)
|
||||||
irb (~> 1.13)
|
irb (~> 1.13)
|
||||||
rackup (>= 1.0.0)
|
rackup (>= 1.0.0)
|
||||||
rake (>= 12.2)
|
rake (>= 12.2)
|
||||||
@@ -277,8 +277,8 @@ GEM
|
|||||||
tsort (>= 0.2)
|
tsort (>= 0.2)
|
||||||
zeitwerk (~> 2.6)
|
zeitwerk (~> 2.6)
|
||||||
rainbow (3.1.1)
|
rainbow (3.1.1)
|
||||||
rake (13.3.0)
|
rake (13.3.1)
|
||||||
rdoc (6.15.0)
|
rdoc (6.15.1)
|
||||||
erb
|
erb
|
||||||
psych (>= 4.0.0)
|
psych (>= 4.0.0)
|
||||||
tsort
|
tsort
|
||||||
@@ -333,6 +333,12 @@ GEM
|
|||||||
rexml (~> 3.2, >= 3.2.5)
|
rexml (~> 3.2, >= 3.2.5)
|
||||||
rubyzip (>= 1.2.2, < 4.0)
|
rubyzip (>= 1.2.2, < 4.0)
|
||||||
websocket (~> 1.0)
|
websocket (~> 1.0)
|
||||||
|
sentry-rails (5.28.0)
|
||||||
|
railties (>= 5.0)
|
||||||
|
sentry-ruby (~> 5.28.0)
|
||||||
|
sentry-ruby (5.28.0)
|
||||||
|
bigdecimal
|
||||||
|
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||||
solid_cable (3.0.12)
|
solid_cable (3.0.12)
|
||||||
actioncable (>= 7.2)
|
actioncable (>= 7.2)
|
||||||
activejob (>= 7.2)
|
activejob (>= 7.2)
|
||||||
@@ -373,7 +379,7 @@ GEM
|
|||||||
thruster (0.1.16-aarch64-linux)
|
thruster (0.1.16-aarch64-linux)
|
||||||
thruster (0.1.16-arm64-darwin)
|
thruster (0.1.16-arm64-darwin)
|
||||||
thruster (0.1.16-x86_64-linux)
|
thruster (0.1.16-x86_64-linux)
|
||||||
timeout (0.4.3)
|
timeout (0.4.4)
|
||||||
tpm-key_attestation (0.14.1)
|
tpm-key_attestation (0.14.1)
|
||||||
bindata (~> 2.4)
|
bindata (~> 2.4)
|
||||||
openssl (> 2.0)
|
openssl (> 2.0)
|
||||||
@@ -387,7 +393,7 @@ GEM
|
|||||||
unicode-display_width (3.2.0)
|
unicode-display_width (3.2.0)
|
||||||
unicode-emoji (~> 4.1)
|
unicode-emoji (~> 4.1)
|
||||||
unicode-emoji (4.1.0)
|
unicode-emoji (4.1.0)
|
||||||
uri (1.0.4)
|
uri (1.1.0)
|
||||||
useragent (0.16.11)
|
useragent (0.16.11)
|
||||||
web-console (4.2.1)
|
web-console (4.2.1)
|
||||||
actionview (>= 6.0.0)
|
actionview (>= 6.0.0)
|
||||||
@@ -438,11 +444,13 @@ DEPENDENCIES
|
|||||||
propshaft
|
propshaft
|
||||||
public_suffix (~> 6.0)
|
public_suffix (~> 6.0)
|
||||||
puma (>= 5.0)
|
puma (>= 5.0)
|
||||||
rails (~> 8.1.0)
|
rails (~> 8.1.1)
|
||||||
rotp (~> 6.3)
|
rotp (~> 6.3)
|
||||||
rqrcode (~> 3.1)
|
rqrcode (~> 3.1)
|
||||||
rubocop-rails-omakase
|
rubocop-rails-omakase
|
||||||
selenium-webdriver
|
selenium-webdriver
|
||||||
|
sentry-rails (~> 5.18)
|
||||||
|
sentry-ruby (~> 5.18)
|
||||||
solid_cable
|
solid_cable
|
||||||
solid_cache
|
solid_cache
|
||||||
sqlite3 (>= 2.1)
|
sqlite3 (>= 2.1)
|
||||||
|
|||||||
51
README.md
51
README.md
@@ -3,9 +3,26 @@
|
|||||||
> [!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 experiemental. If you'd like to try it out, find bugs, security flaws and improvements, please do.
|
||||||
|
|
||||||
**A lightweight, self-hosted identity & SSO portal**
|
**A lightweight, self-hosted identity & SSO / IpD portal**
|
||||||
|
|
||||||
Clinch gives you one place to manage users and lets any web app authenticate against it without maintaining its own user table.
|
Clinch gives you one place to manage users and lets any web app authenticate against it without maintaining its own user table.
|
||||||
|
|
||||||
|
I've completed all planned features:
|
||||||
|
|
||||||
|
* Create Admin user on first login
|
||||||
|
* TOTP ( QR Code ) 2FA, with backup codes ( encrypted at rest )
|
||||||
|
* Passkey generation and login, with detection of Passkey during login
|
||||||
|
* Forward Auth configured and working
|
||||||
|
* OIDC provider with auto discovery, refresh tokens, and token revocation
|
||||||
|
* Configurable token expiry per application (access, refresh, ID tokens)
|
||||||
|
* Invite users by email, assign to groups
|
||||||
|
* Self managed password reset by email
|
||||||
|
* Use Groups to assign Applications ( Family group can access Kavita, Developers can access Gitea )
|
||||||
|
* Configurable Group and User custom claims for OIDC token
|
||||||
|
* Display all Applications available to the user on their Dashboard
|
||||||
|
* Display all logged in sessions and OIDC logged in sessions
|
||||||
|
|
||||||
|
What remains now is ensure test coverage,
|
||||||
|
|
||||||
## Why Clinch?
|
## Why Clinch?
|
||||||
|
|
||||||
@@ -70,11 +87,17 @@ Clinch sits in a sweet spot between two excellent open-source identity solutions
|
|||||||
#### OpenID Connect (OIDC)
|
#### OpenID Connect (OIDC)
|
||||||
Standard OAuth2/OIDC provider with endpoints:
|
Standard OAuth2/OIDC provider with endpoints:
|
||||||
- `/.well-known/openid-configuration` - Discovery endpoint
|
- `/.well-known/openid-configuration` - Discovery endpoint
|
||||||
- `/authorize` - Authorization endpoint
|
- `/authorize` - Authorization endpoint with PKCE support
|
||||||
- `/token` - Token endpoint
|
- `/token` - Token endpoint (authorization_code and refresh_token grants)
|
||||||
- `/userinfo` - User info endpoint
|
- `/userinfo` - User info endpoint
|
||||||
|
- `/revoke` - Token revocation endpoint (RFC 7009)
|
||||||
|
|
||||||
Client apps (Audiobookshelf, Kavita, Grafana, etc.) redirect to Clinch for login and receive ID tokens and access tokens.
|
Features:
|
||||||
|
- **Refresh tokens** - Long-lived tokens (30 days default) with automatic rotation and revocation
|
||||||
|
- **Configurable token expiry** - Set access token (5min-24hr), refresh token (1-90 days), and ID token TTL per application
|
||||||
|
- **Token security** - BCrypt-hashed tokens, automatic cleanup of expired tokens
|
||||||
|
|
||||||
|
Client apps (Audiobookshelf, Kavita, Grafana, etc.) redirect to Clinch for login and receive ID tokens, access tokens, and refresh tokens.
|
||||||
|
|
||||||
#### Trusted-Header SSO (ForwardAuth)
|
#### Trusted-Header SSO (ForwardAuth)
|
||||||
Works with reverse proxies (Caddy, Traefik, Nginx):
|
Works with reverse proxies (Caddy, Traefik, Nginx):
|
||||||
@@ -140,25 +163,29 @@ Send emails for:
|
|||||||
- Redirect URIs (for OIDC apps)
|
- Redirect URIs (for OIDC apps)
|
||||||
- Domain pattern (for ForwardAuth apps, supports wildcards like *.example.com)
|
- Domain pattern (for ForwardAuth apps, supports wildcards like *.example.com)
|
||||||
- Headers config (for ForwardAuth apps, JSON configuration for custom header names)
|
- Headers config (for ForwardAuth apps, JSON configuration for custom header names)
|
||||||
|
- Token TTL configuration (access_token_ttl, refresh_token_ttl, id_token_ttl)
|
||||||
- Metadata (flexible JSON storage)
|
- Metadata (flexible JSON storage)
|
||||||
- Active flag
|
- Active flag
|
||||||
- Many-to-many with Groups (allowlist)
|
- Many-to-many with Groups (allowlist)
|
||||||
|
|
||||||
**OIDC Tokens**
|
**OIDC Tokens**
|
||||||
- Authorization codes (10-minute expiry, one-time use)
|
- Authorization codes (10-minute expiry, one-time use, PKCE support)
|
||||||
- Access tokens (1-hour expiry, revocable)
|
- Access tokens (opaque, BCrypt-hashed, configurable expiry 5min-24hr, revocable)
|
||||||
|
- Refresh tokens (opaque, BCrypt-hashed, configurable expiry 1-90 days, single-use with rotation)
|
||||||
|
- ID tokens (JWT, signed with RS256, configurable expiry 5min-24hr)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Authentication Flows
|
## Authentication Flows
|
||||||
|
|
||||||
### OIDC Authorization Flow
|
### OIDC Authorization Flow
|
||||||
1. Client redirects user to `/authorize` with client_id, redirect_uri, scope
|
1. Client redirects user to `/authorize` with client_id, redirect_uri, scope (optional PKCE)
|
||||||
2. User authenticates with Clinch (username/password + optional TOTP)
|
2. User authenticates with Clinch (username/password + optional TOTP)
|
||||||
3. Access control check: Is user in an allowed group for this app?
|
3. Access control check: Is user in an allowed group for this app?
|
||||||
4. If allowed, generate authorization code and redirect to client
|
4. If allowed, generate authorization code and redirect to client
|
||||||
5. Client exchanges code for access token at `/token`
|
5. Client exchanges code at `/token` for ID token, access token, and refresh token
|
||||||
6. Client uses access token to fetch user info from `/userinfo`
|
6. Client uses access token to fetch fresh user info from `/userinfo`
|
||||||
|
7. When access token expires, client uses refresh token to get new tokens (no re-authentication)
|
||||||
|
|
||||||
### ForwardAuth Flow
|
### ForwardAuth Flow
|
||||||
1. User requests protected resource at `https://app.example.com/dashboard`
|
1. User requests protected resource at `https://app.example.com/dashboard`
|
||||||
@@ -242,6 +269,10 @@ SMTP_ENABLE_STARTTLS=true
|
|||||||
# Application
|
# Application
|
||||||
CLINCH_HOST=https://auth.example.com
|
CLINCH_HOST=https://auth.example.com
|
||||||
CLINCH_FROM_EMAIL=noreply@example.com
|
CLINCH_FROM_EMAIL=noreply@example.com
|
||||||
|
|
||||||
|
# OIDC (optional - generates temporary key in development)
|
||||||
|
# Generate with: openssl genpkey -algorithm RSA -out private_key.pem -pkeyopt rsa_keygen_bits:2048
|
||||||
|
OIDC_PRIVATE_KEY=<contents-of-private-key.pem>
|
||||||
```
|
```
|
||||||
|
|
||||||
### First Run
|
### First Run
|
||||||
|
|||||||
@@ -99,8 +99,12 @@ module Admin
|
|||||||
def application_params
|
def application_params
|
||||||
params.require(:application).permit(
|
params.require(:application).permit(
|
||||||
:name, :slug, :app_type, :active, :redirect_uris, :description, :metadata,
|
:name, :slug, :app_type, :active, :redirect_uris, :description, :metadata,
|
||||||
:domain_pattern, :landing_url, headers_config: {}
|
:domain_pattern, :landing_url, :access_token_ttl, :refresh_token_ttl, :id_token_ttl,
|
||||||
)
|
headers_config: {}
|
||||||
|
).tap do |whitelisted|
|
||||||
|
# Remove client_secret from params if present (shouldn't be updated via form)
|
||||||
|
whitelisted.delete(:client_secret)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -8,19 +8,45 @@ 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']
|
||||||
|
|
||||||
|
# Validate that we have a proper CSP report
|
||||||
|
unless csp_report.is_a?(Hash) && csp_report.present?
|
||||||
|
Rails.logger.warn "Received empty or invalid CSP violation report"
|
||||||
|
head :bad_request
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
# Log the violation for security monitoring
|
# Log the violation for security monitoring
|
||||||
Rails.logger.warn "CSP Violation Report:"
|
Rails.logger.warn "CSP Violation Report:"
|
||||||
Rails.logger.warn " Blocked URI: #{report_data.dig('csp-report', 'blocked-uri')}"
|
Rails.logger.warn " Blocked URI: #{csp_report['blocked-uri']}"
|
||||||
Rails.logger.warn " Document URI: #{report_data.dig('csp-report', 'document-uri')}"
|
Rails.logger.warn " Document URI: #{csp_report['document-uri']}"
|
||||||
Rails.logger.warn " Referrer: #{report_data.dig('csp-report', 'referrer')}"
|
Rails.logger.warn " Referrer: #{csp_report['referrer']}"
|
||||||
Rails.logger.warn " Violated Directive: #{report_data.dig('csp-report', 'violated-directive')}"
|
Rails.logger.warn " Violated Directive: #{csp_report['violated-directive']}"
|
||||||
Rails.logger.warn " Original Policy: #{report_data.dig('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}"
|
||||||
|
|
||||||
# In production, you might want to send this to a security monitoring service
|
# Emit structured event for CSP violation
|
||||||
# For now, we'll just log it and return a success response
|
# This allows multiple subscribers to process the event (Sentry, local logging, etc.)
|
||||||
|
Rails.event.notify("csp.violation", {
|
||||||
|
blocked_uri: csp_report['blocked-uri'],
|
||||||
|
document_uri: csp_report['document-uri'],
|
||||||
|
referrer: csp_report['referrer'],
|
||||||
|
violated_directive: csp_report['violated-directive'],
|
||||||
|
original_policy: csp_report['original-policy'],
|
||||||
|
disposition: csp_report['disposition'],
|
||||||
|
effective_directive: csp_report['effective-directive'],
|
||||||
|
source_file: csp_report['source-file'],
|
||||||
|
line_number: csp_report['line-number'],
|
||||||
|
column_number: csp_report['column-number'],
|
||||||
|
status_code: csp_report['status-code'],
|
||||||
|
user_agent: request.user_agent,
|
||||||
|
ip_address: request.remote_ip,
|
||||||
|
current_user_id: Current.user&.id,
|
||||||
|
timestamp: Time.current,
|
||||||
|
session_id: Current.session&.id
|
||||||
|
})
|
||||||
|
|
||||||
head :no_content
|
head :no_content
|
||||||
rescue JSON::ParserError => e
|
rescue JSON::ParserError => e
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ module Api
|
|||||||
|
|
||||||
# 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 = redirect_url || "https://clinch.aapamilne.com"
|
base_url = determine_base_url(redirect_url)
|
||||||
|
|
||||||
# Set the original URL that user was trying to access
|
# Set the original URL that user was trying to access
|
||||||
# This will be used after authentication
|
# This will be used after authentication
|
||||||
@@ -214,5 +214,27 @@ module Api
|
|||||||
app.matches_domain?(domain.downcase)
|
app.matches_domain?(domain.downcase)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def determine_base_url(redirect_url)
|
||||||
|
# If we have a valid redirect URL, use it
|
||||||
|
return redirect_url if redirect_url.present?
|
||||||
|
|
||||||
|
# Try CLINCH_HOST environment variable first
|
||||||
|
if ENV['CLINCH_HOST'].present?
|
||||||
|
host = ENV['CLINCH_HOST']
|
||||||
|
# Ensure URL has https:// protocol
|
||||||
|
host.match?(/^https?:\/\//) ? host : "https://#{host}"
|
||||||
|
else
|
||||||
|
# Fallback to the request host
|
||||||
|
request_host = request.host || request.headers['X-Forwarded-Host']
|
||||||
|
if request_host.present?
|
||||||
|
Rails.logger.warn "ForwardAuth: CLINCH_HOST not set, using request host: #{request_host}"
|
||||||
|
"https://#{request_host}"
|
||||||
|
else
|
||||||
|
# No host information available - raise exception to force proper configuration
|
||||||
|
raise StandardError, "ForwardAuth: CLINCH_HOST environment variable not set and no request host available. Please configure CLINCH_HOST properly."
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -5,4 +5,7 @@ class ApplicationController < ActionController::Base
|
|||||||
|
|
||||||
# Changes to the importmap will invalidate the etag for HTML responses
|
# Changes to the importmap will invalidate the etag for HTML responses
|
||||||
stale_when_importmap_changes
|
stale_when_importmap_changes
|
||||||
|
|
||||||
|
# CSRF protection
|
||||||
|
protect_from_forgery with: :exception
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -120,11 +120,11 @@ module Authentication
|
|||||||
# Generate a secure random token
|
# Generate a secure random token
|
||||||
token = SecureRandom.urlsafe_base64(32)
|
token = SecureRandom.urlsafe_base64(32)
|
||||||
|
|
||||||
# Store it with an expiry of 30 seconds
|
# Store it with an expiry of 60 seconds
|
||||||
Rails.cache.write(
|
Rails.cache.write(
|
||||||
"forward_auth_token:#{token}",
|
"forward_auth_token:#{token}",
|
||||||
session_obj.id,
|
session_obj.id,
|
||||||
expires_in: 30.seconds
|
expires_in: 60.seconds
|
||||||
)
|
)
|
||||||
|
|
||||||
# Set the token as a query parameter on the redirect URL
|
# Set the token as a query parameter on the redirect URL
|
||||||
@@ -134,13 +134,16 @@ module Authentication
|
|||||||
original_url = controller_session[:return_to_after_authenticating]
|
original_url = controller_session[:return_to_after_authenticating]
|
||||||
uri = URI.parse(original_url)
|
uri = URI.parse(original_url)
|
||||||
|
|
||||||
# Add token as query parameter
|
# Skip adding fa_token for OAuth URLs (OAuth flow should not have forward auth tokens)
|
||||||
query_params = URI.decode_www_form(uri.query || "").to_h
|
unless uri.path&.start_with?("/oauth/")
|
||||||
query_params['fa_token'] = token
|
# Add token as query parameter
|
||||||
uri.query = URI.encode_www_form(query_params)
|
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
|
# Update the session with the tokenized URL
|
||||||
controller_session[:return_to_after_authenticating] = uri.to_s
|
controller_session[:return_to_after_authenticating] = uri.to_s
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
class OidcController < ApplicationController
|
class OidcController < ApplicationController
|
||||||
# Discovery and JWKS endpoints are public
|
# Discovery and JWKS endpoints are public
|
||||||
allow_unauthenticated_access only: [:discovery, :jwks, :token, :userinfo, :logout]
|
allow_unauthenticated_access only: [:discovery, :jwks, :token, :revoke, :userinfo, :logout]
|
||||||
skip_before_action :verify_authenticity_token, only: [:token, :logout]
|
skip_before_action :verify_authenticity_token, only: [:token, :revoke, :logout]
|
||||||
|
|
||||||
# GET /.well-known/openid-configuration
|
# GET /.well-known/openid-configuration
|
||||||
def discovery
|
def discovery
|
||||||
@@ -11,15 +11,19 @@ class OidcController < ApplicationController
|
|||||||
issuer: base_url,
|
issuer: base_url,
|
||||||
authorization_endpoint: "#{base_url}/oauth/authorize",
|
authorization_endpoint: "#{base_url}/oauth/authorize",
|
||||||
token_endpoint: "#{base_url}/oauth/token",
|
token_endpoint: "#{base_url}/oauth/token",
|
||||||
|
revocation_endpoint: "#{base_url}/oauth/revoke",
|
||||||
userinfo_endpoint: "#{base_url}/oauth/userinfo",
|
userinfo_endpoint: "#{base_url}/oauth/userinfo",
|
||||||
jwks_uri: "#{base_url}/.well-known/jwks.json",
|
jwks_uri: "#{base_url}/.well-known/jwks.json",
|
||||||
end_session_endpoint: "#{base_url}/logout",
|
end_session_endpoint: "#{base_url}/logout",
|
||||||
response_types_supported: ["code"],
|
response_types_supported: ["code"],
|
||||||
|
response_modes_supported: ["query"],
|
||||||
|
grant_types_supported: ["authorization_code", "refresh_token"],
|
||||||
subject_types_supported: ["public"],
|
subject_types_supported: ["public"],
|
||||||
id_token_signing_alg_values_supported: ["RS256"],
|
id_token_signing_alg_values_supported: ["RS256"],
|
||||||
scopes_supported: ["openid", "profile", "email", "groups"],
|
scopes_supported: ["openid", "profile", "email", "groups"],
|
||||||
token_endpoint_auth_methods_supported: ["client_secret_post", "client_secret_basic"],
|
token_endpoint_auth_methods_supported: ["client_secret_post", "client_secret_basic"],
|
||||||
claims_supported: ["sub", "email", "email_verified", "name", "preferred_username", "groups", "admin"]
|
claims_supported: ["sub", "email", "email_verified", "name", "preferred_username", "groups", "admin"],
|
||||||
|
code_challenge_methods_supported: ["plain", "S256"]
|
||||||
}
|
}
|
||||||
|
|
||||||
render json: config
|
render json: config
|
||||||
@@ -32,30 +36,71 @@ class OidcController < ApplicationController
|
|||||||
|
|
||||||
# GET /oauth/authorize
|
# GET /oauth/authorize
|
||||||
def authorize
|
def authorize
|
||||||
# Get parameters
|
# Get parameters (ignore forward auth tokens and other unknown params)
|
||||||
client_id = params[:client_id]
|
client_id = params[:client_id]
|
||||||
redirect_uri = params[:redirect_uri]
|
redirect_uri = params[:redirect_uri]
|
||||||
state = params[:state]
|
state = params[:state]
|
||||||
nonce = params[:nonce]
|
nonce = params[:nonce]
|
||||||
scope = params[:scope] || "openid"
|
scope = params[:scope] || "openid"
|
||||||
response_type = params[:response_type]
|
response_type = params[:response_type]
|
||||||
|
code_challenge = params[:code_challenge]
|
||||||
|
code_challenge_method = params[:code_challenge_method] || "plain"
|
||||||
|
|
||||||
# Validate required parameters
|
# Validate required parameters
|
||||||
unless client_id.present? && redirect_uri.present? && response_type == "code"
|
unless client_id.present? && redirect_uri.present? && response_type == "code"
|
||||||
render plain: "Invalid request: missing required parameters", status: :bad_request
|
error_details = []
|
||||||
|
error_details << "client_id is required" unless client_id.present?
|
||||||
|
error_details << "redirect_uri is required" unless redirect_uri.present?
|
||||||
|
error_details << "response_type must be 'code'" unless response_type == "code"
|
||||||
|
|
||||||
|
render plain: "Invalid request: #{error_details.join(', ')}", status: :bad_request
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Validate PKCE parameters if present
|
||||||
|
if code_challenge.present?
|
||||||
|
unless %w[plain S256].include?(code_challenge_method)
|
||||||
|
render plain: "Invalid code_challenge_method: must be 'plain' or 'S256'", status: :bad_request
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Validate code challenge format (base64url-encoded, 43-128 characters)
|
||||||
|
unless code_challenge.match?(/\A[A-Za-z0-9\-_]{43,128}\z/)
|
||||||
|
render plain: "Invalid code_challenge format: must be 43-128 characters of base64url encoding", status: :bad_request
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# Find the application
|
# Find the application
|
||||||
@application = Application.find_by(client_id: client_id, app_type: "oidc")
|
@application = Application.find_by(client_id: client_id, app_type: "oidc")
|
||||||
unless @application
|
unless @application
|
||||||
render plain: "Invalid client_id", status: :bad_request
|
# Log all OIDC applications for debugging
|
||||||
|
all_oidc_apps = Application.where(app_type: "oidc")
|
||||||
|
Rails.logger.error "OAuth: Invalid request - application not found for client_id: #{client_id}"
|
||||||
|
Rails.logger.error "OAuth: Available OIDC applications: #{all_oidc_apps.pluck(:id, :client_id, :name)}"
|
||||||
|
|
||||||
|
error_msg = if Rails.env.development?
|
||||||
|
"Invalid request: Application not found for client_id '#{client_id}'. Available OIDC applications: #{all_oidc_apps.pluck(:name, :client_id).map { |name, id| "#{name} (#{id})" }.join(', ')}"
|
||||||
|
else
|
||||||
|
"Invalid request: Application not found"
|
||||||
|
end
|
||||||
|
|
||||||
|
render plain: error_msg, status: :bad_request
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Validate redirect URI
|
# Validate redirect URI
|
||||||
unless @application.parsed_redirect_uris.include?(redirect_uri)
|
unless @application.parsed_redirect_uris.include?(redirect_uri)
|
||||||
render plain: "Invalid redirect_uri", status: :bad_request
|
Rails.logger.error "OAuth: Invalid request - redirect URI mismatch. Expected: #{@application.parsed_redirect_uris}, Got: #{redirect_uri}"
|
||||||
|
|
||||||
|
# For development, show detailed error
|
||||||
|
error_msg = if Rails.env.development?
|
||||||
|
"Invalid request: Redirect URI mismatch. Application is configured for: #{@application.parsed_redirect_uris.join(', ')}, but received: #{redirect_uri}"
|
||||||
|
else
|
||||||
|
"Invalid request: Redirect URI not registered for this application"
|
||||||
|
end
|
||||||
|
|
||||||
|
render plain: error_msg, status: :bad_request
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -67,7 +112,9 @@ class OidcController < ApplicationController
|
|||||||
redirect_uri: redirect_uri,
|
redirect_uri: redirect_uri,
|
||||||
state: state,
|
state: state,
|
||||||
nonce: nonce,
|
nonce: nonce,
|
||||||
scope: scope
|
scope: scope,
|
||||||
|
code_challenge: code_challenge,
|
||||||
|
code_challenge_method: code_challenge_method
|
||||||
}
|
}
|
||||||
redirect_to signin_path, alert: "Please sign in to continue"
|
redirect_to signin_path, alert: "Please sign in to continue"
|
||||||
return
|
return
|
||||||
@@ -96,6 +143,8 @@ class OidcController < ApplicationController
|
|||||||
redirect_uri: redirect_uri,
|
redirect_uri: redirect_uri,
|
||||||
scope: scope,
|
scope: scope,
|
||||||
nonce: nonce,
|
nonce: nonce,
|
||||||
|
code_challenge: code_challenge,
|
||||||
|
code_challenge_method: code_challenge_method,
|
||||||
expires_at: 10.minutes.from_now
|
expires_at: 10.minutes.from_now
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -112,12 +161,34 @@ class OidcController < ApplicationController
|
|||||||
redirect_uri: redirect_uri,
|
redirect_uri: redirect_uri,
|
||||||
state: state,
|
state: state,
|
||||||
nonce: nonce,
|
nonce: nonce,
|
||||||
scope: scope
|
scope: scope,
|
||||||
|
code_challenge: code_challenge,
|
||||||
|
code_challenge_method: code_challenge_method
|
||||||
}
|
}
|
||||||
|
|
||||||
# Render consent page
|
# Render consent page with dynamic CSP for OAuth redirect
|
||||||
@redirect_uri = redirect_uri
|
@redirect_uri = redirect_uri
|
||||||
@scopes = requested_scopes
|
@scopes = requested_scopes
|
||||||
|
|
||||||
|
# Add the redirect URI to CSP form-action for this specific request
|
||||||
|
# This allows the OAuth redirect to work while maintaining security
|
||||||
|
# CSP must allow the OAuth client's redirect_uri as a form submission target
|
||||||
|
if redirect_uri.present?
|
||||||
|
begin
|
||||||
|
redirect_host = URI.parse(redirect_uri).host
|
||||||
|
csp = request.content_security_policy
|
||||||
|
if csp && redirect_host
|
||||||
|
# Only modify if form_action is available and mutable
|
||||||
|
if csp.respond_to?(:form_action) && csp.form_action.respond_to?(:<<)
|
||||||
|
csp.form_action << "https://#{redirect_host}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
rescue => e
|
||||||
|
# Log CSP modification errors but don't fail the request
|
||||||
|
Rails.logger.warn "OAuth: Could not modify CSP for redirect_uri #{redirect_uri}: #{e.message}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
render :consent
|
render :consent
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -165,6 +236,8 @@ class OidcController < ApplicationController
|
|||||||
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_method: oauth_params['code_challenge_method'],
|
||||||
expires_at: 10.minutes.from_now
|
expires_at: 10.minutes.from_now
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -182,10 +255,17 @@ class OidcController < ApplicationController
|
|||||||
def token
|
def token
|
||||||
grant_type = params[:grant_type]
|
grant_type = params[:grant_type]
|
||||||
|
|
||||||
unless grant_type == "authorization_code"
|
case grant_type
|
||||||
|
when "authorization_code"
|
||||||
|
handle_authorization_code_grant
|
||||||
|
when "refresh_token"
|
||||||
|
handle_refresh_token_grant
|
||||||
|
else
|
||||||
render json: { error: "unsupported_grant_type" }, status: :bad_request
|
render json: { error: "unsupported_grant_type" }, status: :bad_request
|
||||||
return
|
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_authorization_code_grant
|
||||||
|
|
||||||
# Get client credentials from Authorization header or params
|
# Get client credentials from Authorization header or params
|
||||||
client_id, client_secret = extract_client_credentials
|
client_id, client_secret = extract_client_credentials
|
||||||
@@ -205,11 +285,11 @@ class OidcController < ApplicationController
|
|||||||
# Get the authorization code
|
# Get the authorization code
|
||||||
code = params[:code]
|
code = params[:code]
|
||||||
redirect_uri = params[:redirect_uri]
|
redirect_uri = params[:redirect_uri]
|
||||||
|
code_verifier = params[:code_verifier]
|
||||||
|
|
||||||
auth_code = OidcAuthorizationCode.find_by(
|
auth_code = OidcAuthorizationCode.find_by(
|
||||||
application: application,
|
application: application,
|
||||||
code: code,
|
code: code
|
||||||
used: false
|
|
||||||
)
|
)
|
||||||
|
|
||||||
unless auth_code
|
unless auth_code
|
||||||
@@ -217,45 +297,180 @@ class OidcController < ApplicationController
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Check if code is expired
|
# Use a transaction with pessimistic locking to prevent code reuse
|
||||||
if auth_code.expires_at < Time.current
|
begin
|
||||||
render json: { error: "invalid_grant", error_description: "Authorization code expired" }, status: :bad_request
|
OidcAuthorizationCode.transaction do
|
||||||
|
# Lock the record to prevent concurrent access
|
||||||
|
auth_code.lock!
|
||||||
|
|
||||||
|
# Check if code has already been used (CRITICAL: check AFTER locking)
|
||||||
|
if auth_code.used?
|
||||||
|
# Per OAuth 2.0 spec, if an auth code is reused, revoke all tokens issued from it
|
||||||
|
Rails.logger.warn "OAuth Security: Authorization code reuse detected for code #{auth_code.id}"
|
||||||
|
|
||||||
|
# Revoke all access tokens issued from this authorization code
|
||||||
|
OidcAccessToken.where(
|
||||||
|
application: application,
|
||||||
|
user: auth_code.user,
|
||||||
|
created_at: auth_code.created_at..Time.current
|
||||||
|
).update_all(expires_at: Time.current)
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
error: "invalid_grant",
|
||||||
|
error_description: "Authorization code has already been used"
|
||||||
|
}, status: :bad_request
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if code is expired
|
||||||
|
if auth_code.expires_at < Time.current
|
||||||
|
render json: { error: "invalid_grant", error_description: "Authorization code expired" }, status: :bad_request
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Validate redirect URI matches
|
||||||
|
unless auth_code.redirect_uri == redirect_uri
|
||||||
|
render json: { error: "invalid_grant", error_description: "Redirect URI mismatch" }, status: :bad_request
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Validate PKCE if code challenge is present
|
||||||
|
pkce_result = validate_pkce(auth_code, code_verifier)
|
||||||
|
unless pkce_result[:valid]
|
||||||
|
render json: {
|
||||||
|
error: pkce_result[:error],
|
||||||
|
error_description: pkce_result[:error_description]
|
||||||
|
}, status: pkce_result[:status]
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Mark code as used BEFORE generating tokens (prevents reuse)
|
||||||
|
auth_code.update!(used: true)
|
||||||
|
|
||||||
|
# Get the user
|
||||||
|
user = auth_code.user
|
||||||
|
|
||||||
|
# Generate access token record (opaque token with BCrypt hashing)
|
||||||
|
access_token_record = OidcAccessToken.create!(
|
||||||
|
application: application,
|
||||||
|
user: user,
|
||||||
|
scope: auth_code.scope
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate refresh token (opaque, with hashing)
|
||||||
|
refresh_token_record = OidcRefreshToken.create!(
|
||||||
|
application: application,
|
||||||
|
user: user,
|
||||||
|
oidc_access_token: access_token_record,
|
||||||
|
scope: auth_code.scope
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate ID token (JWT)
|
||||||
|
id_token = OidcJwtService.generate_id_token(user, application, nonce: auth_code.nonce)
|
||||||
|
|
||||||
|
# Return tokens
|
||||||
|
render json: {
|
||||||
|
access_token: access_token_record.plaintext_token, # Opaque token
|
||||||
|
token_type: "Bearer",
|
||||||
|
expires_in: application.access_token_ttl || 3600,
|
||||||
|
id_token: id_token, # JWT
|
||||||
|
refresh_token: refresh_token_record.token, # Opaque token
|
||||||
|
scope: auth_code.scope
|
||||||
|
}
|
||||||
|
end
|
||||||
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
render json: { error: "invalid_grant" }, status: :bad_request
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_refresh_token_grant
|
||||||
|
# Get client credentials from Authorization header or params
|
||||||
|
client_id, client_secret = extract_client_credentials
|
||||||
|
|
||||||
|
unless client_id && client_secret
|
||||||
|
render json: { error: "invalid_client" }, status: :unauthorized
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Validate redirect URI matches
|
# Find and validate the application
|
||||||
unless auth_code.redirect_uri == redirect_uri
|
application = Application.find_by(client_id: client_id)
|
||||||
render json: { error: "invalid_grant", error_description: "Redirect URI mismatch" }, status: :bad_request
|
unless application && application.authenticate_client_secret(client_secret)
|
||||||
|
render json: { error: "invalid_client" }, status: :unauthorized
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Mark code as used
|
# Get the refresh token
|
||||||
auth_code.update!(used: true)
|
refresh_token = params[:refresh_token]
|
||||||
|
unless refresh_token.present?
|
||||||
|
render json: { error: "invalid_request", error_description: "refresh_token is required" }, status: :bad_request
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Find the refresh token record
|
||||||
|
# Note: This is inefficient with BCrypt hashing, but necessary for security
|
||||||
|
# In production, consider adding a token prefix for faster lookup
|
||||||
|
refresh_token_record = OidcRefreshToken.where(application: application).find do |rt|
|
||||||
|
rt.token_matches?(refresh_token)
|
||||||
|
end
|
||||||
|
|
||||||
|
unless refresh_token_record
|
||||||
|
render json: { error: "invalid_grant", error_description: "Invalid refresh token" }, status: :bad_request
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if refresh token is expired
|
||||||
|
if refresh_token_record.expired?
|
||||||
|
render json: { error: "invalid_grant", error_description: "Refresh token expired" }, status: :bad_request
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if refresh token is revoked
|
||||||
|
if refresh_token_record.revoked?
|
||||||
|
# If a revoked refresh token is used, it's a security issue
|
||||||
|
# Revoke all tokens in the family (token rotation attack detection)
|
||||||
|
Rails.logger.warn "OAuth Security: Revoked refresh token reuse detected for token family #{refresh_token_record.token_family_id}"
|
||||||
|
refresh_token_record.revoke_family!
|
||||||
|
|
||||||
|
render json: { error: "invalid_grant", error_description: "Refresh token has been revoked" }, status: :bad_request
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
# Get the user
|
# Get the user
|
||||||
user = auth_code.user
|
user = refresh_token_record.user
|
||||||
|
|
||||||
# Generate access token
|
# Revoke the old refresh token (token rotation)
|
||||||
access_token = SecureRandom.urlsafe_base64(32)
|
refresh_token_record.revoke!
|
||||||
OidcAccessToken.create!(
|
|
||||||
|
# Generate new access token record (opaque token with BCrypt hashing)
|
||||||
|
new_access_token = OidcAccessToken.create!(
|
||||||
application: application,
|
application: application,
|
||||||
user: user,
|
user: user,
|
||||||
token: access_token,
|
scope: refresh_token_record.scope
|
||||||
scope: auth_code.scope,
|
|
||||||
expires_at: 1.hour.from_now
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Generate ID token
|
# Generate new refresh token (token rotation)
|
||||||
id_token = OidcJwtService.generate_id_token(user, application, nonce: auth_code.nonce)
|
new_refresh_token = OidcRefreshToken.create!(
|
||||||
|
application: application,
|
||||||
|
user: user,
|
||||||
|
oidc_access_token: new_access_token,
|
||||||
|
scope: refresh_token_record.scope,
|
||||||
|
token_family_id: refresh_token_record.token_family_id # Keep same family for rotation tracking
|
||||||
|
)
|
||||||
|
|
||||||
# Return tokens
|
# Generate new ID token (JWT, no nonce for refresh grants)
|
||||||
|
id_token = OidcJwtService.generate_id_token(user, application)
|
||||||
|
|
||||||
|
# Return new tokens
|
||||||
render json: {
|
render json: {
|
||||||
access_token: access_token,
|
access_token: new_access_token.plaintext_token, # Opaque token
|
||||||
token_type: "Bearer",
|
token_type: "Bearer",
|
||||||
expires_in: 3600,
|
expires_in: application.access_token_ttl || 3600,
|
||||||
id_token: id_token,
|
id_token: id_token, # JWT
|
||||||
scope: auth_code.scope
|
refresh_token: new_refresh_token.token, # Opaque token
|
||||||
|
scope: refresh_token_record.scope
|
||||||
}
|
}
|
||||||
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
render json: { error: "invalid_grant" }, status: :bad_request
|
||||||
end
|
end
|
||||||
|
|
||||||
# GET /oauth/userinfo
|
# GET /oauth/userinfo
|
||||||
@@ -267,24 +482,22 @@ class OidcController < ApplicationController
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
access_token = auth_header.sub("Bearer ", "")
|
token = auth_header.sub("Bearer ", "")
|
||||||
|
|
||||||
# Find the access token
|
# Find and validate access token (opaque token with BCrypt hashing)
|
||||||
token_record = OidcAccessToken.find_by(token: access_token)
|
access_token = OidcAccessToken.find_by_token(token)
|
||||||
unless token_record
|
unless access_token&.active?
|
||||||
head :unauthorized
|
head :unauthorized
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Check if token is expired
|
# Get the user (with fresh data from database)
|
||||||
if token_record.expires_at < Time.current
|
user = access_token.user
|
||||||
|
unless user
|
||||||
head :unauthorized
|
head :unauthorized
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Get the user
|
|
||||||
user = token_record.user
|
|
||||||
|
|
||||||
# Return user claims
|
# Return user claims
|
||||||
claims = {
|
claims = {
|
||||||
sub: user.id.to_s,
|
sub: user.id.to_s,
|
||||||
@@ -313,6 +526,73 @@ class OidcController < ApplicationController
|
|||||||
render json: claims
|
render json: claims
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# POST /oauth/revoke
|
||||||
|
# RFC 7009 - Token Revocation
|
||||||
|
def revoke
|
||||||
|
# Get client credentials
|
||||||
|
client_id, client_secret = extract_client_credentials
|
||||||
|
|
||||||
|
unless client_id && client_secret
|
||||||
|
# RFC 7009 says we should return 200 OK even for invalid client
|
||||||
|
# But log the attempt for security monitoring
|
||||||
|
Rails.logger.warn "OAuth: Token revocation attempted with invalid client credentials"
|
||||||
|
head :ok
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Find and validate the application
|
||||||
|
application = Application.find_by(client_id: client_id)
|
||||||
|
unless application && application.authenticate_client_secret(client_secret)
|
||||||
|
Rails.logger.warn "OAuth: Token revocation attempted for invalid application: #{client_id}"
|
||||||
|
head :ok
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Get the token to revoke
|
||||||
|
token = params[:token]
|
||||||
|
token_type_hint = params[:token_type_hint] # Optional hint: "access_token" or "refresh_token"
|
||||||
|
|
||||||
|
unless token.present?
|
||||||
|
# RFC 7009: Missing token parameter is an error
|
||||||
|
render json: { error: "invalid_request", error_description: "token parameter is required" }, status: :bad_request
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Try to find and revoke the token
|
||||||
|
# Check token type hint first for efficiency, otherwise try both
|
||||||
|
revoked = false
|
||||||
|
|
||||||
|
if token_type_hint == "refresh_token" || token_type_hint.nil?
|
||||||
|
# Try to find as refresh token
|
||||||
|
refresh_token_record = OidcRefreshToken.where(application: application).find do |rt|
|
||||||
|
rt.token_matches?(token)
|
||||||
|
end
|
||||||
|
|
||||||
|
if refresh_token_record
|
||||||
|
refresh_token_record.revoke!
|
||||||
|
Rails.logger.info "OAuth: Refresh token revoked for application #{application.name}"
|
||||||
|
revoked = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if !revoked && (token_type_hint == "access_token" || token_type_hint.nil?)
|
||||||
|
# Try to find as access token
|
||||||
|
access_token_record = OidcAccessToken.where(application: application).find do |at|
|
||||||
|
at.token_matches?(token)
|
||||||
|
end
|
||||||
|
|
||||||
|
if access_token_record
|
||||||
|
access_token_record.revoke!
|
||||||
|
Rails.logger.info "OAuth: Access token revoked for application #{application.name}"
|
||||||
|
revoked = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# RFC 7009: Always return 200 OK, even if token was not found
|
||||||
|
# This prevents token scanning attacks
|
||||||
|
head :ok
|
||||||
|
end
|
||||||
|
|
||||||
# GET /logout
|
# GET /logout
|
||||||
def logout
|
def logout
|
||||||
# OpenID Connect RP-Initiated Logout
|
# OpenID Connect RP-Initiated Logout
|
||||||
@@ -342,6 +622,58 @@ class OidcController < ApplicationController
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def validate_pkce(auth_code, code_verifier)
|
||||||
|
# Skip PKCE validation if no code challenge was stored (legacy clients)
|
||||||
|
return { valid: true } unless auth_code.code_challenge.present?
|
||||||
|
|
||||||
|
# PKCE is required but no verifier provided
|
||||||
|
unless code_verifier.present?
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: "invalid_request",
|
||||||
|
error_description: "code_verifier is required when code_challenge was provided",
|
||||||
|
status: :bad_request
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Validate code verifier format (base64url-encoded, 43-128 characters)
|
||||||
|
unless code_verifier.match?(/\A[A-Za-z0-9\-_]{43,128}\z/)
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: "invalid_request",
|
||||||
|
error_description: "Invalid code_verifier format. Must be 43-128 characters of base64url encoding",
|
||||||
|
status: :bad_request
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Recreate code challenge based on method
|
||||||
|
expected_challenge = case auth_code.code_challenge_method
|
||||||
|
when "plain"
|
||||||
|
code_verifier
|
||||||
|
when "S256"
|
||||||
|
Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false)
|
||||||
|
else
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: "server_error",
|
||||||
|
error_description: "Unsupported code challenge method",
|
||||||
|
status: :internal_server_error
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Validate the code challenge
|
||||||
|
unless auth_code.code_challenge == expected_challenge
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: "invalid_grant",
|
||||||
|
error_description: "Invalid code verifier",
|
||||||
|
status: :bad_request
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
{ valid: true }
|
||||||
|
end
|
||||||
|
|
||||||
def extract_client_credentials
|
def extract_client_credentials
|
||||||
# Try Authorization header first (Basic auth)
|
# Try Authorization header first (Basic auth)
|
||||||
if request.headers["Authorization"]&.start_with?("Basic ")
|
if request.headers["Authorization"]&.start_with?("Basic ")
|
||||||
|
|||||||
@@ -24,9 +24,12 @@ class TotpController < ApplicationController
|
|||||||
if totp.verify(code, drift_behind: 30, drift_ahead: 30)
|
if totp.verify(code, drift_behind: 30, drift_ahead: 30)
|
||||||
# Save the secret and generate backup codes
|
# Save the secret and generate backup codes
|
||||||
@user.totp_secret = totp_secret
|
@user.totp_secret = totp_secret
|
||||||
@user.backup_codes = generate_backup_codes
|
plain_codes = @user.send(:generate_backup_codes) # Use private method from User model
|
||||||
@user.save!
|
@user.save!
|
||||||
|
|
||||||
|
# Store plain codes temporarily in session for display after redirect
|
||||||
|
session[:temp_backup_codes] = plain_codes
|
||||||
|
|
||||||
# Redirect to backup codes page with success message
|
# Redirect to backup codes page with success message
|
||||||
redirect_to backup_codes_totp_path, notice: "Two-factor authentication has been enabled successfully! Save these backup codes now."
|
redirect_to backup_codes_totp_path, notice: "Two-factor authentication has been enabled successfully! Save these backup codes now."
|
||||||
else
|
else
|
||||||
@@ -36,8 +39,15 @@ class TotpController < ApplicationController
|
|||||||
|
|
||||||
# GET /totp/backup_codes - Show backup codes (requires password)
|
# GET /totp/backup_codes - Show backup codes (requires password)
|
||||||
def backup_codes
|
def backup_codes
|
||||||
# This will be shown after password verification
|
# Check if we have temporary codes from TOTP setup
|
||||||
@backup_codes = @user.parsed_backup_codes
|
if session[:temp_backup_codes].present?
|
||||||
|
@backup_codes = session[:temp_backup_codes]
|
||||||
|
session.delete(:temp_backup_codes) # Clear after use
|
||||||
|
else
|
||||||
|
# This will be shown after password verification for existing users
|
||||||
|
# Since we can't display BCrypt hashes, redirect to regenerate
|
||||||
|
redirect_to regenerate_backup_codes_totp_path
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# POST /totp/verify_password - Verify password before showing backup codes
|
# POST /totp/verify_password - Verify password before showing backup codes
|
||||||
@@ -49,6 +59,28 @@ class TotpController < ApplicationController
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# GET /totp/regenerate_backup_codes - Regenerate backup codes (requires password)
|
||||||
|
def regenerate_backup_codes
|
||||||
|
# This will be shown after password verification
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST /totp/regenerate_backup_codes - Actually regenerate backup codes
|
||||||
|
def create_new_backup_codes
|
||||||
|
unless @user.authenticate(params[:password])
|
||||||
|
redirect_to regenerate_backup_codes_totp_path, alert: "Incorrect password."
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Generate new backup codes and store BCrypt hashes
|
||||||
|
plain_codes = @user.send(:generate_backup_codes)
|
||||||
|
@user.save!
|
||||||
|
|
||||||
|
# Store plain codes temporarily in session for display
|
||||||
|
session[:temp_backup_codes] = plain_codes
|
||||||
|
|
||||||
|
redirect_to backup_codes_totp_path, notice: "New backup codes have been generated. Save them now!"
|
||||||
|
end
|
||||||
|
|
||||||
# DELETE /totp - Disable TOTP (requires password)
|
# DELETE /totp - Disable TOTP (requires password)
|
||||||
def destroy
|
def destroy
|
||||||
unless @user.authenticate(params[:password])
|
unless @user.authenticate(params[:password])
|
||||||
@@ -77,8 +109,4 @@ class TotpController < ApplicationController
|
|||||||
redirect_to profile_path, alert: "Two-factor authentication is not enabled."
|
redirect_to profile_path, alert: "Two-factor authentication is not enabled."
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def generate_backup_codes
|
|
||||||
Array.new(10) { SecureRandom.alphanumeric(8).upcase }.to_json
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -19,4 +19,14 @@ module ApplicationHelper
|
|||||||
:smtp
|
:smtp
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def border_class_for(type)
|
||||||
|
case type.to_s
|
||||||
|
when 'notice' then 'border-green-200'
|
||||||
|
when 'alert', 'error' then 'border-red-200'
|
||||||
|
when 'warning' then 'border-yellow-200'
|
||||||
|
when 'info' then 'border-blue-200'
|
||||||
|
else 'border-gray-200'
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
24
app/javascript/controllers/application_form_controller.js
Normal file
24
app/javascript/controllers/application_form_controller.js
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = ["appTypeSelect", "oidcFields", "forwardAuthFields"]
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
this.updateFieldVisibility()
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFieldVisibility() {
|
||||||
|
const appType = this.appTypeSelectTarget.value
|
||||||
|
|
||||||
|
if (appType === 'oidc') {
|
||||||
|
this.oidcFieldsTarget.classList.remove('hidden')
|
||||||
|
this.forwardAuthFieldsTarget.classList.add('hidden')
|
||||||
|
} else if (appType === 'forward_auth') {
|
||||||
|
this.oidcFieldsTarget.classList.add('hidden')
|
||||||
|
this.forwardAuthFieldsTarget.classList.remove('hidden')
|
||||||
|
} else {
|
||||||
|
this.oidcFieldsTarget.classList.add('hidden')
|
||||||
|
this.forwardAuthFieldsTarget.classList.add('hidden')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
28
app/javascript/controllers/backup_codes_controller.js
Normal file
28
app/javascript/controllers/backup_codes_controller.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
static values = {
|
||||||
|
codes: Array
|
||||||
|
}
|
||||||
|
|
||||||
|
download() {
|
||||||
|
const content = "Clinch Backup Codes\n" +
|
||||||
|
"===================\n\n" +
|
||||||
|
this.codesValue.join("\n") +
|
||||||
|
"\n\nSave these codes in a secure location."
|
||||||
|
|
||||||
|
const blob = new Blob([content], { type: 'text/plain' })
|
||||||
|
const url = window.URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = 'clinch-backup-codes.txt'
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
document.body.removeChild(a)
|
||||||
|
window.URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
print() {
|
||||||
|
window.print()
|
||||||
|
}
|
||||||
|
}
|
||||||
85
app/javascript/controllers/flash_controller.js
Normal file
85
app/javascript/controllers/flash_controller.js
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages flash message display, auto-dismissal, and user interactions
|
||||||
|
* Supports different flash types with appropriate styling and behavior
|
||||||
|
*/
|
||||||
|
export default class extends Controller {
|
||||||
|
static values = {
|
||||||
|
autoDismiss: String, // "false" or delay in milliseconds
|
||||||
|
type: String
|
||||||
|
}
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
// Auto-dismiss if enabled
|
||||||
|
if (this.autoDismissValue && this.autoDismissValue !== "false") {
|
||||||
|
this.scheduleAutoDismiss()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Smooth entrance animation
|
||||||
|
this.element.classList.add('transition-all', 'duration-300', 'ease-out')
|
||||||
|
this.element.style.opacity = '0'
|
||||||
|
this.element.style.transform = 'translateY(-10px)'
|
||||||
|
|
||||||
|
// Animate in
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
this.element.style.opacity = '1'
|
||||||
|
this.element.style.transform = 'translateY(0)'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dismisses the flash message with smooth animation
|
||||||
|
*/
|
||||||
|
dismiss() {
|
||||||
|
// Add dismiss animation
|
||||||
|
this.element.classList.add('transition-all', 'duration-300', 'ease-in')
|
||||||
|
this.element.style.opacity = '0'
|
||||||
|
this.element.style.transform = 'translateY(-10px)'
|
||||||
|
|
||||||
|
// Remove from DOM after animation
|
||||||
|
setTimeout(() => {
|
||||||
|
this.element.remove()
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedules auto-dismissal based on the configured delay
|
||||||
|
*/
|
||||||
|
scheduleAutoDismiss() {
|
||||||
|
const delay = parseInt(this.autoDismissValue)
|
||||||
|
if (delay > 0) {
|
||||||
|
setTimeout(() => {
|
||||||
|
this.dismiss()
|
||||||
|
}, delay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pause auto-dismissal on hover (for user reading)
|
||||||
|
*/
|
||||||
|
mouseEnter() {
|
||||||
|
if (this.autoDismissTimer) {
|
||||||
|
clearTimeout(this.autoDismissTimer)
|
||||||
|
this.autoDismissTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resume auto-dismissal when hover ends
|
||||||
|
*/
|
||||||
|
mouseLeave() {
|
||||||
|
if (this.autoDismissValue && this.autoDismissValue !== "false") {
|
||||||
|
this.scheduleAutoDismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle keyboard interactions
|
||||||
|
*/
|
||||||
|
keydown(event) {
|
||||||
|
if (event.key === 'Escape' || event.key === 'Enter') {
|
||||||
|
this.dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
89
app/javascript/controllers/form_errors_controller.js
Normal file
89
app/javascript/controllers/form_errors_controller.js
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages form error display and dismissal
|
||||||
|
* Provides consistent error handling across all forms
|
||||||
|
*/
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = ["container"]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dismisses the error container with a smooth fade-out animation
|
||||||
|
*/
|
||||||
|
dismiss() {
|
||||||
|
if (!this.hasContainerTarget) return
|
||||||
|
|
||||||
|
// Add transition classes
|
||||||
|
this.containerTarget.classList.add('transition-all', 'duration-300', 'opacity-0', 'transform', 'scale-95')
|
||||||
|
|
||||||
|
// Remove from DOM after animation completes
|
||||||
|
setTimeout(() => {
|
||||||
|
this.containerTarget.remove()
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows server-side validation errors after form submission
|
||||||
|
* Auto-focuses the first error field for better accessibility
|
||||||
|
*/
|
||||||
|
connect() {
|
||||||
|
// Auto-focus first error field if errors exist
|
||||||
|
this.focusFirstErrorField()
|
||||||
|
|
||||||
|
// Scroll to errors if needed
|
||||||
|
this.scrollToErrors()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Focuses the first field with validation errors
|
||||||
|
*/
|
||||||
|
focusFirstErrorField() {
|
||||||
|
if (!this.hasContainerTarget) return
|
||||||
|
|
||||||
|
// Find first form field with errors (look for error classes or aria-invalid)
|
||||||
|
const form = this.element.closest('form')
|
||||||
|
if (!form) return
|
||||||
|
|
||||||
|
const errorField = form.querySelector('[aria-invalid="true"], .border-red-500, .ring-red-500')
|
||||||
|
if (errorField) {
|
||||||
|
setTimeout(() => {
|
||||||
|
errorField.focus()
|
||||||
|
errorField.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scrolls error container into view if it's not visible
|
||||||
|
*/
|
||||||
|
scrollToErrors() {
|
||||||
|
if (!this.hasContainerTarget) return
|
||||||
|
|
||||||
|
const rect = this.containerTarget.getBoundingClientRect()
|
||||||
|
const isInViewport = rect.top >= 0 && rect.left >= 0 &&
|
||||||
|
rect.bottom <= window.innerHeight &&
|
||||||
|
rect.right <= window.innerWidth
|
||||||
|
|
||||||
|
if (!isInViewport) {
|
||||||
|
setTimeout(() => {
|
||||||
|
this.containerTarget.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'start',
|
||||||
|
inline: 'nearest'
|
||||||
|
})
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-dismisses success messages after a delay
|
||||||
|
* Can be called from other controllers
|
||||||
|
*/
|
||||||
|
autoDismiss(delay = 5000) {
|
||||||
|
if (!this.hasContainerTarget) return
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
this.dismiss()
|
||||||
|
}, delay)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { Controller } from "@hotwired/stimulus"
|
|
||||||
|
|
||||||
export default class extends Controller {
|
|
||||||
connect() {
|
|
||||||
this.element.textContent = "Hello World!"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
81
app/javascript/controllers/json_validator_controller.js
Normal file
81
app/javascript/controllers/json_validator_controller.js
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = ["textarea", "status"]
|
||||||
|
static classes = ["valid", "invalid", "validStatus", "invalidStatus"]
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
this.validate()
|
||||||
|
}
|
||||||
|
|
||||||
|
validate() {
|
||||||
|
const value = this.textareaTarget.value.trim()
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
this.clearStatus()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
JSON.parse(value)
|
||||||
|
this.showValid()
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
this.showInvalid(error.message)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
format() {
|
||||||
|
const value = this.textareaTarget.value.trim()
|
||||||
|
|
||||||
|
if (!value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value)
|
||||||
|
const formatted = JSON.stringify(parsed, null, 2)
|
||||||
|
this.textareaTarget.value = formatted
|
||||||
|
this.showValid()
|
||||||
|
} catch (error) {
|
||||||
|
this.showInvalid(error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clearStatus() {
|
||||||
|
this.textareaTarget.classList.remove(...this.invalidClasses)
|
||||||
|
this.textareaTarget.classList.remove(...this.validClasses)
|
||||||
|
if (this.hasStatusTarget) {
|
||||||
|
this.statusTarget.textContent = ""
|
||||||
|
this.statusTarget.classList.remove(...this.validStatusClasses, ...this.invalidStatusClasses)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showValid() {
|
||||||
|
this.textareaTarget.classList.remove(...this.invalidClasses)
|
||||||
|
this.textareaTarget.classList.add(...this.validClasses)
|
||||||
|
if (this.hasStatusTarget) {
|
||||||
|
this.statusTarget.textContent = "✓ Valid JSON"
|
||||||
|
this.statusTarget.classList.remove(...this.invalidStatusClasses)
|
||||||
|
this.statusTarget.classList.add(...this.validStatusClasses)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showInvalid(errorMessage) {
|
||||||
|
this.textareaTarget.classList.remove(...this.validClasses)
|
||||||
|
this.textareaTarget.classList.add(...this.invalidClasses)
|
||||||
|
if (this.hasStatusTarget) {
|
||||||
|
this.statusTarget.textContent = `✗ Invalid JSON: ${errorMessage}`
|
||||||
|
this.statusTarget.classList.remove(...this.validStatusClasses)
|
||||||
|
this.statusTarget.classList.add(...this.invalidStatusClasses)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
insertSample(event) {
|
||||||
|
event.preventDefault()
|
||||||
|
const sample = event.params.json || event.target.dataset.jsonSample
|
||||||
|
if (sample) {
|
||||||
|
this.textareaTarget.value = sample
|
||||||
|
this.format()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
48
app/javascript/controllers/mobile_sidebar_controller.js
Normal file
48
app/javascript/controllers/mobile_sidebar_controller.js
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { Controller } from "@hotwired/stimulus";
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = ["sidebarOverlay"];
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
// Initialize mobile sidebar functionality
|
||||||
|
// Add escape key listener to close sidebar
|
||||||
|
this.boundHandleEscape = this.handleEscape.bind(this);
|
||||||
|
document.addEventListener('keydown', this.boundHandleEscape);
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
// Clean up event listeners
|
||||||
|
document.removeEventListener('keydown', this.boundHandleEscape);
|
||||||
|
}
|
||||||
|
|
||||||
|
openSidebar() {
|
||||||
|
if (this.hasSidebarOverlayTarget) {
|
||||||
|
this.sidebarOverlayTarget.classList.remove('hidden');
|
||||||
|
// Prevent body scroll when sidebar is open
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
closeSidebar() {
|
||||||
|
if (this.hasSidebarOverlayTarget) {
|
||||||
|
this.sidebarOverlayTarget.classList.add('hidden');
|
||||||
|
// Restore body scroll
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close sidebar when clicking on the overlay background
|
||||||
|
closeOnBackgroundClick(event) {
|
||||||
|
// Check if the click is on the overlay background (the semi-transparent layer)
|
||||||
|
if (event.target === this.sidebarOverlayTarget || event.target.classList.contains('bg-gray-900/80')) {
|
||||||
|
this.closeSidebar();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle escape key to close sidebar
|
||||||
|
handleEscape(event) {
|
||||||
|
if (event.key === 'Escape' && this.hasSidebarOverlayTarget && !this.sidebarOverlayTarget.classList.contains('hidden')) {
|
||||||
|
this.closeSidebar();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,7 +22,11 @@ export default class extends Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
hide() {
|
hide() {
|
||||||
if (this.hasDialogTarget) {
|
// Find the currently visible modal to hide it
|
||||||
|
const visibleModal = document.querySelector('[data-controller="modal"] .fixed.inset-0:not(.hidden)');
|
||||||
|
if (visibleModal) {
|
||||||
|
visibleModal.classList.add("hidden");
|
||||||
|
} else if (this.hasDialogTarget) {
|
||||||
this.dialogTarget.classList.add("hidden");
|
this.dialogTarget.classList.add("hidden");
|
||||||
} else {
|
} else {
|
||||||
this.element.classList.add("hidden");
|
this.element.classList.add("hidden");
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
import { Controller } from "@hotwired/stimulus"
|
|
||||||
|
|
||||||
export default class extends Controller {
|
|
||||||
static targets = ["userSelect", "assignLink", "editForm"]
|
|
||||||
|
|
||||||
connect() {
|
|
||||||
console.log("Role management controller connected")
|
|
||||||
}
|
|
||||||
|
|
||||||
assignRole(event) {
|
|
||||||
event.preventDefault()
|
|
||||||
|
|
||||||
const link = event.currentTarget
|
|
||||||
const roleId = link.dataset.roleId
|
|
||||||
const select = document.getElementById(`assign-user-${roleId}`)
|
|
||||||
|
|
||||||
if (!select.value) {
|
|
||||||
alert("Please select a user")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the href with the selected user ID
|
|
||||||
const originalHref = link.href
|
|
||||||
const newHref = originalHref.replace("PLACEHOLDER", select.value)
|
|
||||||
|
|
||||||
// Navigate to the updated URL
|
|
||||||
window.location.href = newHref
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleEdit(event) {
|
|
||||||
event.preventDefault()
|
|
||||||
|
|
||||||
const roleId = event.currentTarget.dataset.roleId
|
|
||||||
const editForm = document.getElementById(`edit-role-${roleId}`)
|
|
||||||
|
|
||||||
if (editForm) {
|
|
||||||
editForm.classList.toggle("hidden")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
hideEdit(event) {
|
|
||||||
event.preventDefault()
|
|
||||||
|
|
||||||
const roleId = event.currentTarget.dataset.roleId
|
|
||||||
const editForm = document.getElementById(`edit-role-${roleId}`)
|
|
||||||
|
|
||||||
if (editForm) {
|
|
||||||
editForm.classList.add("hidden")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
29
app/jobs/oidc_token_cleanup_job.rb
Normal file
29
app/jobs/oidc_token_cleanup_job.rb
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
class OidcTokenCleanupJob < ApplicationJob
|
||||||
|
queue_as :default
|
||||||
|
|
||||||
|
def perform
|
||||||
|
# Delete expired access tokens (keep revoked ones for audit trail)
|
||||||
|
expired_access_tokens = OidcAccessToken.where("expires_at < ?", 7.days.ago)
|
||||||
|
deleted_count = expired_access_tokens.delete_all
|
||||||
|
Rails.logger.info "OIDC Token Cleanup: Deleted #{deleted_count} expired access tokens"
|
||||||
|
|
||||||
|
# Delete expired refresh tokens (keep revoked ones for audit trail)
|
||||||
|
expired_refresh_tokens = OidcRefreshToken.where("expires_at < ?", 7.days.ago)
|
||||||
|
deleted_count = expired_refresh_tokens.delete_all
|
||||||
|
Rails.logger.info "OIDC Token Cleanup: Deleted #{deleted_count} expired refresh tokens"
|
||||||
|
|
||||||
|
# Delete old revoked tokens (after 30 days for audit trail)
|
||||||
|
old_revoked_access_tokens = OidcAccessToken.where("revoked_at < ?", 30.days.ago)
|
||||||
|
deleted_count = old_revoked_access_tokens.delete_all
|
||||||
|
Rails.logger.info "OIDC Token Cleanup: Deleted #{deleted_count} old revoked access tokens"
|
||||||
|
|
||||||
|
old_revoked_refresh_tokens = OidcRefreshToken.where("revoked_at < ?", 30.days.ago)
|
||||||
|
deleted_count = old_revoked_refresh_tokens.delete_all
|
||||||
|
Rails.logger.info "OIDC Token Cleanup: Deleted #{deleted_count} old revoked refresh tokens"
|
||||||
|
|
||||||
|
# Delete old used authorization codes (after 7 days)
|
||||||
|
old_auth_codes = OidcAuthorizationCode.where("created_at < ?", 7.days.ago)
|
||||||
|
deleted_count = old_auth_codes.delete_all
|
||||||
|
Rails.logger.info "OIDC Token Cleanup: Deleted #{deleted_count} old authorization codes"
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
class ApplicationMailer < ActionMailer::Base
|
class ApplicationMailer < ActionMailer::Base
|
||||||
default from: ENV.fetch('CLINCH_EMAIL_FROM', 'clinch@example.com')
|
default from: ENV.fetch('CLINCH_FROM_EMAIL', 'clinch@example.com')
|
||||||
layout "mailer"
|
layout "mailer"
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ class Application < ApplicationRecord
|
|||||||
has_many :allowed_groups, through: :application_groups, source: :group
|
has_many :allowed_groups, through: :application_groups, source: :group
|
||||||
has_many :oidc_authorization_codes, dependent: :destroy
|
has_many :oidc_authorization_codes, dependent: :destroy
|
||||||
has_many :oidc_access_tokens, dependent: :destroy
|
has_many :oidc_access_tokens, dependent: :destroy
|
||||||
|
has_many :oidc_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
|
||||||
@@ -13,12 +14,20 @@ class Application < ApplicationRecord
|
|||||||
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, if: :oidc?
|
validates :client_secret, presence: true, on: :create, if: -> { oidc? }
|
||||||
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::regexp(%w[http https]), allow_nil: true, message: "must be a valid URL" }
|
||||||
|
|
||||||
|
# Token TTL validations (for OIDC apps)
|
||||||
|
validates :access_token_ttl, numericality: { greater_than_or_equal_to: 300, less_than_or_equal_to: 86400 }, if: :oidc? # 5 min - 24 hours
|
||||||
|
validates :refresh_token_ttl, numericality: { greater_than_or_equal_to: 86400, less_than_or_equal_to: 7776000 }, if: :oidc? # 1 day - 90 days
|
||||||
|
validates :id_token_ttl, numericality: { greater_than_or_equal_to: 300, less_than_or_equal_to: 86400 }, if: :oidc? # 5 min - 24 hours
|
||||||
|
|
||||||
normalizes :slug, with: ->(slug) { slug.strip.downcase }
|
normalizes :slug, with: ->(slug) { slug.strip.downcase }
|
||||||
normalizes :domain_pattern, with: ->(pattern) { pattern&.strip&.downcase }
|
normalizes :domain_pattern, with: ->(pattern) {
|
||||||
|
normalized = pattern&.strip&.downcase
|
||||||
|
normalized.blank? ? nil : normalized
|
||||||
|
}
|
||||||
|
|
||||||
before_validation :generate_client_credentials, on: :create, if: :oidc?
|
before_validation :generate_client_credentials, on: :create, if: :oidc?
|
||||||
|
|
||||||
@@ -151,8 +160,44 @@ class Application < ApplicationRecord
|
|||||||
secret
|
secret
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Token TTL helper methods (for OIDC)
|
||||||
|
def access_token_expiry
|
||||||
|
(access_token_ttl || 3600).seconds.from_now
|
||||||
|
end
|
||||||
|
|
||||||
|
def refresh_token_expiry
|
||||||
|
(refresh_token_ttl || 2592000).seconds.from_now
|
||||||
|
end
|
||||||
|
|
||||||
|
def id_token_expiry_seconds
|
||||||
|
id_token_ttl || 3600
|
||||||
|
end
|
||||||
|
|
||||||
|
# Human-readable TTL for display
|
||||||
|
def access_token_ttl_human
|
||||||
|
duration_to_human(access_token_ttl || 3600)
|
||||||
|
end
|
||||||
|
|
||||||
|
def refresh_token_ttl_human
|
||||||
|
duration_to_human(refresh_token_ttl || 2592000)
|
||||||
|
end
|
||||||
|
|
||||||
|
def id_token_ttl_human
|
||||||
|
duration_to_human(id_token_ttl || 3600)
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def duration_to_human(seconds)
|
||||||
|
if seconds < 3600
|
||||||
|
"#{seconds / 60} minutes"
|
||||||
|
elsif seconds < 86400
|
||||||
|
"#{seconds / 3600} hours"
|
||||||
|
else
|
||||||
|
"#{seconds / 86400} days"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def generate_client_credentials
|
def generate_client_credentials
|
||||||
self.client_id ||= SecureRandom.urlsafe_base64(32)
|
self.client_id ||= SecureRandom.urlsafe_base64(32)
|
||||||
# Generate and hash the client secret
|
# Generate and hash the client secret
|
||||||
|
|||||||
@@ -1,34 +1,83 @@
|
|||||||
class OidcAccessToken < ApplicationRecord
|
class OidcAccessToken < ApplicationRecord
|
||||||
belongs_to :application
|
belongs_to :application
|
||||||
belongs_to :user
|
belongs_to :user
|
||||||
|
has_many :oidc_refresh_tokens, dependent: :destroy
|
||||||
|
|
||||||
before_validation :generate_token, on: :create
|
before_validation :generate_token, on: :create
|
||||||
before_validation :set_expiry, on: :create
|
before_validation :set_expiry, on: :create
|
||||||
|
|
||||||
validates :token, presence: true, uniqueness: true
|
validates :token, uniqueness: true, presence: true
|
||||||
|
|
||||||
scope :valid, -> { where("expires_at > ?", Time.current) }
|
scope :valid, -> { where("expires_at > ?", Time.current).where(revoked_at: nil) }
|
||||||
scope :expired, -> { where("expires_at <= ?", Time.current) }
|
scope :expired, -> { where("expires_at <= ?", Time.current) }
|
||||||
|
scope :revoked, -> { where.not(revoked_at: nil) }
|
||||||
|
scope :active, -> { valid }
|
||||||
|
|
||||||
|
attr_accessor :plaintext_token # Store plaintext temporarily for returning to client
|
||||||
|
|
||||||
def expired?
|
def expired?
|
||||||
expires_at <= Time.current
|
expires_at <= Time.current
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def revoked?
|
||||||
|
revoked_at.present?
|
||||||
|
end
|
||||||
|
|
||||||
def active?
|
def active?
|
||||||
!expired?
|
!expired? && !revoked?
|
||||||
end
|
end
|
||||||
|
|
||||||
def revoke!
|
def revoke!
|
||||||
update!(expires_at: Time.current)
|
update!(revoked_at: Time.current)
|
||||||
|
# Also revoke associated refresh tokens
|
||||||
|
oidc_refresh_tokens.each(&:revoke!)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if a plaintext token matches the hashed token
|
||||||
|
def token_matches?(plaintext_token)
|
||||||
|
return false if plaintext_token.blank?
|
||||||
|
|
||||||
|
# Use BCrypt to compare if token_digest exists
|
||||||
|
if token_digest.present?
|
||||||
|
BCrypt::Password.new(token_digest) == plaintext_token
|
||||||
|
# Fall back to direct comparison for backward compatibility
|
||||||
|
elsif token.present?
|
||||||
|
token == plaintext_token
|
||||||
|
else
|
||||||
|
false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Find by token (validates and checks if revoked)
|
||||||
|
def self.find_by_token(plaintext_token)
|
||||||
|
return nil if plaintext_token.blank?
|
||||||
|
|
||||||
|
# Find all non-revoked, non-expired tokens
|
||||||
|
valid.find_each do |access_token|
|
||||||
|
# Use BCrypt to compare (if token_digest exists) or direct comparison
|
||||||
|
if access_token.token_digest.present?
|
||||||
|
return access_token if BCrypt::Password.new(access_token.token_digest) == plaintext_token
|
||||||
|
elsif access_token.token == plaintext_token
|
||||||
|
return access_token
|
||||||
|
end
|
||||||
|
end
|
||||||
|
nil
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def generate_token
|
def generate_token
|
||||||
self.token ||= SecureRandom.urlsafe_base64(48)
|
return if token.present?
|
||||||
|
|
||||||
|
# Generate opaque access token
|
||||||
|
plaintext = SecureRandom.urlsafe_base64(48)
|
||||||
|
self.plaintext_token = plaintext # Store temporarily for returning to client
|
||||||
|
self.token_digest = BCrypt::Password.create(plaintext)
|
||||||
|
# Keep token column for backward compatibility during migration
|
||||||
|
self.token = plaintext
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_expiry
|
def set_expiry
|
||||||
self.expires_at ||= 1.hour.from_now
|
self.expires_at ||= application.access_token_expiry
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ class OidcAuthorizationCode < ApplicationRecord
|
|||||||
|
|
||||||
validates :code, presence: true, uniqueness: true
|
validates :code, presence: true, uniqueness: true
|
||||||
validates :redirect_uri, presence: true
|
validates :redirect_uri, presence: true
|
||||||
|
validates :code_challenge_method, inclusion: { in: %w[plain S256], allow_nil: true }
|
||||||
|
validate :validate_code_challenge_format, if: -> { code_challenge.present? }
|
||||||
|
|
||||||
scope :valid, -> { where(used: false).where("expires_at > ?", Time.current) }
|
scope :valid, -> { where(used: false).where("expires_at > ?", Time.current) }
|
||||||
scope :expired, -> { where("expires_at <= ?", Time.current) }
|
scope :expired, -> { where("expires_at <= ?", Time.current) }
|
||||||
@@ -23,6 +25,10 @@ class OidcAuthorizationCode < ApplicationRecord
|
|||||||
update!(used: true)
|
update!(used: true)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def uses_pkce?
|
||||||
|
code_challenge.present?
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def generate_code
|
def generate_code
|
||||||
@@ -32,4 +38,11 @@ class OidcAuthorizationCode < ApplicationRecord
|
|||||||
def set_expiry
|
def set_expiry
|
||||||
self.expires_at ||= 10.minutes.from_now
|
self.expires_at ||= 10.minutes.from_now
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def validate_code_challenge_format
|
||||||
|
# PKCE code challenge should be base64url-encoded, 43-128 characters
|
||||||
|
unless code_challenge.match?(/\A[A-Za-z0-9\-_]{43,128}\z/)
|
||||||
|
errors.add(:code_challenge, "must be 43-128 characters of base64url encoding")
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
87
app/models/oidc_refresh_token.rb
Normal file
87
app/models/oidc_refresh_token.rb
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
class OidcRefreshToken < ApplicationRecord
|
||||||
|
belongs_to :application
|
||||||
|
belongs_to :user
|
||||||
|
belongs_to :oidc_access_token
|
||||||
|
has_many :oidc_access_tokens, foreign_key: :oidc_access_token_id, dependent: :nullify
|
||||||
|
|
||||||
|
before_validation :generate_token, on: :create
|
||||||
|
before_validation :set_expiry, on: :create
|
||||||
|
before_validation :set_token_family_id, on: :create
|
||||||
|
|
||||||
|
validates :token_digest, presence: true, uniqueness: true
|
||||||
|
|
||||||
|
scope :valid, -> { where("expires_at > ?", Time.current).where(revoked_at: nil) }
|
||||||
|
scope :expired, -> { where("expires_at <= ?", Time.current) }
|
||||||
|
scope :revoked, -> { where.not(revoked_at: nil) }
|
||||||
|
scope :active, -> { valid }
|
||||||
|
|
||||||
|
# For token rotation detection (prevents reuse attacks)
|
||||||
|
scope :in_family, ->(family_id) { where(token_family_id: family_id) }
|
||||||
|
|
||||||
|
attr_accessor :token # Store plaintext token temporarily for returning to client
|
||||||
|
|
||||||
|
def expired?
|
||||||
|
expires_at <= Time.current
|
||||||
|
end
|
||||||
|
|
||||||
|
def revoked?
|
||||||
|
revoked_at.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def active?
|
||||||
|
!expired? && !revoked?
|
||||||
|
end
|
||||||
|
|
||||||
|
def revoke!
|
||||||
|
update!(revoked_at: Time.current)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Revoke all refresh tokens in the same family (token rotation security)
|
||||||
|
def revoke_family!
|
||||||
|
return unless token_family_id.present?
|
||||||
|
|
||||||
|
OidcRefreshToken.in_family(token_family_id).update_all(revoked_at: Time.current)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Verify a plaintext token against the stored digest
|
||||||
|
def self.find_by_token(plaintext_token)
|
||||||
|
return nil if plaintext_token.blank?
|
||||||
|
|
||||||
|
# Try to find tokens that could match (we can't search by hash directly)
|
||||||
|
# This is less efficient but necessary with BCrypt
|
||||||
|
# In production, you might want to add a token prefix or other optimization
|
||||||
|
all.find do |refresh_token|
|
||||||
|
refresh_token.token_matches?(plaintext_token)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def token_matches?(plaintext_token)
|
||||||
|
return false if plaintext_token.blank? || token_digest.blank?
|
||||||
|
|
||||||
|
BCrypt::Password.new(token_digest) == plaintext_token
|
||||||
|
rescue BCrypt::Errors::InvalidHash
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def generate_token
|
||||||
|
# Generate a secure random token
|
||||||
|
plaintext = SecureRandom.urlsafe_base64(48)
|
||||||
|
self.token = plaintext # Store temporarily for returning to client
|
||||||
|
|
||||||
|
# Hash it with BCrypt for storage
|
||||||
|
self.token_digest = BCrypt::Password.create(plaintext)
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_expiry
|
||||||
|
# Use application's configured refresh token TTL
|
||||||
|
self.expires_at ||= application.refresh_token_expiry
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_token_family_id
|
||||||
|
# Use a random ID to group tokens in the same rotation chain
|
||||||
|
# This helps detect token reuse attacks
|
||||||
|
self.token_family_id ||= SecureRandom.random_number(2**31)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -66,19 +66,53 @@ class User < ApplicationRecord
|
|||||||
def verify_backup_code(code)
|
def verify_backup_code(code)
|
||||||
return false unless backup_codes.present?
|
return false unless backup_codes.present?
|
||||||
|
|
||||||
codes = JSON.parse(backup_codes)
|
# Rate limiting: prevent brute force attacks on backup codes
|
||||||
if codes.include?(code)
|
if rate_limit_backup_code_verification?
|
||||||
codes.delete(code)
|
Rails.logger.warn "Rate limit exceeded for backup code verification - User ID: #{id}"
|
||||||
update(backup_codes: codes.to_json)
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
# backup_codes is now an Array (JSON column), no need to parse
|
||||||
|
# Find the matching hash by comparing with BCrypt
|
||||||
|
matching_hash = backup_codes.find do |hashed_code|
|
||||||
|
BCrypt::Password.new(hashed_code) == code
|
||||||
|
end
|
||||||
|
|
||||||
|
if matching_hash
|
||||||
|
# Remove the used hash from the array (single-use property)
|
||||||
|
backup_codes.delete(matching_hash)
|
||||||
|
save! # Save the updated array
|
||||||
|
|
||||||
|
# Log successful backup code usage for security monitoring
|
||||||
|
Rails.logger.info "Backup code used successfully - User ID: #{id}, IP: #{Current.session&.client_ip}"
|
||||||
true
|
true
|
||||||
else
|
else
|
||||||
|
# Increment failed attempt counter and log for security monitoring
|
||||||
|
increment_backup_code_failed_attempts
|
||||||
|
Rails.logger.warn "Failed backup code attempt - User ID: #{id}, IP: #{Current.session&.client_ip}"
|
||||||
false
|
false
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def parsed_backup_codes
|
# Rate limiting for backup code verification to prevent brute force attacks
|
||||||
return [] unless backup_codes.present?
|
def rate_limit_backup_code_verification?
|
||||||
JSON.parse(backup_codes)
|
# Use Rails.cache to track failed attempts
|
||||||
|
cache_key = "backup_code_failed_attempts_#{id}"
|
||||||
|
attempts = Rails.cache.read(cache_key) || 0
|
||||||
|
|
||||||
|
if attempts >= 5 # Allow max 5 failed attempts per hour
|
||||||
|
true
|
||||||
|
else
|
||||||
|
# Don't increment here - increment only on failed attempts
|
||||||
|
false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Increment failed attempt counter
|
||||||
|
def increment_backup_code_failed_attempts
|
||||||
|
cache_key = "backup_code_failed_attempts_#{id}"
|
||||||
|
attempts = Rails.cache.read(cache_key) || 0
|
||||||
|
Rails.cache.write(cache_key, attempts + 1, expires_in: 1.hour)
|
||||||
end
|
end
|
||||||
|
|
||||||
# WebAuthn methods
|
# WebAuthn methods
|
||||||
@@ -152,6 +186,16 @@ class User < ApplicationRecord
|
|||||||
private
|
private
|
||||||
|
|
||||||
def generate_backup_codes
|
def generate_backup_codes
|
||||||
Array.new(10) { SecureRandom.alphanumeric(8).upcase }.to_json
|
# Generate plain codes for user to see/save
|
||||||
|
plain_codes = Array.new(10) { SecureRandom.alphanumeric(8).upcase }
|
||||||
|
|
||||||
|
# Store BCrypt hashes of the codes
|
||||||
|
hashed_codes = plain_codes.map { |code| BCrypt::Password.create(code) }
|
||||||
|
|
||||||
|
# Return plain codes for display (will be shown to user once)
|
||||||
|
# Store only hashes in the database (as Array for JSON column)
|
||||||
|
self.backup_codes = hashed_codes
|
||||||
|
|
||||||
|
plain_codes
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ class OidcJwtService
|
|||||||
# 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, nonce: nil)
|
||||||
now = Time.current.to_i
|
now = Time.current.to_i
|
||||||
|
# Use application's configured ID token TTL (defaults to 1 hour)
|
||||||
|
ttl = application.id_token_expiry_seconds
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
iss: issuer_url,
|
iss: issuer_url,
|
||||||
sub: user.id.to_s,
|
sub: user.id.to_s,
|
||||||
aud: application.client_id,
|
aud: application.client_id,
|
||||||
exp: now + 3600, # 1 hour
|
exp: now + ttl,
|
||||||
iat: now,
|
iat: now,
|
||||||
email: user.email_address,
|
email: user.email_address,
|
||||||
email_verified: true,
|
email_verified: true,
|
||||||
@@ -63,7 +65,9 @@ class OidcJwtService
|
|||||||
def issuer_url
|
def issuer_url
|
||||||
# In production, this should come from ENV or config
|
# In production, this should come from ENV or config
|
||||||
# For now, we'll use a placeholder that can be overridden
|
# For now, we'll use a placeholder that can be overridden
|
||||||
"https://#{ENV.fetch("CLINCH_HOST", "localhost:3000")}"
|
host = ENV.fetch("CLINCH_HOST", "localhost:3000")
|
||||||
|
# Ensure URL has https:// protocol
|
||||||
|
host.match?(/^https?:\/\//) ? host : "https://#{host}"
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|||||||
@@ -1,22 +1,5 @@
|
|||||||
<%= form_with(model: [:admin, application], class: "space-y-6") do |form| %>
|
<%= form_with(model: [:admin, application], class: "space-y-6", data: { controller: "application-form form-errors" }) do |form| %>
|
||||||
<% if application.errors.any? %>
|
<%= render "shared/form_errors", form: form %>
|
||||||
<div class="rounded-md bg-red-50 p-4">
|
|
||||||
<div class="flex">
|
|
||||||
<div class="ml-3">
|
|
||||||
<h3 class="text-sm font-medium text-red-800">
|
|
||||||
<%= pluralize(application.errors.count, "error") %> prohibited this application from being saved:
|
|
||||||
</h3>
|
|
||||||
<div class="mt-2 text-sm text-red-700">
|
|
||||||
<ul class="list-disc pl-5 space-y-1">
|
|
||||||
<% application.errors.full_messages.each do |message| %>
|
|
||||||
<li><%= message %></li>
|
|
||||||
<% end %>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<%= form.label :name, class: "block text-sm font-medium text-gray-700" %>
|
<%= form.label :name, class: "block text-sm font-medium text-gray-700" %>
|
||||||
@@ -42,14 +25,18 @@
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<%= form.label :app_type, "Application Type", class: "block text-sm font-medium text-gray-700" %>
|
<%= form.label :app_type, "Application Type", class: "block text-sm font-medium text-gray-700" %>
|
||||||
<%= form.select :app_type, [["OpenID Connect (OIDC)", "oidc"], ["Forward Auth (Reverse Proxy)", "forward_auth"]], {}, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", disabled: application.persisted? %>
|
<%= form.select :app_type, [["OpenID Connect (OIDC)", "oidc"], ["Forward Auth (Reverse Proxy)", "forward_auth"]], {}, {
|
||||||
|
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm",
|
||||||
|
disabled: application.persisted?,
|
||||||
|
data: { action: "change->application-form#updateFieldVisibility", application_form_target: "appTypeSelect" }
|
||||||
|
} %>
|
||||||
<% if application.persisted? %>
|
<% if application.persisted? %>
|
||||||
<p class="mt-1 text-sm text-gray-500">Application type cannot be changed after creation.</p>
|
<p class="mt-1 text-sm text-gray-500">Application type cannot be changed after creation.</p>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- OIDC-specific fields -->
|
<!-- OIDC-specific fields -->
|
||||||
<div id="oidc-fields" class="space-y-6 border-t border-gray-200 pt-6" style="<%= 'display: none;' unless application.oidc? || !application.persisted? %>">
|
<div id="oidc-fields" class="space-y-6 border-t border-gray-200 pt-6 <%= 'hidden' unless application.oidc? || !application.persisted? %>" data-application-form-target="oidcFields">
|
||||||
<h3 class="text-base font-semibold text-gray-900">OIDC Configuration</h3>
|
<h3 class="text-base font-semibold text-gray-900">OIDC Configuration</h3>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -57,10 +44,57 @@
|
|||||||
<%= 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 class="border-t border-gray-200 pt-4 mt-4">
|
||||||
|
<h4 class="text-sm font-semibold text-gray-900 mb-3">Token Expiration Settings</h4>
|
||||||
|
<p class="text-sm text-gray-500 mb-4">Configure how long tokens remain valid. Shorter times are more secure but require more frequent refreshes.</p>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<%= form.label :access_token_ttl, "Access Token TTL (seconds)", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.number_field :access_token_ttl, value: application.access_token_ttl || 3600, min: 300, max: 86400, step: 60, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
||||||
|
<p class="mt-1 text-xs text-gray-500">
|
||||||
|
Range: 5 min - 24 hours
|
||||||
|
<br>Default: 1 hour (3600s)
|
||||||
|
<br>Current: <span class="font-medium"><%= application.access_token_ttl_human || "1 hour" %></span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= form.label :refresh_token_ttl, "Refresh Token TTL (seconds)", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.number_field :refresh_token_ttl, value: application.refresh_token_ttl || 2592000, min: 86400, max: 7776000, step: 86400, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
||||||
|
<p class="mt-1 text-xs text-gray-500">
|
||||||
|
Range: 1 day - 90 days
|
||||||
|
<br>Default: 30 days (2592000s)
|
||||||
|
<br>Current: <span class="font-medium"><%= application.refresh_token_ttl_human || "30 days" %></span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= form.label :id_token_ttl, "ID Token TTL (seconds)", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.number_field :id_token_ttl, value: application.id_token_ttl || 3600, min: 300, max: 86400, step: 60, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
||||||
|
<p class="mt-1 text-xs text-gray-500">
|
||||||
|
Range: 5 min - 24 hours
|
||||||
|
<br>Default: 1 hour (3600s)
|
||||||
|
<br>Current: <span class="font-medium"><%= application.id_token_ttl_human || "1 hour" %></span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details class="mt-3">
|
||||||
|
<summary class="cursor-pointer text-sm text-blue-600 hover:text-blue-800">Understanding Token Types</summary>
|
||||||
|
<div class="mt-2 ml-4 space-y-2 text-sm text-gray-600">
|
||||||
|
<p><strong>Access Token:</strong> Used to access protected resources (APIs). Shorter lifetime = more secure. Users won't notice automatic refreshes.</p>
|
||||||
|
<p><strong>Refresh Token:</strong> Used to get new access tokens without re-authentication. Longer lifetime = better UX (less re-logins).</p>
|
||||||
|
<p><strong>ID Token:</strong> Contains user identity information (JWT). Should match access token lifetime in most cases.</p>
|
||||||
|
<p class="text-xs italic mt-2">💡 Tip: Banking apps use 5-15 min access tokens. Internal tools use 1-4 hours.</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Forward Auth-specific fields -->
|
<!-- Forward Auth-specific fields -->
|
||||||
<div id="forward-auth-fields" class="space-y-6 border-t border-gray-200 pt-6" style="<%= 'display: none;' unless application.forward_auth? %>">
|
<div id="forward-auth-fields" class="space-y-6 border-t border-gray-200 pt-6 <%= 'hidden' unless application.forward_auth? %>" data-application-form-target="forwardAuthFields">
|
||||||
<h3 class="text-base font-semibold text-gray-900">Forward Auth Configuration</h3>
|
<h3 class="text-base font-semibold text-gray-900">Forward Auth Configuration</h3>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -69,12 +103,25 @@
|
|||||||
<p class="mt-1 text-sm text-gray-500">Domain pattern to match. Use * for wildcard subdomains (e.g., *.example.com matches app.example.com, api.example.com, etc.)</p>
|
<p class="mt-1 text-sm text-gray-500">Domain pattern to match. Use * for wildcard subdomains (e.g., *.example.com matches app.example.com, api.example.com, etc.)</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div data-controller="json-validator" data-json-validator-valid-class="border-green-500 focus:border-green-500 focus:ring-green-500" data-json-validator-invalid-class="border-red-500 focus:border-red-500 focus:ring-red-500" data-json-validator-valid-status-class="text-green-600" data-json-validator-invalid-status-class="text-red-600">
|
||||||
<%= form.label :headers_config, "Custom Headers Configuration (JSON)", class: "block text-sm font-medium text-gray-700" %>
|
<%= form.label :headers_config, "Custom Headers Configuration (JSON)", class: "block text-sm font-medium text-gray-700" %>
|
||||||
<%= form.text_area :headers_config, rows: 10, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono", placeholder: '{"user": "Remote-User", "groups": "Remote-Groups"}' %>
|
<%= form.text_area :headers_config, value: (application.headers_config.present? && application.headers_config.any? ? JSON.pretty_generate(application.headers_config) : ""), rows: 10,
|
||||||
|
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono",
|
||||||
|
placeholder: '{"user": "Remote-User", "groups": "Remote-Groups"}',
|
||||||
|
data: {
|
||||||
|
action: "input->json-validator#validate blur->json-validator#format",
|
||||||
|
json_validator_target: "textarea"
|
||||||
|
} %>
|
||||||
<div class="mt-2 text-sm text-gray-600 space-y-1">
|
<div class="mt-2 text-sm text-gray-600 space-y-1">
|
||||||
<p class="font-medium">Optional: Customize header names sent to your application.</p>
|
<div class="flex items-center justify-between">
|
||||||
|
<p class="font-medium">Optional: Customize header names sent to your application.</p>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button type="button" data-action="json-validator#format" class="text-xs bg-gray-100 hover:bg-gray-200 px-2 py-1 rounded">Format JSON</button>
|
||||||
|
<button type="button" data-action="json-validator#insertSample" data-json-sample='{"user": "Remote-User", "groups": "Remote-Groups", "email": "Remote-Email", "name": "Remote-Name", "admin": "Remote-Admin"}' class="text-xs bg-blue-100 hover:bg-blue-200 text-blue-700 px-2 py-1 rounded">Insert Example</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<p><strong>Default headers:</strong> X-Remote-User, X-Remote-Email, X-Remote-Name, X-Remote-Groups, X-Remote-Admin</p>
|
<p><strong>Default headers:</strong> X-Remote-User, X-Remote-Email, X-Remote-Name, X-Remote-Groups, X-Remote-Admin</p>
|
||||||
|
<div data-json-validator-target="status" class="text-xs font-medium"></div>
|
||||||
<details class="mt-2">
|
<details class="mt-2">
|
||||||
<summary class="cursor-pointer text-blue-600 hover:text-blue-800">Show available header keys and what data they send</summary>
|
<summary class="cursor-pointer text-blue-600 hover:text-blue-800">Show available header keys and what data they send</summary>
|
||||||
<div class="mt-2 ml-4 space-y-1 text-xs">
|
<div class="mt-2 ml-4 space-y-1 text-xs">
|
||||||
@@ -120,30 +167,3 @@
|
|||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<script>
|
|
||||||
// Show/hide type-specific fields based on app type selection
|
|
||||||
const appTypeSelect = document.querySelector('#application_app_type');
|
|
||||||
const oidcFields = document.querySelector('#oidc-fields');
|
|
||||||
const forwardAuthFields = document.querySelector('#forward-auth-fields');
|
|
||||||
|
|
||||||
function updateFieldVisibility() {
|
|
||||||
if (!appTypeSelect) return;
|
|
||||||
|
|
||||||
const appType = appTypeSelect.value;
|
|
||||||
|
|
||||||
if (oidcFields) {
|
|
||||||
oidcFields.style.display = appType === 'oidc' ? 'block' : 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (forwardAuthFields) {
|
|
||||||
forwardAuthFields.style.display = appType === 'forward_auth' ? 'block' : 'none';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (appTypeSelect) {
|
|
||||||
appTypeSelect.addEventListener('change', updateFieldVisibility);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize visibility on page load
|
|
||||||
updateFieldVisibility();
|
|
||||||
</script>
|
|
||||||
|
|||||||
@@ -37,6 +37,8 @@
|
|||||||
<% case application.app_type %>
|
<% case application.app_type %>
|
||||||
<% when "oidc" %>
|
<% when "oidc" %>
|
||||||
<span class="inline-flex items-center rounded-full bg-purple-100 px-2 py-1 text-xs font-medium text-purple-700">OIDC</span>
|
<span class="inline-flex items-center rounded-full bg-purple-100 px-2 py-1 text-xs font-medium text-purple-700">OIDC</span>
|
||||||
|
<% when "forward_auth" %>
|
||||||
|
<span class="inline-flex items-center rounded-full bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700">Forward Auth</span>
|
||||||
<% when "saml" %>
|
<% when "saml" %>
|
||||||
<span class="inline-flex items-center rounded-full bg-orange-100 px-2 py-1 text-xs font-medium text-orange-700">SAML</span>
|
<span class="inline-flex items-center rounded-full bg-orange-100 px-2 py-1 text-xs font-medium text-orange-700">SAML</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -1,22 +1,5 @@
|
|||||||
<%= form_with(model: [:admin, group], class: "space-y-6") do |form| %>
|
<%= form_with(model: [:admin, group], class: "space-y-6", data: { controller: "form-errors" }) do |form| %>
|
||||||
<% if group.errors.any? %>
|
<%= render "shared/form_errors", form: form %>
|
||||||
<div class="rounded-md bg-red-50 p-4">
|
|
||||||
<div class="flex">
|
|
||||||
<div class="ml-3">
|
|
||||||
<h3 class="text-sm font-medium text-red-800">
|
|
||||||
<%= pluralize(group.errors.count, "error") %> prohibited this group from being saved:
|
|
||||||
</h3>
|
|
||||||
<div class="mt-2 text-sm text-red-700">
|
|
||||||
<ul class="list-disc pl-5 space-y-1">
|
|
||||||
<% group.errors.full_messages.each do |message| %>
|
|
||||||
<li><%= message %></li>
|
|
||||||
<% end %>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<%= form.label :name, class: "block text-sm font-medium text-gray-700" %>
|
<%= form.label :name, class: "block text-sm font-medium text-gray-700" %>
|
||||||
@@ -49,10 +32,25 @@
|
|||||||
<p class="mt-1 text-sm text-gray-500">Select which users should be members of this group.</p>
|
<p class="mt-1 text-sm text-gray-500">Select which users should be members of this group.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<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: (group.custom_claims.present? ? JSON.pretty_generate(group.custom_claims) : ""), rows: 8, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono", placeholder: '{"roles": ["admin", "editor"]}' %>
|
<%= form.text_area :custom_claims, value: (group.custom_claims.present? ? JSON.pretty_generate(group.custom_claims) : ""), rows: 8,
|
||||||
<p class="mt-1 text-sm text-gray-500">Optional: Custom claims to add to OIDC tokens for all members. These will be merged with user-level claims.</p>
|
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono",
|
||||||
|
placeholder: '{"roles": ["admin", "editor"]}',
|
||||||
|
data: {
|
||||||
|
action: "input->json-validator#validate blur->json-validator#format",
|
||||||
|
json_validator_target: "textarea"
|
||||||
|
} %>
|
||||||
|
<div class="mt-2 text-sm text-gray-600 space-y-1">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<p>Optional: Custom claims to add to OIDC tokens for all members. These will be merged with user-level claims.</p>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button type="button" data-action="json-validator#format" class="text-xs bg-gray-100 hover:bg-gray-200 px-2 py-1 rounded">Format JSON</button>
|
||||||
|
<button type="button" data-action="json-validator#insertSample" data-json-sample='{"roles": ["admin", "editor"], "permissions": ["read", "write"], "team": "backend"}' class="text-xs bg-blue-100 hover:bg-blue-200 text-blue-700 px-2 py-1 rounded">Insert Example</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div data-json-validator-target="status" class="text-xs font-medium"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
|
|||||||
@@ -39,9 +39,11 @@
|
|||||||
<%= pluralize(group.applications.count, "app") %>
|
<%= pluralize(group.applications.count, "app") %>
|
||||||
</td>
|
</td>
|
||||||
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">
|
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">
|
||||||
<%= link_to "View", admin_group_path(group), class: "text-blue-600 hover:text-blue-900 mr-4" %>
|
<div class="flex justify-end space-x-3">
|
||||||
<%= link_to "Edit", edit_admin_group_path(group), class: "text-blue-600 hover:text-blue-900 mr-4" %>
|
<%= link_to "View", admin_group_path(group), class: "text-blue-600 hover:text-blue-900 whitespace-nowrap" %>
|
||||||
<%= button_to "Delete", admin_group_path(group), method: :delete, data: { turbo_confirm: "Are you sure you want to delete this group?" }, class: "text-red-600 hover:text-red-900" %>
|
<%= link_to "Edit", edit_admin_group_path(group), class: "text-blue-600 hover:text-blue-900 whitespace-nowrap" %>
|
||||||
|
<%= button_to "Delete", admin_group_path(group), method: :delete, data: { turbo_confirm: "Are you sure you want to delete this group?" }, class: "text-red-600 hover:text-red-900 whitespace-nowrap" %>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -1,22 +1,5 @@
|
|||||||
<%= form_with(model: [:admin, user], class: "space-y-6") do |form| %>
|
<%= form_with(model: [:admin, user], class: "space-y-6", data: { controller: "form-errors" }) do |form| %>
|
||||||
<% if user.errors.any? %>
|
<%= render "shared/form_errors", form: form %>
|
||||||
<div class="rounded-md bg-red-50 p-4">
|
|
||||||
<div class="flex">
|
|
||||||
<div class="ml-3">
|
|
||||||
<h3 class="text-sm font-medium text-red-800">
|
|
||||||
<%= pluralize(user.errors.count, "error") %> prohibited this user from being saved:
|
|
||||||
</h3>
|
|
||||||
<div class="mt-2 text-sm text-red-700">
|
|
||||||
<ul class="list-disc pl-5 space-y-1">
|
|
||||||
<% user.errors.full_messages.each do |message| %>
|
|
||||||
<li><%= message %></li>
|
|
||||||
<% end %>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<%= form.label :email_address, class: "block text-sm font-medium text-gray-700" %>
|
<%= form.label :email_address, class: "block text-sm font-medium text-gray-700" %>
|
||||||
@@ -52,10 +35,25 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div data-controller="json-validator" data-json-validator-valid-class="border-green-500 focus:border-green-500 focus:ring-green-500" data-json-validator-invalid-class="border-red-500 focus:border-red-500 focus:ring-red-500" data-json-validator-valid-status-class="text-green-600" data-json-validator-invalid-status-class="text-red-600">
|
||||||
<%= form.label :custom_claims, "Custom Claims (JSON)", class: "block text-sm font-medium text-gray-700" %>
|
<%= form.label :custom_claims, "Custom Claims (JSON)", class: "block text-sm font-medium text-gray-700" %>
|
||||||
<%= form.text_area :custom_claims, value: (user.custom_claims.present? ? JSON.pretty_generate(user.custom_claims) : ""), rows: 8, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono", placeholder: '{"department": "engineering", "level": "senior"}' %>
|
<%= form.text_area :custom_claims, value: (user.custom_claims.present? ? JSON.pretty_generate(user.custom_claims) : ""), rows: 8,
|
||||||
<p class="mt-1 text-sm text-gray-500">Optional: User-specific custom claims to add to OIDC tokens. These override group-level claims.</p>
|
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono",
|
||||||
|
placeholder: '{"department": "engineering", "level": "senior"}',
|
||||||
|
data: {
|
||||||
|
action: "input->json-validator#validate blur->json-validator#format",
|
||||||
|
json_validator_target: "textarea"
|
||||||
|
} %>
|
||||||
|
<div class="mt-2 text-sm text-gray-600 space-y-1">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<p>Optional: User-specific custom claims to add to OIDC tokens. These override group-level claims.</p>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button type="button" data-action="json-validator#format" class="text-xs bg-gray-100 hover:bg-gray-200 px-2 py-1 rounded">Format JSON</button>
|
||||||
|
<button type="button" data-action="json-validator#insertSample" data-json-sample='{"department": "engineering", "level": "senior", "location": "remote"}' class="text-xs bg-blue-100 hover:bg-blue-200 text-blue-700 px-2 py-1 rounded">Insert Example</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div data-json-validator-target="status" class="text-xs font-medium"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
|
|||||||
@@ -25,24 +25,29 @@
|
|||||||
|
|
||||||
<body>
|
<body>
|
||||||
<% if authenticated? %>
|
<% if authenticated? %>
|
||||||
<%= render "shared/sidebar" %>
|
<div data-controller="mobile-sidebar">
|
||||||
<div class="lg:pl-64">
|
<%= render "shared/sidebar" %>
|
||||||
<!-- Mobile menu button -->
|
<div class="lg:pl-64">
|
||||||
<div class="sticky top-0 z-40 flex h-16 shrink-0 items-center gap-x-4 border-b border-gray-200 bg-white px-4 shadow-sm sm:gap-x-6 sm:px-6 lg:hidden">
|
<!-- Mobile menu button -->
|
||||||
<button type="button" class="-m-2.5 p-2.5 text-gray-700" id="mobile-menu-button">
|
<div class="sticky top-0 z-40 flex h-16 shrink-0 items-center gap-x-4 border-b border-gray-200 bg-white px-4 shadow-sm sm:gap-x-6 sm:px-6 lg:hidden">
|
||||||
<span class="sr-only">Open sidebar</span>
|
<button type="button"
|
||||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
class="-m-2.5 p-2.5 text-gray-700"
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
id="mobile-menu-button"
|
||||||
</svg>
|
data-action="click->mobile-sidebar#openSidebar">
|
||||||
</button>
|
<span class="sr-only">Open sidebar</span>
|
||||||
</div>
|
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
||||||
<main class="py-10">
|
</svg>
|
||||||
<div class="px-4 sm:px-6 lg:px-8">
|
</button>
|
||||||
<%= render "shared/flash" %>
|
|
||||||
<%= yield %>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
|
||||||
|
<main class="py-10">
|
||||||
|
<div class="px-4 sm:px-6 lg:px-8">
|
||||||
|
<%= render "shared/flash" %>
|
||||||
|
<%= yield %>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% else %>
|
<% else %>
|
||||||
<!-- Public layout (signup/signin) -->
|
<!-- Public layout (signup/signin) -->
|
||||||
@@ -52,23 +57,5 @@
|
|||||||
</main>
|
</main>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<script>
|
|
||||||
// Mobile sidebar toggle
|
|
||||||
const mobileMenuButton = document.getElementById('mobile-menu-button');
|
|
||||||
const mobileMenuClose = document.getElementById('mobile-menu-close');
|
|
||||||
const mobileSidebarOverlay = document.getElementById('mobile-sidebar-overlay');
|
|
||||||
|
|
||||||
if (mobileMenuButton) {
|
|
||||||
mobileMenuButton.addEventListener('click', () => {
|
|
||||||
mobileSidebarOverlay?.classList.remove('hidden');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mobileMenuClose) {
|
|
||||||
mobileMenuClose.addEventListener('click', () => {
|
|
||||||
mobileSidebarOverlay?.classList.add('hidden');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -57,7 +57,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= form_with url: oauth_consent_path, method: :post, class: "space-y-3", data: { turbo: false } do |form| %>
|
<%= form_with url: "/oauth/authorize/consent", method: :post, class: "space-y-3", data: { turbo: false }, local: true do |form| %>
|
||||||
<%= form.submit "Authorize",
|
<%= form.submit "Authorize",
|
||||||
class: "w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %>
|
class: "w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %>
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
<h1 class="font-bold text-4xl">Forgot your password?</h1>
|
<h1 class="font-bold text-4xl">Forgot your password?</h1>
|
||||||
|
|
||||||
<%= form_with url: passwords_path, class: "contents" do |form| %>
|
<%= form_with url: passwords_path, class: "contents", data: { controller: "form-errors" } do |form| %>
|
||||||
<div class="my-5">
|
<div class="my-5">
|
||||||
<%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email_address], class: "block shadow-sm rounded-md border border-gray-400 focus:outline-solid focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
|
<%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email_address], class: "block shadow-sm rounded-md border border-gray-400 focus:outline-solid focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<div class="space-y-8">
|
<div class="space-y-8" data-controller="modal">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-3xl font-bold text-gray-900">Account Security</h1>
|
<h1 class="text-3xl font-bold text-gray-900">Account Security</h1>
|
||||||
<p class="mt-2 text-sm text-gray-600">Manage your account settings, active sessions, and connected applications.</p>
|
<p class="mt-2 text-sm text-gray-600">Manage your account settings, active sessions, and connected applications.</p>
|
||||||
@@ -126,7 +126,6 @@
|
|||||||
|
|
||||||
<!-- Disable 2FA Modal -->
|
<!-- Disable 2FA Modal -->
|
||||||
<div id="disable-2fa-modal"
|
<div id="disable-2fa-modal"
|
||||||
data-controller="modal"
|
|
||||||
data-action="click->modal#closeOnBackdrop keyup@window->modal#closeOnEscape"
|
data-action="click->modal#closeOnBackdrop keyup@window->modal#closeOnEscape"
|
||||||
class="hidden fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center z-50">
|
class="hidden fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center z-50">
|
||||||
<div class="bg-white rounded-lg px-4 pt-5 pb-4 shadow-xl max-w-md w-full">
|
<div class="bg-white rounded-lg px-4 pt-5 pb-4 shadow-xl max-w-md w-full">
|
||||||
@@ -164,18 +163,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- View Backup Codes Modal -->
|
<!-- Regenerate Backup Codes Modal -->
|
||||||
<div id="view-backup-codes-modal"
|
<div id="view-backup-codes-modal"
|
||||||
data-controller="modal"
|
|
||||||
data-action="click->modal#closeOnBackdrop keyup@window->modal#closeOnEscape"
|
data-action="click->modal#closeOnBackdrop keyup@window->modal#closeOnEscape"
|
||||||
class="hidden fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center z-50">
|
class="hidden fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center z-50">
|
||||||
<div class="bg-white rounded-lg px-4 pt-5 pb-4 shadow-xl max-w-md w-full">
|
<div class="bg-white rounded-lg px-4 pt-5 pb-4 shadow-xl max-w-md w-full">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-medium leading-6 text-gray-900">View Backup Codes</h3>
|
<h3 class="text-lg font-medium leading-6 text-gray-900">Generate New Backup Codes</h3>
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<p class="text-sm text-gray-500">Enter your password to view your backup codes.</p>
|
<p class="text-sm text-gray-500">Due to security improvements, you need to generate new backup codes. Your old codes have been invalidated.</p>
|
||||||
</div>
|
</div>
|
||||||
<%= form_with url: verify_password_totp_path, method: :post, class: "mt-4" do |form| %>
|
<div class="mt-3 p-3 bg-yellow-50 rounded-md">
|
||||||
|
<div class="flex">
|
||||||
|
<svg class="h-5 w-5 text-yellow-400 mr-2 flex-shrink-0" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
<p class="text-sm text-yellow-800">
|
||||||
|
<strong>Important:</strong> Save the new codes immediately after generation. You won't be able to see them again without regenerating.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<%= form_with url: create_new_backup_codes_totp_path, method: :post, class: "mt-4" do |form| %>
|
||||||
<div>
|
<div>
|
||||||
<%= password_field_tag :password, nil,
|
<%= password_field_tag :password, nil,
|
||||||
placeholder: "Enter your password",
|
placeholder: "Enter your password",
|
||||||
@@ -184,7 +192,7 @@
|
|||||||
class: "block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
class: "block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4 flex gap-3">
|
<div class="mt-4 flex gap-3">
|
||||||
<%= form.submit "View Codes",
|
<%= form.submit "Generate New Codes",
|
||||||
class: "inline-flex justify-center rounded-md border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" %>
|
class: "inline-flex justify-center rounded-md border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" %>
|
||||||
<button type="button"
|
<button type="button"
|
||||||
data-action="click->modal#hide"
|
data-action="click->modal#hide"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<h1 class="font-bold text-4xl">Sign in to Clinch</h1>
|
<h1 class="font-bold text-4xl">Sign in to Clinch</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= form_with url: signin_path, class: "contents" do |form| %>
|
<%= form_with url: signin_path, class: "contents", data: { controller: "form-errors" } do |form| %>
|
||||||
<%= hidden_field_tag :rd, params[:rd] if params[:rd].present? %>
|
<%= hidden_field_tag :rd, params[:rd] if params[:rd].present? %>
|
||||||
<div class="my-5">
|
<div class="my-5">
|
||||||
<%= form.label :email_address, "Email Address", class: "block font-medium text-sm text-gray-700" %>
|
<%= form.label :email_address, "Email Address", class: "block font-medium text-sm text-gray-700" %>
|
||||||
|
|||||||
@@ -1,29 +1,73 @@
|
|||||||
<% if flash[:alert] %>
|
<%# Enhanced Flash Messages with Support for Multiple Types and Auto-Dismiss %>
|
||||||
<div class="mb-4 rounded-lg bg-red-50 p-4" role="alert">
|
<% flash.each do |type, message| %>
|
||||||
<div class="flex">
|
<% next if message.blank? %>
|
||||||
<div class="flex-shrink-0">
|
|
||||||
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
|
||||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="ml-3">
|
|
||||||
<p class="text-sm font-medium text-red-800"><%= flash[:alert] %></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<% if flash[:notice] %>
|
<%
|
||||||
<div class="mb-4 rounded-lg bg-green-50 p-4" role="alert">
|
# Map flash types to styling
|
||||||
|
case type.to_s
|
||||||
|
when 'notice'
|
||||||
|
bg_class = 'bg-green-50'
|
||||||
|
text_class = 'text-green-800'
|
||||||
|
icon_class = 'text-green-400'
|
||||||
|
icon_path = 'M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z'
|
||||||
|
auto_dismiss = true
|
||||||
|
when 'alert', 'error'
|
||||||
|
bg_class = 'bg-red-50'
|
||||||
|
text_class = 'text-red-800'
|
||||||
|
icon_class = 'text-red-400'
|
||||||
|
icon_path = 'M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z'
|
||||||
|
auto_dismiss = false
|
||||||
|
when 'warning'
|
||||||
|
bg_class = 'bg-yellow-50'
|
||||||
|
text_class = 'text-yellow-800'
|
||||||
|
icon_class = 'text-yellow-400'
|
||||||
|
icon_path = 'M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z'
|
||||||
|
auto_dismiss = false
|
||||||
|
when 'info'
|
||||||
|
bg_class = 'bg-blue-50'
|
||||||
|
text_class = 'text-blue-800'
|
||||||
|
icon_class = 'text-blue-400'
|
||||||
|
icon_path = 'M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z'
|
||||||
|
auto_dismiss = true
|
||||||
|
else
|
||||||
|
# Default styling for unknown types
|
||||||
|
bg_class = 'bg-gray-50'
|
||||||
|
text_class = 'text-gray-800'
|
||||||
|
icon_class = 'text-gray-400'
|
||||||
|
icon_path = 'M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z'
|
||||||
|
auto_dismiss = false
|
||||||
|
end
|
||||||
|
%>
|
||||||
|
|
||||||
|
<div class="mb-4 rounded-lg <%= bg_class %> p-4 border border-opacity-20 <%= border_class_for(type) %>"
|
||||||
|
role="alert"
|
||||||
|
data-controller="flash"
|
||||||
|
data-flash-auto-dismiss-value="<%= auto_dismiss ? '5000' : 'false' %>"
|
||||||
|
data-flash-type-value="<%= type %>">
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<div class="flex-shrink-0">
|
<div class="shrink-0">
|
||||||
<svg class="h-5 w-5 text-green-400" viewBox="0 0 20 20" fill="currentColor">
|
<svg class="h-5 w-5 <%= icon_class %>" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
|
<path fill-rule="evenodd" d="<%= icon_path %>" clip-rule="evenodd"/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-3">
|
<div class="ml-3 flex-1">
|
||||||
<p class="text-sm font-medium text-green-800"><%= flash[:notice] %></p>
|
<p class="text-sm font-medium <%= text_class %>"><%= message %></p>
|
||||||
</div>
|
</div>
|
||||||
|
<% if auto_dismiss || type.to_s != 'alert' %>
|
||||||
|
<div class="ml-auto pl-3">
|
||||||
|
<div class="-mx-1.5 -my-1.5">
|
||||||
|
<button type="button"
|
||||||
|
data-action="click->flash#dismiss"
|
||||||
|
class="inline-flex rounded-md <%= bg_class %> p-1.5 <%= icon_class %> hover:bg-opacity-70 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-<%= bg_class.gsub('bg-', '') %>"
|
||||||
|
aria-label="Dismiss">
|
||||||
|
<span class="sr-only">Dismiss</span>
|
||||||
|
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -1,23 +1,36 @@
|
|||||||
<% if form.object.errors.any? %>
|
<%# Usage: render "shared/form_errors", object: @user %>
|
||||||
<div class="rounded-md bg-red-50 p-4">
|
<%# Usage: render "shared/form_errors", form: form %>
|
||||||
|
|
||||||
|
<% form_object = form.respond_to?(:object) ? form.object : (object || form) %>
|
||||||
|
<% if form_object&.errors&.any? %>
|
||||||
|
<div class="rounded-md bg-red-50 p-4 mb-6 border border-red-200" role="alert" aria-labelledby="form-errors-title" data-form-errors-target="container">
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z" clip-rule="evenodd" />
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-3">
|
<div class="ml-3 flex-1">
|
||||||
<h3 class="text-sm font-medium text-red-800">
|
<h3 id="form-errors-title" class="text-sm font-medium text-red-800">
|
||||||
There were <%= pluralize(form.object.errors.count, "error") %> with your submission:
|
<%= pluralize(form_object.errors.count, "error") %> prohibited this <%= form_object.class.name.downcase.gsub(/^admin::/, '') %> from being saved:
|
||||||
</h3>
|
</h3>
|
||||||
<div class="mt-2 text-sm text-red-700">
|
<div class="mt-2">
|
||||||
<ul class="list-disc space-y-1 pl-5">
|
<ul class="list-disc space-y-1 pl-5 text-sm text-red-700">
|
||||||
<% form.object.errors.full_messages.each do |message| %>
|
<% form_object.errors.full_messages.each do |message| %>
|
||||||
<li><%= message %></li>
|
<li><%= message %></li>
|
||||||
<% end %>
|
<% end %>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="ml-auto pl-3">
|
||||||
|
<div class="-mx-1.5 -my-1.5">
|
||||||
|
<button type="button" data-action="click->form-errors#dismiss" class="inline-flex rounded-md bg-red-50 p-1.5 text-red-500 hover:bg-red-100 focus:outline-none focus:ring-2 focus:ring-red-600 focus:ring-offset-2 focus:ring-offset-red-50" aria-label="Dismiss">
|
||||||
|
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
@@ -90,7 +90,7 @@
|
|||||||
|
|
||||||
<!-- Sign Out -->
|
<!-- Sign Out -->
|
||||||
<li>
|
<li>
|
||||||
<%= link_to signout_path, data: { turbo_method: :delete }, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-red-600 hover:text-red-700 hover:bg-red-50" do %>
|
<%= link_to signout_path, data: { turbo_method: :delete, action: "click->mobile-sidebar#closeSidebar" }, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-red-600 hover:text-red-700 hover:bg-red-50" do %>
|
||||||
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -105,12 +105,18 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mobile sidebar overlay -->
|
<!-- Mobile sidebar overlay -->
|
||||||
<div class="relative z-50 lg:hidden hidden" id="mobile-sidebar-overlay">
|
<div class="relative z-50 lg:hidden hidden"
|
||||||
|
data-mobile-sidebar-target="sidebarOverlay"
|
||||||
|
id="mobile-sidebar-overlay"
|
||||||
|
data-action="click->mobile-sidebar#closeOnBackgroundClick">
|
||||||
<div class="fixed inset-0 bg-gray-900/80"></div>
|
<div class="fixed inset-0 bg-gray-900/80"></div>
|
||||||
<div class="fixed inset-0 flex">
|
<div class="fixed inset-0 flex">
|
||||||
<div class="relative mr-16 flex w-full max-w-xs flex-1">
|
<div class="relative mr-16 flex w-full max-w-xs flex-1">
|
||||||
<div class="absolute left-full top-0 flex w-16 justify-center pt-5">
|
<div class="absolute left-full top-0 flex w-16 justify-center pt-5">
|
||||||
<button type="button" class="-m-2.5 p-2.5" id="mobile-menu-close">
|
<button type="button"
|
||||||
|
class="-m-2.5 p-2.5"
|
||||||
|
id="mobile-menu-close"
|
||||||
|
data-action="click->mobile-sidebar#closeSidebar">
|
||||||
<span class="sr-only">Close sidebar</span>
|
<span class="sr-only">Close sidebar</span>
|
||||||
<svg class="h-6 w-6 text-white" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
<svg class="h-6 w-6 text-white" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
@@ -138,7 +144,7 @@
|
|||||||
<!-- Same nav items as desktop -->
|
<!-- Same nav items as desktop -->
|
||||||
<ul role="list" class="-mx-2 space-y-1">
|
<ul role="list" class="-mx-2 space-y-1">
|
||||||
<li>
|
<li>
|
||||||
<%= link_to root_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-gray-700 hover:text-blue-600 hover:bg-gray-50" do %>
|
<%= link_to root_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path == '/' ? 'bg-gray-50 text-blue-600' : 'text-gray-700 hover:text-blue-600 hover:bg-gray-50' }", data: { action: "click->mobile-sidebar#closeSidebar" } do %>
|
||||||
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -147,7 +153,7 @@
|
|||||||
</li>
|
</li>
|
||||||
<% if user.admin? %>
|
<% if user.admin? %>
|
||||||
<li>
|
<li>
|
||||||
<%= link_to admin_users_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-gray-700 hover:text-blue-600 hover:bg-gray-50" do %>
|
<%= link_to admin_users_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path.start_with?('/admin/users') ? 'bg-gray-50 text-blue-600' : 'text-gray-700 hover:text-blue-600 hover:bg-gray-50' }", data: { action: "click->mobile-sidebar#closeSidebar" } do %>
|
||||||
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -155,7 +161,7 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<%= link_to admin_applications_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-gray-700 hover:text-blue-600 hover:bg-gray-50" do %>
|
<%= link_to admin_applications_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path.start_with?('/admin/applications') ? 'bg-gray-50 text-blue-600' : 'text-gray-700 hover:text-blue-600 hover:bg-gray-50' }", data: { action: "click->mobile-sidebar#closeSidebar" } do %>
|
||||||
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -163,7 +169,7 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<%= link_to admin_groups_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-gray-700 hover:text-blue-600 hover:bg-gray-50" do %>
|
<%= link_to admin_groups_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path.start_with?('/admin/groups') ? 'bg-gray-50 text-blue-600' : 'text-gray-700 hover:text-blue-600 hover:bg-gray-50' }", data: { action: "click->mobile-sidebar#closeSidebar" } do %>
|
||||||
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -172,7 +178,7 @@
|
|||||||
</li>
|
</li>
|
||||||
<% end %>
|
<% end %>
|
||||||
<li>
|
<li>
|
||||||
<%= link_to profile_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-gray-700 hover:text-blue-600 hover:bg-gray-50" do %>
|
<%= link_to profile_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path == '/profile' ? 'bg-gray-50 text-blue-600' : 'text-gray-700 hover:text-blue-600 hover:bg-gray-50' }", data: { action: "click->mobile-sidebar#closeSidebar" } do %>
|
||||||
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M17.982 18.725A7.488 7.488 0 0012 15.75a7.488 7.488 0 00-5.982 2.975m11.963 0a9 9 0 10-11.963 0m11.963 0A8.966 8.966 0 0112 21a8.966 8.966 0 01-5.982-2.275M15 9.75a3 3 0 11-6 0 3 3 0 016 0z" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M17.982 18.725A7.488 7.488 0 0012 15.75a7.488 7.488 0 00-5.982 2.975m11.963 0a9 9 0 10-11.963 0m11.963 0A8.966 8.966 0 0112 21a8.966 8.966 0 01-5.982-2.275M15 9.75a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -180,7 +186,7 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<%= link_to active_sessions_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-gray-700 hover:text-blue-600 hover:bg-gray-50" do %>
|
<%= link_to active_sessions_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path == '/active_sessions' ? 'bg-gray-50 text-blue-600' : 'text-gray-700 hover:text-blue-600 hover:bg-gray-50' }", data: { action: "click->mobile-sidebar#closeSidebar" } do %>
|
||||||
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 18v-5.25m0 0a6.01 6.01 0 001.5-.189m-1.5.189a6.01 6.01 0 01-1.5-.189m3.75 7.478a12.06 12.06 0 01-4.5 0m3.75 2.383a14.406 14.406 0 01-3 0M14.25 18v-.192c0-.983.658-1.823 1.508-2.316a7.5 7.5 0 10-7.517 0c.85.493 1.509 1.333 1.509 2.316V18" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 18v-5.25m0 0a6.01 6.01 0 001.5-.189m-1.5.189a6.01 6.01 0 01-1.5-.189m3.75 7.478a12.06 12.06 0 01-4.5 0m3.75 2.383a14.406 14.406 0 01-3 0M14.25 18v-.192c0-.983.658-1.823 1.508-2.316a7.5 7.5 0 10-7.517 0c.85.493 1.509 1.333 1.509 2.316V18" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -188,7 +194,7 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<%= link_to signout_path, data: { turbo_method: :delete }, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-red-600 hover:text-red-700 hover:bg-red-50" do %>
|
<%= link_to signout_path, data: { turbo_method: :delete, action: "click->mobile-sidebar#closeSidebar" }, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-red-600 hover:text-red-700 hover:bg-red-50" do %>
|
||||||
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<div class="max-w-2xl mx-auto">
|
<div class="max-w-2xl mx-auto" data-controller="backup-codes" data-backup-codes-codes-value="<%= @backup_codes.to_json %>">
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<h1 class="text-3xl font-bold text-gray-900">Backup Codes</h1>
|
<h1 class="text-3xl font-bold text-gray-900">Backup Codes</h1>
|
||||||
<p class="mt-2 text-sm text-gray-600">
|
<p class="mt-2 text-sm text-gray-600">
|
||||||
@@ -29,14 +29,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-6 flex gap-3">
|
<div class="mt-6 flex gap-3">
|
||||||
<button onclick="downloadBackupCodes()" class="inline-flex items-center rounded-md border border-gray-300 bg-white py-2 px-4 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
|
<button data-action="click->backup-codes#download" class="inline-flex items-center rounded-md border border-gray-300 bg-white py-2 px-4 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
|
||||||
<svg class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||||
</svg>
|
</svg>
|
||||||
Download Codes
|
Download Codes
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button onclick="printBackupCodes()" class="inline-flex items-center rounded-md border border-gray-300 bg-white py-2 px-4 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
|
<button data-action="click->backup-codes#print" class="inline-flex items-center rounded-md border border-gray-300 bg-white py-2 px-4 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
|
||||||
<svg class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -52,27 +52,3 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
|
||||||
const backupCodes = <%= raw @backup_codes.to_json %>;
|
|
||||||
|
|
||||||
function downloadBackupCodes() {
|
|
||||||
const content = "Clinch Backup Codes\n" +
|
|
||||||
"===================\n\n" +
|
|
||||||
backupCodes.join("\n") +
|
|
||||||
"\n\nSave these codes in a secure location.";
|
|
||||||
|
|
||||||
const blob = new Blob([content], { type: 'text/plain' });
|
|
||||||
const url = window.URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = 'clinch-backup-codes.txt';
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
document.body.removeChild(a);
|
|
||||||
window.URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
function printBackupCodes() {
|
|
||||||
window.print();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|||||||
45
app/views/totp/regenerate_backup_codes.html.erb
Normal file
45
app/views/totp/regenerate_backup_codes.html.erb
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<div class="max-w-2xl mx-auto">
|
||||||
|
<div class="mb-8">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900">Regenerate Backup Codes</h1>
|
||||||
|
<p class="mt-2 text-sm text-gray-600">
|
||||||
|
This will invalidate all existing backup codes and generate new ones.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white shadow sm:rounded-lg">
|
||||||
|
<div class="px-4 py-5 sm:p-6">
|
||||||
|
<div class="rounded-md bg-yellow-50 p-4 mb-6">
|
||||||
|
<div class="flex">
|
||||||
|
<svg class="h-5 w-5 text-yellow-400 mr-3 flex-shrink-0" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
<div class="text-sm text-yellow-800">
|
||||||
|
<p class="font-medium">Important Security Notice</p>
|
||||||
|
<p class="mt-1">All your current backup codes will become invalid after this action. Make sure you're ready to save the new codes.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= form_with(url: create_new_backup_codes_totp_path, method: :post, class: "space-y-6") do |form| %>
|
||||||
|
<div>
|
||||||
|
<%= form.label :password, "Enter your password to confirm", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<div class="mt-1">
|
||||||
|
<%= form.password_field :password, required: true,
|
||||||
|
class: "block w-full appearance-none rounded-md border border-gray-300 px-3 py-2 placeholder-gray-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 sm:text-sm" %>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500">
|
||||||
|
This is required to verify your identity before regenerating backup codes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<%= form.submit "Generate New Backup Codes",
|
||||||
|
class: "inline-flex justify-center rounded-md border border-transparent bg-blue-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" %>
|
||||||
|
|
||||||
|
<%= link_to "Cancel", profile_path,
|
||||||
|
class: "inline-flex justify-center rounded-md border border-gray-300 bg-white py-2 px-4 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -4,17 +4,8 @@
|
|||||||
<p class="mt-2 text-gray-600">Create your admin account to get started</p>
|
<p class="mt-2 text-gray-600">Create your admin account to get started</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= form_with model: @user, url: signup_path, class: "contents" do |form| %>
|
<%= form_with model: @user, url: signup_path, class: "contents", data: { controller: "form-errors" } do |form| %>
|
||||||
<% if @user.errors.any? %>
|
<%= render "shared/form_errors", form: form %>
|
||||||
<div class="bg-red-50 text-red-500 px-3 py-2 font-medium rounded-lg mt-3">
|
|
||||||
<h2><%= pluralize(@user.errors.count, "error") %> prohibited this account from being saved:</h2>
|
|
||||||
<ul class="list-disc list-inside">
|
|
||||||
<% @user.errors.each do |error| %>
|
|
||||||
<li><%= error.full_message %></li>
|
|
||||||
<% end %>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<div class="my-5">
|
<div class="my-5">
|
||||||
<%= form.label :email_address, class: "block font-medium text-sm text-gray-700" %>
|
<%= form.label :email_address, class: "block font-medium text-sm text-gray-700" %>
|
||||||
|
|||||||
@@ -83,4 +83,14 @@ Rails.application.configure do
|
|||||||
|
|
||||||
# Apply autocorrection by RuboCop to files generated by `bin/rails generate`.
|
# Apply autocorrection by RuboCop to files generated by `bin/rails generate`.
|
||||||
# config.generators.apply_rubocop_autocorrect_after_generate!
|
# config.generators.apply_rubocop_autocorrect_after_generate!
|
||||||
|
|
||||||
|
# Sentry configuration for development
|
||||||
|
# Only enabled if SENTRY_DSN environment variable is set and explicitly enabled
|
||||||
|
if ENV["SENTRY_DSN"].present? && ENV["SENTRY_ENABLED_IN_DEVELOPMENT"] == "true"
|
||||||
|
config.sentry.enabled = true
|
||||||
|
|
||||||
|
# High sample rates for development debugging
|
||||||
|
config.sentry.traces_sample_rate = ENV.fetch("SENTRY_TRACES_SAMPLE_RATE", 0.5).to_f
|
||||||
|
config.sentry.profiles_sample_rate = ENV.fetch("SENTRY_PROFILES_SAMPLE_RATE", 0.2).to_f
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -80,14 +80,28 @@ Rails.application.configure do
|
|||||||
# Only use :id for inspections in production.
|
# Only use :id for inspections in production.
|
||||||
config.active_record.attributes_for_inspect = [ :id ]
|
config.active_record.attributes_for_inspect = [ :id ]
|
||||||
|
|
||||||
|
# Helper method to extract domain from CLINCH_HOST (removes protocol if present)
|
||||||
|
def self.extract_domain(host)
|
||||||
|
return host if host.blank?
|
||||||
|
# Remove protocol (http:// or https://) if present
|
||||||
|
host.gsub(/^https?:\/\//, '')
|
||||||
|
end
|
||||||
|
|
||||||
|
# Helper method to ensure URL has https:// protocol
|
||||||
|
def self.ensure_https(url)
|
||||||
|
return url if url.blank?
|
||||||
|
# Add https:// if no protocol is present
|
||||||
|
url.match?(/^https?:\/\//) ? url : "https://#{url}"
|
||||||
|
end
|
||||||
|
|
||||||
# Enable DNS rebinding protection and other `Host` header attacks.
|
# Enable DNS rebinding protection and other `Host` header attacks.
|
||||||
# Configure allowed hosts based on deployment scenario
|
# Configure allowed hosts based on deployment scenario
|
||||||
allowed_hosts = [
|
allowed_hosts = [
|
||||||
ENV.fetch('CLINCH_HOST', 'auth.example.com'), # External domain (auth service itself)
|
extract_domain(ENV.fetch('CLINCH_HOST', 'auth.example.com')), # External domain (auth service itself)
|
||||||
]
|
]
|
||||||
|
|
||||||
# Use PublicSuffix to extract registrable domain and allow all subdomains
|
# Use PublicSuffix to extract registrable domain and allow all subdomains
|
||||||
host_domain = ENV.fetch('CLINCH_HOST', 'auth.example.com')
|
host_domain = extract_domain(ENV.fetch('CLINCH_HOST', 'auth.example.com'))
|
||||||
if host_domain.present?
|
if host_domain.present?
|
||||||
begin
|
begin
|
||||||
# Use PublicSuffix to properly extract the domain
|
# Use PublicSuffix to properly extract the domain
|
||||||
@@ -133,4 +147,18 @@ Rails.application.configure do
|
|||||||
|
|
||||||
# Skip DNS rebinding protection for the default health check endpoint.
|
# Skip DNS rebinding protection for the default health check endpoint.
|
||||||
config.host_authorization = { exclude: ->(request) { request.path == "/up" } }
|
config.host_authorization = { exclude: ->(request) { request.path == "/up" } }
|
||||||
|
|
||||||
|
# Sentry configuration for production
|
||||||
|
# Only enabled if SENTRY_DSN environment variable is set
|
||||||
|
if ENV["SENTRY_DSN"].present?
|
||||||
|
config.sentry.enabled = true
|
||||||
|
|
||||||
|
# Performance monitoring: sample 20% of transactions for traces
|
||||||
|
# Adjust based on your traffic volume and Sentry plan limits
|
||||||
|
config.sentry.traces_sample_rate = ENV.fetch("SENTRY_TRACES_SAMPLE_RATE", 0.2).to_f
|
||||||
|
|
||||||
|
# Continuous profiling: disabled by default in production due to cost
|
||||||
|
# Enable temporarily for performance investigations if needed
|
||||||
|
config.sentry.profiles_sample_rate = ENV.fetch("SENTRY_PROFILES_SAMPLE_RATE", 0.0).to_f
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -50,4 +50,8 @@ Rails.application.configure do
|
|||||||
|
|
||||||
# Raise error when a before_action's only/except options reference missing actions.
|
# Raise error when a before_action's only/except options reference missing actions.
|
||||||
config.action_controller.raise_on_missing_callback_actions = true
|
config.action_controller.raise_on_missing_callback_actions = true
|
||||||
|
|
||||||
|
# Disable Sentry in test environment to avoid interference with tests
|
||||||
|
# Sentry can be explicitly enabled for integration testing if needed
|
||||||
|
ENV["SENTRY_ENABLED_IN_DEVELOPMENT"] = "false"
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -6,72 +6,64 @@
|
|||||||
|
|
||||||
Rails.application.configure do
|
Rails.application.configure do
|
||||||
config.content_security_policy do |policy|
|
config.content_security_policy do |policy|
|
||||||
# Default policy: only allow resources from same origin and HTTPS
|
# Default to self for everything, plus blob: for file downloads
|
||||||
policy.default_src :self, :https
|
policy.default_src :self, "blob:"
|
||||||
|
|
||||||
# Scripts: strict security with nonce support for dynamic content
|
# Scripts: Allow self, importmaps, unsafe-inline for Turbo/StimulusJS, and blob: for downloads
|
||||||
policy.script_src :self, :https, :strict_dynamic
|
# Note: unsafe_inline is needed for Stimulus controllers and Turbo navigation
|
||||||
|
policy.script_src :self, :unsafe_inline, :unsafe_eval, "blob:"
|
||||||
|
|
||||||
# Styles: allow inline styles for CSS frameworks, but require HTTPS
|
# Styles: Allow self and unsafe_inline for TailwindCSS dynamic classes
|
||||||
policy.style_src :self, :https, :unsafe_inline
|
# and Stimulus controller style manipulations
|
||||||
|
policy.style_src :self, :unsafe_inline
|
||||||
|
|
||||||
# Images: allow data URIs for inline images and HTTPS sources
|
# Images: Allow self, data URLs, and https for external images
|
||||||
policy.img_src :self, :https, :data
|
policy.img_src :self, :data, :https
|
||||||
|
|
||||||
# Fonts: allow self-hosted and HTTPS fonts, plus data URIs
|
# Fonts: Allow self and data URLs
|
||||||
policy.font_src :self, :https, :data
|
policy.font_src :self, :data
|
||||||
|
|
||||||
# Media: allow self and HTTPS media sources
|
# Connect: Allow self for API calls, WebAuthn, and ActionCable if needed
|
||||||
policy.media_src :self, :https
|
# WebAuthn endpoints are on the same domain, so self is sufficient
|
||||||
|
policy.connect_src :self, "wss:"
|
||||||
|
|
||||||
# Objects: block potentially dangerous plugins
|
# Media: Allow self
|
||||||
|
policy.media_src :self
|
||||||
|
|
||||||
|
# Object and embed sources: Disallow for security (no Flash/etc)
|
||||||
policy.object_src :none
|
policy.object_src :none
|
||||||
|
policy.frame_src :none
|
||||||
# Base URI: restrict base tag to same origin
|
|
||||||
policy.base_uri :self
|
|
||||||
|
|
||||||
# Form actions: only allow forms to submit to same origin
|
|
||||||
policy.form_action :self
|
|
||||||
|
|
||||||
# Frame ancestors: prevent clickjacking by disallowing framing
|
|
||||||
policy.frame_ancestors :none
|
policy.frame_ancestors :none
|
||||||
|
|
||||||
# Frame sources: block iframes unless explicitly needed
|
# Base URI: Restricted to self
|
||||||
policy.frame_src :none
|
policy.base_uri :self
|
||||||
|
|
||||||
# Connect sources: control where XHR/Fetch can connect
|
# Form actions: Allow self for all form submissions
|
||||||
policy.connect_src :self, :https
|
# Note: OAuth redirects will be handled dynamically in the consent page
|
||||||
|
policy.form_action :self
|
||||||
|
|
||||||
# Manifest: only allow same-origin manifest files
|
# Manifest sources: Allow self for PWA manifest
|
||||||
policy.manifest_src :self
|
policy.manifest_src :self
|
||||||
|
|
||||||
# Worker sources: control web worker origins
|
# Worker sources: Allow self for potential Web Workers
|
||||||
policy.worker_src :self, :https
|
policy.worker_src :self
|
||||||
|
|
||||||
# Report URI: send violation reports to our monitoring endpoint
|
# Child sources: Allow self for any future iframes
|
||||||
if Rails.env.production?
|
policy.child_src :self
|
||||||
policy.report_uri "/api/csp-violation-report"
|
|
||||||
end
|
# Additional security headers for WebAuthn
|
||||||
|
# Required for WebAuthn to work properly
|
||||||
|
policy.require_trusted_types_for :none
|
||||||
|
|
||||||
|
# CSP reporting using report_uri (supported method)
|
||||||
|
policy.report_uri "/api/csp-violation-report"
|
||||||
end
|
end
|
||||||
|
|
||||||
# Generate session nonces for permitted inline scripts and styles
|
|
||||||
config.content_security_policy_nonce_generator = ->(request) {
|
# Start with CSP in report-only mode for testing
|
||||||
# Use a secure random nonce instead of session ID for better security
|
# Set to false after verifying everything works in production
|
||||||
SecureRandom.base64(16)
|
config.content_security_policy_report_only = Rails.env.development?
|
||||||
}
|
|
||||||
|
|
||||||
# Apply nonces to script and style directives
|
# Report CSP violations (optional - uncomment to enable)
|
||||||
config.content_security_policy_nonce_directives = %w(script-src style-src)
|
# config.content_security_policy_report_uri = "/csp-violations"
|
||||||
|
end
|
||||||
# Automatically add `nonce` attributes to script/style tags
|
|
||||||
config.content_security_policy_nonce_auto = true
|
|
||||||
|
|
||||||
# Enforce CSP in production, but use report-only in development for debugging
|
|
||||||
if Rails.env.production?
|
|
||||||
# Enforce the policy in production
|
|
||||||
config.content_security_policy_report_only = false
|
|
||||||
else
|
|
||||||
# Report violations only in development (helps with debugging)
|
|
||||||
config.content_security_policy_report_only = true
|
|
||||||
end
|
|
||||||
end
|
|
||||||
128
config/initializers/csp_local_logger.rb
Normal file
128
config/initializers/csp_local_logger.rb
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
# Local file logger for CSP violations
|
||||||
|
# Provides local logging even when Sentry is not configured
|
||||||
|
|
||||||
|
Rails.application.config.after_initialize do
|
||||||
|
# Create a dedicated logger for CSP violations
|
||||||
|
csp_log_path = Rails.root.join("log", "csp_violations.log")
|
||||||
|
|
||||||
|
# Configure log rotation
|
||||||
|
csp_logger = Logger.new(
|
||||||
|
csp_log_path,
|
||||||
|
'daily', # Rotate daily
|
||||||
|
30 # Keep 30 old log files
|
||||||
|
)
|
||||||
|
|
||||||
|
csp_logger.level = Logger::INFO
|
||||||
|
|
||||||
|
# Format: [TIMESTAMP] LEVEL MESSAGE
|
||||||
|
csp_logger.formatter = proc do |severity, datetime, progname, msg|
|
||||||
|
"[#{datetime.strftime('%Y-%m-%d %H:%M:%S')}] #{severity} #{msg}\n"
|
||||||
|
end
|
||||||
|
|
||||||
|
module CspViolationLocalLogger
|
||||||
|
def self.emit(event)
|
||||||
|
csp_data = event[:payload] || {}
|
||||||
|
|
||||||
|
# Skip logging if there's no meaningful violation data
|
||||||
|
return if csp_data.empty? ||
|
||||||
|
(csp_data[:violated_directive].nil? &&
|
||||||
|
csp_data[:blocked_uri].nil? &&
|
||||||
|
csp_data[:document_uri].nil?)
|
||||||
|
|
||||||
|
# Build a structured log message
|
||||||
|
violated_directive = csp_data[:violated_directive] || "unknown"
|
||||||
|
blocked_uri = csp_data[:blocked_uri] || "unknown"
|
||||||
|
document_uri = csp_data[:document_uri] || "unknown"
|
||||||
|
|
||||||
|
# Create a comprehensive log entry
|
||||||
|
log_message = "CSP VIOLATION DETECTED\n"
|
||||||
|
log_message += " Directive: #{violated_directive}\n"
|
||||||
|
log_message += " Blocked URI: #{blocked_uri}\n"
|
||||||
|
log_message += " Document URI: #{document_uri}\n"
|
||||||
|
log_message += " User Agent: #{csp_data[:user_agent]}\n"
|
||||||
|
log_message += " IP Address: #{csp_data[:ip_address]}\n"
|
||||||
|
log_message += " Timestamp: #{csp_data[:timestamp]}\n"
|
||||||
|
|
||||||
|
if csp_data[:current_user_id].present?
|
||||||
|
log_message += " Authenticated User ID: #{csp_data[:current_user_id]}\n"
|
||||||
|
log_message += " Session ID: #{csp_data[:session_id]}\n"
|
||||||
|
else
|
||||||
|
log_message += " User: Anonymous\n"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Add additional details if available
|
||||||
|
if csp_data[:source_file].present?
|
||||||
|
log_message += " Source File: #{csp_data[:source_file]}"
|
||||||
|
log_message += ":#{csp_data[:line_number]}" if csp_data[:line_number].present?
|
||||||
|
log_message += ":#{csp_data[:column_number]}" if csp_data[:column_number].present?
|
||||||
|
log_message += "\n"
|
||||||
|
end
|
||||||
|
|
||||||
|
if csp_data[:referrer].present?
|
||||||
|
log_message += " Referrer: #{csp_data[:referrer]}\n"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Determine severity for log level
|
||||||
|
level = determine_log_level(csp_data[:violated_directive])
|
||||||
|
|
||||||
|
self.csp_logger.log(level, log_message)
|
||||||
|
|
||||||
|
# Also log to main Rails logger for visibility
|
||||||
|
Rails.logger.info "CSP violation logged to csp_violations.log: #{violated_directive} - #{blocked_uri}"
|
||||||
|
|
||||||
|
rescue => e
|
||||||
|
# Ensure logger errors don't break the CSP reporting flow
|
||||||
|
Rails.logger.error "Failed to log CSP violation to file: #{e.message}"
|
||||||
|
Rails.logger.error e.backtrace.join("\n") if Rails.env.development?
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.csp_logger
|
||||||
|
@csp_logger ||= begin
|
||||||
|
csp_log_path = Rails.root.join("log", "csp_violations.log")
|
||||||
|
logger = Logger.new(
|
||||||
|
csp_log_path,
|
||||||
|
'daily', # Rotate daily
|
||||||
|
30 # Keep 30 old log files
|
||||||
|
)
|
||||||
|
logger.level = Logger::INFO
|
||||||
|
logger.formatter = proc do |severity, datetime, progname, msg|
|
||||||
|
"[#{datetime.strftime('%Y-%m-%d %H:%M:%S')}] #{severity} #{msg}\n"
|
||||||
|
end
|
||||||
|
logger
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def self.determine_log_level(violated_directive)
|
||||||
|
return Logger::INFO unless violated_directive.present?
|
||||||
|
|
||||||
|
case violated_directive.to_sym
|
||||||
|
when :script_src, :script_src_elem, :script_src_attr, :frame_src, :child_src
|
||||||
|
Logger::WARN # Higher priority violations
|
||||||
|
when :connect_src, :default_src, :style_src, :style_src_elem, :style_src_attr
|
||||||
|
Logger::INFO # Medium priority violations
|
||||||
|
else
|
||||||
|
Logger::DEBUG # Lower priority violations
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Register the local logger subscriber
|
||||||
|
Rails.event.subscribe(CspViolationLocalLogger)
|
||||||
|
|
||||||
|
Rails.logger.info "CSP violation local logger registered - logging to: #{csp_log_path}"
|
||||||
|
|
||||||
|
# Ensure the log file is created and writable
|
||||||
|
begin
|
||||||
|
# Create log file if it doesn't exist
|
||||||
|
FileUtils.touch(csp_log_path) unless File.exist?(csp_log_path)
|
||||||
|
|
||||||
|
# Test write to ensure permissions are correct
|
||||||
|
csp_logger.info "CSP Logger initialized at #{Time.current}"
|
||||||
|
|
||||||
|
rescue => e
|
||||||
|
Rails.logger.error "Failed to initialize CSP local logger: #{e.message}"
|
||||||
|
Rails.logger.error "CSP violations will only be sent to Sentry (if configured)"
|
||||||
|
end
|
||||||
|
end
|
||||||
140
config/initializers/sentry.rb
Normal file
140
config/initializers/sentry.rb
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
# Sentry configuration for error tracking and performance monitoring
|
||||||
|
# Only initializes if SENTRY_DSN environment variable is set
|
||||||
|
|
||||||
|
return unless ENV["SENTRY_DSN"].present?
|
||||||
|
|
||||||
|
Rails.application.configure do
|
||||||
|
config.sentry.dsn = ENV["SENTRY_DSN"]
|
||||||
|
|
||||||
|
# Set environment (defaults to Rails.env)
|
||||||
|
config.sentry.environment = ENV["SENTRY_ENVIRONMENT"] || Rails.env
|
||||||
|
|
||||||
|
# Set release version from Git or environment variable
|
||||||
|
config.sentry.release = ENV["SENTRY_RELEASE"] || `git rev-parse HEAD 2>/dev/null`.strip.presence || nil
|
||||||
|
|
||||||
|
# Sample rate for performance monitoring (0.0 to 1.0)
|
||||||
|
config.sentry.traces_sample_rate = ENV.fetch("SENTRY_TRACES_SAMPLE_RATE", 0.1).to_f
|
||||||
|
|
||||||
|
# Enable profiling in development/staging, disable in production unless explicitly enabled
|
||||||
|
config.sentry.profiles_sample_rate = if Rails.env.production?
|
||||||
|
ENV.fetch("SENTRY_PROFILES_SAMPLE_RATE", 0.0).to_f
|
||||||
|
else
|
||||||
|
ENV.fetch("SENTRY_PROFILES_SAMPLE_RATE", 0.5).to_f
|
||||||
|
end
|
||||||
|
|
||||||
|
# Include additional context
|
||||||
|
config.sentry.before_send = lambda do |event, hint|
|
||||||
|
# Filter out sensitive information
|
||||||
|
if event.context[:extra]
|
||||||
|
event.context[:extra].reject! { |key, value|
|
||||||
|
key.to_s.match?(/password|secret|token|key/i) || value.to_s.match?(/password|secret/i)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Filter sensitive parameters
|
||||||
|
if event.context[:request]
|
||||||
|
event.context[:request].reject! { |key, value|
|
||||||
|
key.to_s.match?(/password|secret|token|key|authorization/i)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
event
|
||||||
|
end
|
||||||
|
|
||||||
|
# Include breadcrumbs for debugging
|
||||||
|
config.sentry.breadcrumbs_logger = [:active_support_logger, :http_logger]
|
||||||
|
|
||||||
|
# Send session data for user context
|
||||||
|
config.sentry.user_context = lambda do
|
||||||
|
if Current.user.present?
|
||||||
|
{
|
||||||
|
id: Current.user.id,
|
||||||
|
email: Current.user.email_address,
|
||||||
|
admin: Current.user.admin?
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Ignore common non-critical exceptions
|
||||||
|
config.sentry.excluded_exceptions += [
|
||||||
|
"ActionController::RoutingError",
|
||||||
|
"ActionController::InvalidAuthenticityToken",
|
||||||
|
"ActionController::UnknownFormat",
|
||||||
|
"ActionDispatch::Http::Parameters::ParseError",
|
||||||
|
"Rack::QueryParser::InvalidParameterError",
|
||||||
|
"Rack::Timeout::RequestTimeoutException",
|
||||||
|
"ActiveRecord::RecordNotFound"
|
||||||
|
]
|
||||||
|
|
||||||
|
# Add CSP-specific tags for security events
|
||||||
|
config.sentry.tags = lambda do
|
||||||
|
{
|
||||||
|
# Add application context
|
||||||
|
app_name: "clinch",
|
||||||
|
app_environment: Rails.env,
|
||||||
|
# Add CSP policy status
|
||||||
|
csp_enabled: defined?(Rails.application.config.content_security_policy) &&
|
||||||
|
Rails.application.config.content_security_policy.present?
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Enhance before_send to handle CSP events properly
|
||||||
|
config.sentry.before_send = lambda do |event, hint|
|
||||||
|
# Filter out sensitive information
|
||||||
|
if event.context[:extra]
|
||||||
|
event.context[:extra].reject! { |key, value|
|
||||||
|
key.to_s.match?(/password|secret|token|key/i) || value.to_s.match?(/password|secret/i)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Filter sensitive parameters
|
||||||
|
if event.context[:request]
|
||||||
|
event.context[:request].reject! { |key, value|
|
||||||
|
key.to_s.match?(/password|secret|token|key|authorization/i)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Special handling for CSP violations
|
||||||
|
if event.tags&.dig(:csp_violation)
|
||||||
|
# Ensure CSP violations have proper security context
|
||||||
|
event.context[:server] = event.context[:server] || {}
|
||||||
|
event.context[:server][:name] = "clinch-auth-service"
|
||||||
|
event.context[:server][:environment] = Rails.env
|
||||||
|
|
||||||
|
# Add additional security context
|
||||||
|
event.context[:extra] ||= {}
|
||||||
|
event.context[:extra][:security_context] = {
|
||||||
|
csp_reporting: true,
|
||||||
|
user_authenticated: event.context[:user].present?,
|
||||||
|
request_origin: event.context[:request]&.dig(:headers, "Origin"),
|
||||||
|
request_referer: event.context[:request]&.dig(:headers, "Referer")
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
event
|
||||||
|
end
|
||||||
|
|
||||||
|
# Add CSP-specific breadcrumbs for security events
|
||||||
|
config.sentry.before_breadcrumb = lambda do |breadcrumb, hint|
|
||||||
|
# Filter out sensitive breadcrumb data
|
||||||
|
if breadcrumb[:data]
|
||||||
|
breadcrumb[:data].reject! { |key, value|
|
||||||
|
key.to_s.match?(/password|secret|token|key|authorization/i) ||
|
||||||
|
value.to_s.match?(/password|secret/i)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Mark CSP-related events
|
||||||
|
if breadcrumb[:message]&.include?("CSP Violation") ||
|
||||||
|
breadcrumb[:category]&.include?("csp")
|
||||||
|
breadcrumb[:data] ||= {}
|
||||||
|
breadcrumb[:data][:security_event] = true
|
||||||
|
breadcrumb[:data][:csp_violation] = true
|
||||||
|
end
|
||||||
|
|
||||||
|
breadcrumb
|
||||||
|
end
|
||||||
|
|
||||||
|
# Only send errors in production unless explicitly enabled
|
||||||
|
config.sentry.enabled = Rails.env.production? || ENV["SENTRY_ENABLED_IN_DEVELOPMENT"] == "true"
|
||||||
|
end
|
||||||
120
config/initializers/sentry_subscriber.rb
Normal file
120
config/initializers/sentry_subscriber.rb
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
# Sentry subscriber for CSP violations via Structured Event Reporting
|
||||||
|
# This subscriber only sends events to Sentry if Sentry is properly initialized
|
||||||
|
|
||||||
|
Rails.application.config.after_initialize do
|
||||||
|
# Only register the subscriber if Sentry is available and configured
|
||||||
|
if defined?(Sentry) && Sentry.initialized?
|
||||||
|
|
||||||
|
module CspViolationSentrySubscriber
|
||||||
|
def self.emit(event)
|
||||||
|
# Extract relevant CSP violation data
|
||||||
|
csp_data = event[:payload] || {}
|
||||||
|
|
||||||
|
# Build a descriptive message for Sentry
|
||||||
|
violated_directive = csp_data[:violated_directive]
|
||||||
|
blocked_uri = csp_data[:blocked_uri]
|
||||||
|
document_uri = csp_data[:document_uri]
|
||||||
|
|
||||||
|
message = "CSP Violation: #{violated_directive}"
|
||||||
|
message += " - Blocked: #{blocked_uri}" if blocked_uri.present?
|
||||||
|
message += " - On: #{document_uri}" if document_uri.present?
|
||||||
|
|
||||||
|
# Extract domain from blocked_uri for better classification
|
||||||
|
blocked_domain = extract_domain(blocked_uri) if blocked_uri.present?
|
||||||
|
|
||||||
|
# Determine severity based on violation type
|
||||||
|
level = determine_severity(violated_directive, blocked_uri)
|
||||||
|
|
||||||
|
# Send to Sentry with rich context
|
||||||
|
Sentry.capture_message(
|
||||||
|
message,
|
||||||
|
level: level,
|
||||||
|
tags: {
|
||||||
|
csp_violation: true,
|
||||||
|
violated_directive: violated_directive,
|
||||||
|
blocked_domain: blocked_domain,
|
||||||
|
document_domain: extract_domain(document_uri),
|
||||||
|
user_authenticated: csp_data[:current_user_id].present?
|
||||||
|
},
|
||||||
|
extra: {
|
||||||
|
# Full CSP report data
|
||||||
|
csp_violation_details: csp_data,
|
||||||
|
# Additional context for security analysis
|
||||||
|
request_context: {
|
||||||
|
user_agent: csp_data[:user_agent],
|
||||||
|
ip_address: csp_data[:ip_address],
|
||||||
|
session_id: csp_data[:session_id],
|
||||||
|
timestamp: csp_data[:timestamp]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
user: csp_data[:current_user_id] ? { id: csp_data[:current_user_id] } : nil
|
||||||
|
)
|
||||||
|
|
||||||
|
# Log to Rails logger for redundancy
|
||||||
|
Rails.logger.info "CSP violation sent to Sentry: #{message}"
|
||||||
|
rescue => e
|
||||||
|
# Ensure subscriber errors don't break the CSP reporting flow
|
||||||
|
Rails.logger.error "Failed to send CSP violation to Sentry: #{e.message}"
|
||||||
|
Rails.logger.error e.backtrace.join("\n") if Rails.env.development?
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
# Extract domain from URI for better analysis
|
||||||
|
def self.extract_domain(uri)
|
||||||
|
return nil if uri.blank?
|
||||||
|
|
||||||
|
begin
|
||||||
|
parsed = URI.parse(uri)
|
||||||
|
parsed.host
|
||||||
|
rescue URI::InvalidURIError
|
||||||
|
# Handle cases where URI might be malformed or just a path
|
||||||
|
if uri.start_with?('/')
|
||||||
|
nil # It's a relative path, no domain
|
||||||
|
else
|
||||||
|
uri.split('/').first # Best effort extraction
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Determine severity level based on violation type
|
||||||
|
def self.determine_severity(violated_directive, blocked_uri)
|
||||||
|
return :warning unless violated_directive.present?
|
||||||
|
|
||||||
|
case violated_directive.to_sym
|
||||||
|
when :script_src, :script_src_elem, :script_src_attr
|
||||||
|
# Script violations are highest priority (XSS risk)
|
||||||
|
:error
|
||||||
|
when :style_src, :style_src_elem, :style_src_attr
|
||||||
|
# Style violations are moderate risk
|
||||||
|
:warning
|
||||||
|
when :img_src
|
||||||
|
# Image violations are typically lower priority
|
||||||
|
:info
|
||||||
|
when :connect_src
|
||||||
|
# Network violations are important
|
||||||
|
:warning
|
||||||
|
when :font_src, :media_src
|
||||||
|
# Font/media violations are lower priority
|
||||||
|
:info
|
||||||
|
when :frame_src, :child_src
|
||||||
|
# Frame violations can be security critical
|
||||||
|
:error
|
||||||
|
when :default_src
|
||||||
|
# Default src violations are important
|
||||||
|
:warning
|
||||||
|
else
|
||||||
|
# Unknown or custom directives
|
||||||
|
:warning
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Register the subscriber for CSP violation events
|
||||||
|
Rails.event.subscribe(CspViolationSentrySubscriber)
|
||||||
|
|
||||||
|
Rails.logger.info "CSP violation Sentry subscriber registered"
|
||||||
|
else
|
||||||
|
Rails.logger.info "Sentry not initialized - CSP violations will only be logged locally"
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,14 +1,31 @@
|
|||||||
# WebAuthn configuration for Clinch Identity Provider
|
# WebAuthn configuration for Clinch Identity Provider
|
||||||
WebAuthn.configure do |config|
|
WebAuthn.configure do |config|
|
||||||
# Relying Party name (displayed in authenticator prompts)
|
# Relying Party name (displayed in authenticator prompts)
|
||||||
# For development, use http://localhost to match passkey in Passwords app
|
# CLINCH_HOST should include protocol (https://) for WebAuthn
|
||||||
origin_host = ENV.fetch("CLINCH_HOST", "http://localhost")
|
origin_host = ENV.fetch("CLINCH_HOST", "http://localhost")
|
||||||
config.allowed_origins = [origin_host]
|
config.allowed_origins = [origin_host]
|
||||||
|
|
||||||
# Relying Party ID (must match origin domain)
|
# Relying Party ID (must match origin domain without protocol)
|
||||||
# Extract domain from origin for RP ID
|
# Extract domain from origin for RP ID if CLINCH_RP_ID not set
|
||||||
origin_uri = URI.parse(origin_host)
|
if ENV["CLINCH_RP_ID"].present?
|
||||||
config.rp_id = ENV.fetch("CLINCH_RP_ID", "localhost")
|
config.rp_id = ENV["CLINCH_RP_ID"]
|
||||||
|
else
|
||||||
|
# Extract registrable domain from CLINCH_HOST using PublicSuffix
|
||||||
|
origin_uri = URI.parse(origin_host)
|
||||||
|
if origin_uri.host
|
||||||
|
begin
|
||||||
|
# Use PublicSuffix to get the registrable domain (e.g., "aapamilne.com" from "auth.aapamilne.com")
|
||||||
|
domain = PublicSuffix.parse(origin_uri.host)
|
||||||
|
config.rp_id = domain.domain || origin_uri.host
|
||||||
|
rescue PublicSuffix::DomainInvalid => e
|
||||||
|
Rails.logger.warn "WebAuthn: Failed to parse domain '#{origin_uri.host}': #{e.message}, using host as fallback"
|
||||||
|
config.rp_id = origin_uri.host
|
||||||
|
end
|
||||||
|
else
|
||||||
|
Rails.logger.error "WebAuthn: Could not extract host from CLINCH_HOST '#{origin_host}'"
|
||||||
|
config.rp_id = "localhost"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# For development, we also allow localhost with common ports and without port
|
# For development, we also allow localhost with common ports and without port
|
||||||
if Rails.env.development?
|
if Rails.env.development?
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ Rails.application.routes.draw do
|
|||||||
get "/oauth/authorize", to: "oidc#authorize"
|
get "/oauth/authorize", to: "oidc#authorize"
|
||||||
post "/oauth/authorize/consent", to: "oidc#consent", as: :oauth_consent
|
post "/oauth/authorize/consent", to: "oidc#consent", as: :oauth_consent
|
||||||
post "/oauth/token", to: "oidc#token"
|
post "/oauth/token", to: "oidc#token"
|
||||||
|
post "/oauth/revoke", to: "oidc#revoke"
|
||||||
get "/oauth/userinfo", to: "oidc#userinfo"
|
get "/oauth/userinfo", to: "oidc#userinfo"
|
||||||
get "/logout", to: "oidc#logout"
|
get "/logout", to: "oidc#logout"
|
||||||
|
|
||||||
@@ -64,6 +65,8 @@ Rails.application.routes.draw do
|
|||||||
delete '/totp', to: 'totp#destroy'
|
delete '/totp', to: 'totp#destroy'
|
||||||
get '/totp/backup_codes', to: 'totp#backup_codes', as: :backup_codes_totp
|
get '/totp/backup_codes', to: 'totp#backup_codes', as: :backup_codes_totp
|
||||||
post '/totp/verify_password', to: 'totp#verify_password', as: :verify_password_totp
|
post '/totp/verify_password', to: 'totp#verify_password', as: :verify_password_totp
|
||||||
|
get '/totp/regenerate_backup_codes', to: 'totp#regenerate_backup_codes', as: :regenerate_backup_codes_totp
|
||||||
|
post '/totp/regenerate_backup_codes', to: 'totp#create_new_backup_codes', as: :create_new_backup_codes_totp
|
||||||
|
|
||||||
# WebAuthn (Passkeys) routes
|
# WebAuthn (Passkeys) routes
|
||||||
get '/webauthn/new', to: 'webauthn#new', as: :new_webauthn
|
get '/webauthn/new', to: 'webauthn#new', as: :new_webauthn
|
||||||
|
|||||||
13
db/migrate/20251104061455_clear_existing_backup_codes.rb
Normal file
13
db/migrate/20251104061455_clear_existing_backup_codes.rb
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
class ClearExistingBackupCodes < ActiveRecord::Migration[8.1]
|
||||||
|
def up
|
||||||
|
# Clear all existing backup codes to force regeneration with BCrypt hashing
|
||||||
|
# This is a security migration to move from plain text to hashed storage
|
||||||
|
User.where.not(backup_codes: nil).update_all(backup_codes: nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
def down
|
||||||
|
# This migration cannot be safely reversed
|
||||||
|
# as the original plain text codes cannot be recovered
|
||||||
|
raise ActiveRecord::IrreversibleMigration
|
||||||
|
end
|
||||||
|
end
|
||||||
12
db/migrate/20251104064114_change_backup_codes_to_json.rb
Normal file
12
db/migrate/20251104064114_change_backup_codes_to_json.rb
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
class ChangeBackupCodesToJson < ActiveRecord::Migration[8.1]
|
||||||
|
def up
|
||||||
|
# Change the column type from text to json
|
||||||
|
# This will automatically handle JSON serialization/deserialization
|
||||||
|
change_column :users, :backup_codes, :json
|
||||||
|
end
|
||||||
|
|
||||||
|
def down
|
||||||
|
# Revert back to text if needed
|
||||||
|
change_column :users, :backup_codes, :text
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
class AddPkceSupportToOidcAuthorizationCodes < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
add_column :oidc_authorization_codes, :code_challenge, :string
|
||||||
|
add_column :oidc_authorization_codes, :code_challenge_method, :string
|
||||||
|
|
||||||
|
# Add index for code_challenge to improve query performance
|
||||||
|
add_index :oidc_authorization_codes, :code_challenge
|
||||||
|
end
|
||||||
|
end
|
||||||
17
db/migrate/20251109011443_fix_empty_domain_patterns.rb
Normal file
17
db/migrate/20251109011443_fix_empty_domain_patterns.rb
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
class FixEmptyDomainPatterns < ActiveRecord::Migration[8.1]
|
||||||
|
def up
|
||||||
|
# Convert empty string domain_patterns to NULL
|
||||||
|
# This fixes a unique constraint issue where multiple OIDC apps
|
||||||
|
# had empty string domain_patterns, causing uniqueness violations
|
||||||
|
execute <<-SQL
|
||||||
|
UPDATE applications
|
||||||
|
SET domain_pattern = NULL
|
||||||
|
WHERE domain_pattern = ''
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
|
||||||
|
def down
|
||||||
|
# No need to reverse this - empty strings and NULL are functionally equivalent
|
||||||
|
# for OIDC applications where domain_pattern is not used
|
||||||
|
end
|
||||||
|
end
|
||||||
22
db/migrate/20251112114852_create_oidc_refresh_tokens.rb
Normal file
22
db/migrate/20251112114852_create_oidc_refresh_tokens.rb
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
class CreateOidcRefreshTokens < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
create_table :oidc_refresh_tokens do |t|
|
||||||
|
t.string :token_digest, null: false # BCrypt hashed token
|
||||||
|
t.references :application, null: false, foreign_key: true
|
||||||
|
t.references :user, null: false, foreign_key: true
|
||||||
|
t.references :oidc_access_token, null: false, foreign_key: true
|
||||||
|
t.string :scope
|
||||||
|
t.datetime :expires_at, null: false
|
||||||
|
t.datetime :revoked_at
|
||||||
|
t.integer :token_family_id # For token rotation detection
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
|
||||||
|
add_index :oidc_refresh_tokens, :token_digest, unique: true
|
||||||
|
add_index :oidc_refresh_tokens, :expires_at
|
||||||
|
add_index :oidc_refresh_tokens, :revoked_at
|
||||||
|
add_index :oidc_refresh_tokens, :token_family_id
|
||||||
|
add_index :oidc_refresh_tokens, [ :application_id, :user_id ]
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
class AddTokenDigestToOidcAccessTokens < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
add_column :oidc_access_tokens, :token_digest, :string
|
||||||
|
add_column :oidc_access_tokens, :revoked_at, :datetime
|
||||||
|
|
||||||
|
add_index :oidc_access_tokens, :token_digest, unique: true
|
||||||
|
add_index :oidc_access_tokens, :revoked_at
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
class AddTokenExpiryToApplications < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
add_column :applications, :access_token_ttl, :integer, default: 3600 # 1 hour in seconds
|
||||||
|
add_column :applications, :refresh_token_ttl, :integer, default: 2592000 # 30 days in seconds
|
||||||
|
add_column :applications, :id_token_ttl, :integer, default: 3600 # 1 hour in seconds
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
class MakeOidcAccessTokenTokenNullable < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
change_column_null :oidc_access_tokens, :token, true
|
||||||
|
end
|
||||||
|
end
|
||||||
40
db/schema.rb
generated
40
db/schema.rb
generated
@@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[8.1].define(version: 2025_11_04_054909) do
|
ActiveRecord::Schema[8.1].define(version: 2025_11_12_120314) do
|
||||||
create_table "application_groups", force: :cascade do |t|
|
create_table "application_groups", force: :cascade do |t|
|
||||||
t.integer "application_id", null: false
|
t.integer "application_id", null: false
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
@@ -22,6 +22,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_04_054909) do
|
|||||||
end
|
end
|
||||||
|
|
||||||
create_table "applications", force: :cascade do |t|
|
create_table "applications", force: :cascade do |t|
|
||||||
|
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 "client_id"
|
t.string "client_id"
|
||||||
@@ -30,10 +31,12 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_04_054909) do
|
|||||||
t.text "description"
|
t.text "description"
|
||||||
t.string "domain_pattern"
|
t.string "domain_pattern"
|
||||||
t.json "headers_config", default: {}, null: false
|
t.json "headers_config", default: {}, null: false
|
||||||
|
t.integer "id_token_ttl", default: 3600
|
||||||
t.string "landing_url"
|
t.string "landing_url"
|
||||||
t.text "metadata"
|
t.text "metadata"
|
||||||
t.string "name", null: false
|
t.string "name", null: false
|
||||||
t.text "redirect_uris"
|
t.text "redirect_uris"
|
||||||
|
t.integer "refresh_token_ttl", default: 2592000
|
||||||
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"
|
||||||
@@ -55,20 +58,26 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_04_054909) do
|
|||||||
t.integer "application_id", null: false
|
t.integer "application_id", 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.datetime "revoked_at"
|
||||||
t.string "scope"
|
t.string "scope"
|
||||||
t.string "token", null: false
|
t.string "token"
|
||||||
|
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 ["token"], name: "index_oidc_access_tokens_on_token", unique: true
|
t.index ["token"], name: "index_oidc_access_tokens_on_token", unique: true
|
||||||
|
t.index ["token_digest"], name: "index_oidc_access_tokens_on_token_digest", unique: true
|
||||||
t.index ["user_id"], name: "index_oidc_access_tokens_on_user_id"
|
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.integer "application_id", null: false
|
t.integer "application_id", null: false
|
||||||
t.string "code", null: false
|
t.string "code", null: false
|
||||||
|
t.string "code_challenge"
|
||||||
|
t.string "code_challenge_method"
|
||||||
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"
|
||||||
@@ -80,10 +89,32 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_04_054909) do
|
|||||||
t.index ["application_id", "user_id"], name: "index_oidc_authorization_codes_on_application_id_and_user_id"
|
t.index ["application_id", "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"], name: "index_oidc_authorization_codes_on_code", unique: true
|
||||||
|
t.index ["code_challenge"], name: "index_oidc_authorization_codes_on_code_challenge"
|
||||||
t.index ["expires_at"], name: "index_oidc_authorization_codes_on_expires_at"
|
t.index ["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|
|
||||||
|
t.integer "application_id", null: false
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "expires_at", null: false
|
||||||
|
t.integer "oidc_access_token_id", null: false
|
||||||
|
t.datetime "revoked_at"
|
||||||
|
t.string "scope"
|
||||||
|
t.string "token_digest", null: false
|
||||||
|
t.integer "token_family_id"
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.integer "user_id", null: false
|
||||||
|
t.index ["application_id", "user_id"], name: "index_oidc_refresh_tokens_on_application_id_and_user_id"
|
||||||
|
t.index ["application_id"], name: "index_oidc_refresh_tokens_on_application_id"
|
||||||
|
t.index ["expires_at"], name: "index_oidc_refresh_tokens_on_expires_at"
|
||||||
|
t.index ["oidc_access_token_id"], name: "index_oidc_refresh_tokens_on_oidc_access_token_id"
|
||||||
|
t.index ["revoked_at"], name: "index_oidc_refresh_tokens_on_revoked_at"
|
||||||
|
t.index ["token_digest"], name: "index_oidc_refresh_tokens_on_token_digest", unique: true
|
||||||
|
t.index ["token_family_id"], name: "index_oidc_refresh_tokens_on_token_family_id"
|
||||||
|
t.index ["user_id"], name: "index_oidc_refresh_tokens_on_user_id"
|
||||||
|
end
|
||||||
|
|
||||||
create_table "oidc_user_consents", force: :cascade do |t|
|
create_table "oidc_user_consents", 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
|
||||||
@@ -124,7 +155,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_04_054909) do
|
|||||||
|
|
||||||
create_table "users", force: :cascade do |t|
|
create_table "users", force: :cascade do |t|
|
||||||
t.boolean "admin", default: false, null: false
|
t.boolean "admin", default: false, null: false
|
||||||
t.text "backup_codes"
|
t.json "backup_codes"
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.json "custom_claims", default: {}, null: false
|
t.json "custom_claims", default: {}, null: false
|
||||||
t.string "email_address", null: false
|
t.string "email_address", null: false
|
||||||
@@ -171,6 +202,9 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_04_054909) do
|
|||||||
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"
|
||||||
add_foreign_key "oidc_authorization_codes", "users"
|
add_foreign_key "oidc_authorization_codes", "users"
|
||||||
|
add_foreign_key "oidc_refresh_tokens", "applications"
|
||||||
|
add_foreign_key "oidc_refresh_tokens", "oidc_access_tokens"
|
||||||
|
add_foreign_key "oidc_refresh_tokens", "users"
|
||||||
add_foreign_key "oidc_user_consents", "applications"
|
add_foreign_key "oidc_user_consents", "applications"
|
||||||
add_foreign_key "oidc_user_consents", "users"
|
add_foreign_key "oidc_user_consents", "users"
|
||||||
add_foreign_key "sessions", "users"
|
add_foreign_key "sessions", "users"
|
||||||
|
|||||||
136
docs/oidc-key-setup.md
Normal file
136
docs/oidc-key-setup.md
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
# OIDC Private Key Setup
|
||||||
|
|
||||||
|
Your OIDC provider needs an RSA private key to sign ID tokens. **This key must persist across deployments** or all existing tokens will become invalid.
|
||||||
|
|
||||||
|
## Option 1: Environment Variable (Recommended for Docker/Kamal)
|
||||||
|
|
||||||
|
### 1. Generate the key
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate a 2048-bit RSA key
|
||||||
|
openssl genrsa -out oidc_private_key.pem 2048
|
||||||
|
|
||||||
|
# View the key (you'll copy this)
|
||||||
|
cat oidc_private_key.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Store in your `.env` file
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .env (for local development)
|
||||||
|
OIDC_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----
|
||||||
|
MIIEpAIBAAKCAQEAyZ0qaICMiLVWSFs+ef9Xok3fzy0p6k/7D5TQzmxf7C2vQG7s
|
||||||
|
2Odmi8iAHLoaUBaFj70qTbaconWyMr8s+ah+qZwrwolTLUe23VrceVXvInU57hBL
|
||||||
|
...
|
||||||
|
-----END RSA PRIVATE KEY-----"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important:** Keep the quotes and include the full key with `-----BEGIN` and `-----END` lines.
|
||||||
|
|
||||||
|
### 3. For Kamal deployment
|
||||||
|
|
||||||
|
Add to your Kamal secrets:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# config/deploy.yml
|
||||||
|
env:
|
||||||
|
secret:
|
||||||
|
- OIDC_PRIVATE_KEY
|
||||||
|
```
|
||||||
|
|
||||||
|
Then set it securely:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate key
|
||||||
|
bin/generate_oidc_key > oidc_private_key.pem
|
||||||
|
|
||||||
|
# Add to .kamal/secrets
|
||||||
|
echo "OIDC_PRIVATE_KEY=$(cat oidc_private_key.pem)" >> .kamal/secrets
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Verify it's loaded
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# In Rails console
|
||||||
|
bin/rails runner "puts OidcJwtService.send(:private_key).present? ? 'Key loaded' : 'Key missing'"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Comparison
|
||||||
|
|
||||||
|
| Feature | ENV Variable | Rails Credentials |
|
||||||
|
|---------|-------------|-------------------|
|
||||||
|
| **Best for** | Docker, Kamal, 12-factor | Simple deployments |
|
||||||
|
| **Key rotation** | Easy (just update ENV) | Medium (re-encrypt) |
|
||||||
|
| **Per-environment** | Yes (dev/staging/prod can differ) | No (same key everywhere) |
|
||||||
|
| **Secrets manager** | Compatible (AWS Secrets, etc.) | Needs RAILS_MASTER_KEY |
|
||||||
|
| **Setup complexity** | Low | Medium |
|
||||||
|
|
||||||
|
**Recommendation:** Use ENV variable (`OIDC_PRIVATE_KEY`) for production with Kamal.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Best Practices
|
||||||
|
|
||||||
|
### DO:
|
||||||
|
- ✅ Generate the key once and keep it forever
|
||||||
|
- ✅ Store in secret manager (AWS Secrets Manager, 1Password, etc.)
|
||||||
|
- ✅ Use strong key (2048-bit RSA minimum)
|
||||||
|
- ✅ Backup the key securely
|
||||||
|
- ✅ Restrict access (only ops team)
|
||||||
|
|
||||||
|
### DON'T:
|
||||||
|
- ❌ Commit the key to git (except encrypted credentials)
|
||||||
|
- ❌ Share the key in Slack/email
|
||||||
|
- ❌ Regenerate the key (invalidates all tokens)
|
||||||
|
- ❌ Store in `.env` if it's committed to git
|
||||||
|
- ❌ Use the same key for multiple environments
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Rotation (Advanced)
|
||||||
|
|
||||||
|
Todo
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "No private key found" warning
|
||||||
|
|
||||||
|
Check your setup:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Is ENV set?
|
||||||
|
echo $OIDC_PRIVATE_KEY
|
||||||
|
|
||||||
|
# Can Rails load it?
|
||||||
|
bin/rails runner "puts ENV['OIDC_PRIVATE_KEY'].present? ? 'ENV set' : 'ENV missing'"
|
||||||
|
|
||||||
|
# Does it load correctly?
|
||||||
|
bin/rails runner "puts OidcJwtService.send(:private_key).to_s[0..50]"
|
||||||
|
```
|
||||||
|
|
||||||
|
### "invalid RSA key" error
|
||||||
|
|
||||||
|
- Make sure you include `-----BEGIN RSA PRIVATE KEY-----` header
|
||||||
|
- Ensure newlines are preserved (use quotes in ENV)
|
||||||
|
- Check for extra spaces or characters
|
||||||
|
|
||||||
|
### Different JWKS key ID on each restart
|
||||||
|
|
||||||
|
This means the key is being regenerated. You need to set `OIDC_PRIVATE_KEY` or add to credentials.
|
||||||
|
|
||||||
|
### All tokens invalid after deployment
|
||||||
|
|
||||||
|
The key changed. You either:
|
||||||
|
- Regenerated the key (don't do this!)
|
||||||
|
- Forgot to set ENV variable in production
|
||||||
|
- The key wasn't loaded correctly
|
||||||
|
|
||||||
|
Check logs for warnings and verify key is loaded:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kamal app logs --grep "OIDC"
|
||||||
|
```
|
||||||
440
test/controllers/oidc_authorization_code_security_test.rb
Normal file
440
test/controllers/oidc_authorization_code_security_test.rb
Normal file
@@ -0,0 +1,440 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
||||||
|
def setup
|
||||||
|
@user = User.create!(email_address: "security_test@example.com", password: "password123")
|
||||||
|
@application = Application.create!(
|
||||||
|
name: "Security Test App",
|
||||||
|
slug: "security-test-app",
|
||||||
|
app_type: "oidc",
|
||||||
|
redirect_uris: ["http://localhost:4000/callback"].to_json,
|
||||||
|
active: true
|
||||||
|
)
|
||||||
|
|
||||||
|
# Store the plain text client secret for testing
|
||||||
|
@client_secret = @application.client_secret_digest
|
||||||
|
@application.generate_new_client_secret!
|
||||||
|
@plain_client_secret = @application.client_secret
|
||||||
|
@application.save!
|
||||||
|
end
|
||||||
|
|
||||||
|
def teardown
|
||||||
|
OidcAuthorizationCode.where(application: @application).destroy_all
|
||||||
|
OidcAccessToken.where(application: @application).destroy_all
|
||||||
|
@user.destroy
|
||||||
|
@application.destroy
|
||||||
|
end
|
||||||
|
|
||||||
|
# ====================
|
||||||
|
# CRITICAL SECURITY TESTS
|
||||||
|
# ====================
|
||||||
|
|
||||||
|
test "prevents authorization code reuse - sequential attempts" do
|
||||||
|
# Create a valid authorization code
|
||||||
|
auth_code = OidcAuthorizationCode.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
code: SecureRandom.urlsafe_base64(32),
|
||||||
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
|
scope: "openid profile",
|
||||||
|
expires_at: 10.minutes.from_now
|
||||||
|
)
|
||||||
|
|
||||||
|
token_params = {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: auth_code.code,
|
||||||
|
redirect_uri: "http://localhost:4000/callback"
|
||||||
|
}
|
||||||
|
|
||||||
|
# First request should succeed
|
||||||
|
post "/oauth/token", params: token_params, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
first_response = JSON.parse(@response.body)
|
||||||
|
assert first_response.key?("access_token")
|
||||||
|
assert first_response.key?("id_token")
|
||||||
|
|
||||||
|
# Second request with same code should fail
|
||||||
|
post "/oauth/token", params: token_params, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :bad_request
|
||||||
|
error = JSON.parse(@response.body)
|
||||||
|
assert_equal "invalid_grant", error["error"]
|
||||||
|
assert_match(/already been used/, error["error_description"])
|
||||||
|
end
|
||||||
|
|
||||||
|
test "revokes existing tokens when authorization code is reused" do
|
||||||
|
# Create a valid authorization code
|
||||||
|
auth_code = OidcAuthorizationCode.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
code: SecureRandom.urlsafe_base64(32),
|
||||||
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
|
scope: "openid profile",
|
||||||
|
expires_at: 10.minutes.from_now
|
||||||
|
)
|
||||||
|
|
||||||
|
token_params = {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: auth_code.code,
|
||||||
|
redirect_uri: "http://localhost:4000/callback"
|
||||||
|
}
|
||||||
|
|
||||||
|
# First request - get access token
|
||||||
|
post "/oauth/token", params: token_params, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
first_response = JSON.parse(@response.body)
|
||||||
|
first_access_token = first_response["access_token"]
|
||||||
|
|
||||||
|
# Verify the token works
|
||||||
|
get "/oauth/userinfo", headers: {
|
||||||
|
"Authorization" => "Bearer #{first_access_token}"
|
||||||
|
}
|
||||||
|
assert_response :success
|
||||||
|
|
||||||
|
# Second request with same code - should fail AND revoke first token
|
||||||
|
post "/oauth/token", params: token_params, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :bad_request
|
||||||
|
|
||||||
|
# Verify the first token is now revoked (expired)
|
||||||
|
get "/oauth/userinfo", headers: {
|
||||||
|
"Authorization" => "Bearer #{first_access_token}"
|
||||||
|
}
|
||||||
|
assert_response :unauthorized, "First access token should be revoked after code reuse"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects already used authorization code" do
|
||||||
|
# Create and mark code as used
|
||||||
|
auth_code = OidcAuthorizationCode.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
code: SecureRandom.urlsafe_base64(32),
|
||||||
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
|
scope: "openid profile",
|
||||||
|
used: true,
|
||||||
|
expires_at: 10.minutes.from_now
|
||||||
|
)
|
||||||
|
|
||||||
|
token_params = {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: auth_code.code,
|
||||||
|
redirect_uri: "http://localhost:4000/callback"
|
||||||
|
}
|
||||||
|
|
||||||
|
post "/oauth/token", params: token_params, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :bad_request
|
||||||
|
error = JSON.parse(@response.body)
|
||||||
|
assert_equal "invalid_grant", error["error"]
|
||||||
|
assert_match(/already been used/, error["error_description"])
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects expired authorization code" do
|
||||||
|
# Create expired code
|
||||||
|
auth_code = OidcAuthorizationCode.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
code: SecureRandom.urlsafe_base64(32),
|
||||||
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
|
scope: "openid profile",
|
||||||
|
expires_at: 5.minutes.ago
|
||||||
|
)
|
||||||
|
|
||||||
|
token_params = {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: auth_code.code,
|
||||||
|
redirect_uri: "http://localhost:4000/callback"
|
||||||
|
}
|
||||||
|
|
||||||
|
post "/oauth/token", params: token_params, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :bad_request
|
||||||
|
error = JSON.parse(@response.body)
|
||||||
|
assert_equal "invalid_grant", error["error"]
|
||||||
|
assert_match(/expired/, error["error_description"])
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects authorization code with mismatched redirect_uri" do
|
||||||
|
auth_code = OidcAuthorizationCode.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
code: SecureRandom.urlsafe_base64(32),
|
||||||
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
|
scope: "openid profile",
|
||||||
|
expires_at: 10.minutes.from_now
|
||||||
|
)
|
||||||
|
|
||||||
|
token_params = {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: auth_code.code,
|
||||||
|
redirect_uri: "http://evil.com/callback" # Wrong redirect URI
|
||||||
|
}
|
||||||
|
|
||||||
|
post "/oauth/token", params: token_params, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :bad_request
|
||||||
|
error = JSON.parse(@response.body)
|
||||||
|
assert_equal "invalid_grant", error["error"]
|
||||||
|
assert_match(/Redirect URI mismatch/, error["error_description"])
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects non-existent authorization code" do
|
||||||
|
token_params = {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: "nonexistent_code_12345",
|
||||||
|
redirect_uri: "http://localhost:4000/callback"
|
||||||
|
}
|
||||||
|
|
||||||
|
post "/oauth/token", params: token_params, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :bad_request
|
||||||
|
error = JSON.parse(@response.body)
|
||||||
|
assert_equal "invalid_grant", error["error"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects authorization code for different application" do
|
||||||
|
# Create another application
|
||||||
|
other_app = Application.create!(
|
||||||
|
name: "Other App",
|
||||||
|
slug: "other-app",
|
||||||
|
app_type: "oidc",
|
||||||
|
redirect_uris: ["http://localhost:5000/callback"].to_json,
|
||||||
|
active: true
|
||||||
|
)
|
||||||
|
other_secret = other_app.client_secret
|
||||||
|
|
||||||
|
# Create auth code for first application
|
||||||
|
auth_code = OidcAuthorizationCode.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
code: SecureRandom.urlsafe_base64(32),
|
||||||
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
|
scope: "openid profile",
|
||||||
|
expires_at: 10.minutes.from_now
|
||||||
|
)
|
||||||
|
|
||||||
|
# Try to use it with different application credentials
|
||||||
|
token_params = {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: auth_code.code,
|
||||||
|
redirect_uri: "http://localhost:4000/callback"
|
||||||
|
}
|
||||||
|
|
||||||
|
post "/oauth/token", params: token_params, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{other_app.client_id}:#{other_secret}")
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :bad_request
|
||||||
|
error = JSON.parse(@response.body)
|
||||||
|
assert_equal "invalid_grant", error["error"]
|
||||||
|
|
||||||
|
other_app.destroy
|
||||||
|
end
|
||||||
|
|
||||||
|
# ====================
|
||||||
|
# CLIENT AUTHENTICATION TESTS
|
||||||
|
# ====================
|
||||||
|
|
||||||
|
test "rejects invalid client_id in Basic auth" do
|
||||||
|
auth_code = OidcAuthorizationCode.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
code: SecureRandom.urlsafe_base64(32),
|
||||||
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
|
scope: "openid profile",
|
||||||
|
expires_at: 10.minutes.from_now
|
||||||
|
)
|
||||||
|
|
||||||
|
token_params = {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: auth_code.code,
|
||||||
|
redirect_uri: "http://localhost:4000/callback"
|
||||||
|
}
|
||||||
|
|
||||||
|
post "/oauth/token", params: token_params, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("invalid_client_id:#{@plain_client_secret}")
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :unauthorized
|
||||||
|
error = JSON.parse(@response.body)
|
||||||
|
assert_equal "invalid_client", error["error"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects invalid client_secret in Basic auth" do
|
||||||
|
auth_code = OidcAuthorizationCode.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
code: SecureRandom.urlsafe_base64(32),
|
||||||
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
|
scope: "openid profile",
|
||||||
|
expires_at: 10.minutes.from_now
|
||||||
|
)
|
||||||
|
|
||||||
|
token_params = {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: auth_code.code,
|
||||||
|
redirect_uri: "http://localhost:4000/callback"
|
||||||
|
}
|
||||||
|
|
||||||
|
post "/oauth/token", params: token_params, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:wrong_secret")
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :unauthorized
|
||||||
|
error = JSON.parse(@response.body)
|
||||||
|
assert_equal "invalid_client", error["error"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "accepts client credentials in POST body" do
|
||||||
|
auth_code = OidcAuthorizationCode.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
code: SecureRandom.urlsafe_base64(32),
|
||||||
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
|
scope: "openid profile",
|
||||||
|
expires_at: 10.minutes.from_now
|
||||||
|
)
|
||||||
|
|
||||||
|
token_params = {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: auth_code.code,
|
||||||
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
|
client_id: @application.client_id,
|
||||||
|
client_secret: @plain_client_secret
|
||||||
|
}
|
||||||
|
|
||||||
|
post "/oauth/token", params: token_params
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
response_body = JSON.parse(@response.body)
|
||||||
|
assert response_body.key?("access_token")
|
||||||
|
assert response_body.key?("id_token")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects request with no client authentication" do
|
||||||
|
auth_code = OidcAuthorizationCode.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
code: SecureRandom.urlsafe_base64(32),
|
||||||
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
|
scope: "openid profile",
|
||||||
|
expires_at: 10.minutes.from_now
|
||||||
|
)
|
||||||
|
|
||||||
|
token_params = {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: auth_code.code,
|
||||||
|
redirect_uri: "http://localhost:4000/callback"
|
||||||
|
}
|
||||||
|
|
||||||
|
post "/oauth/token", params: token_params
|
||||||
|
|
||||||
|
assert_response :unauthorized
|
||||||
|
error = JSON.parse(@response.body)
|
||||||
|
assert_equal "invalid_client", error["error"]
|
||||||
|
end
|
||||||
|
|
||||||
|
# ====================
|
||||||
|
# GRANT TYPE VALIDATION
|
||||||
|
# ====================
|
||||||
|
|
||||||
|
test "rejects unsupported grant_type" do
|
||||||
|
post "/oauth/token", params: {
|
||||||
|
grant_type: "password",
|
||||||
|
username: "user",
|
||||||
|
password: "pass"
|
||||||
|
}, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :bad_request
|
||||||
|
error = JSON.parse(@response.body)
|
||||||
|
assert_equal "unsupported_grant_type", error["error"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects missing grant_type" do
|
||||||
|
post "/oauth/token", params: {
|
||||||
|
code: "some_code",
|
||||||
|
redirect_uri: "http://localhost:4000/callback"
|
||||||
|
}, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :bad_request
|
||||||
|
error = JSON.parse(@response.body)
|
||||||
|
assert_equal "unsupported_grant_type", error["error"]
|
||||||
|
end
|
||||||
|
|
||||||
|
# ====================
|
||||||
|
# TIMING ATTACK PROTECTION
|
||||||
|
# ====================
|
||||||
|
|
||||||
|
test "client authentication uses constant-time comparison" do
|
||||||
|
auth_code = OidcAuthorizationCode.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
code: SecureRandom.urlsafe_base64(32),
|
||||||
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
|
scope: "openid profile",
|
||||||
|
expires_at: 10.minutes.from_now
|
||||||
|
)
|
||||||
|
|
||||||
|
token_params = {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: auth_code.code,
|
||||||
|
redirect_uri: "http://localhost:4000/callback"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test with completely wrong secret
|
||||||
|
times_wrong = []
|
||||||
|
5.times do
|
||||||
|
start_time = Time.now.to_f
|
||||||
|
post "/oauth/token", params: token_params, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:wrong_secret_xxx")
|
||||||
|
}
|
||||||
|
times_wrong << (Time.now.to_f - start_time)
|
||||||
|
assert_response :unauthorized
|
||||||
|
end
|
||||||
|
|
||||||
|
# Test with almost correct secret (differs by one character)
|
||||||
|
correct_secret = @plain_client_secret
|
||||||
|
almost_correct = correct_secret[0..-2] + "X"
|
||||||
|
|
||||||
|
times_almost = []
|
||||||
|
5.times do
|
||||||
|
start_time = Time.now.to_f
|
||||||
|
post "/oauth/token", params: token_params, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{almost_correct}")
|
||||||
|
}
|
||||||
|
times_almost << (Time.now.to_f - start_time)
|
||||||
|
assert_response :unauthorized
|
||||||
|
end
|
||||||
|
|
||||||
|
# The timing difference should be minimal (within 50ms) if using constant-time comparison
|
||||||
|
avg_wrong = times_wrong.sum / times_wrong.size
|
||||||
|
avg_almost = times_almost.sum / times_almost.size
|
||||||
|
timing_difference = (avg_wrong - avg_almost).abs
|
||||||
|
|
||||||
|
# This is a best-effort check - in practice, constant-time comparison is handled by bcrypt
|
||||||
|
assert timing_difference < 0.05,
|
||||||
|
"Timing difference #{timing_difference}s suggests potential timing attack vulnerability"
|
||||||
|
end
|
||||||
|
end
|
||||||
299
test/controllers/oidc_pkce_controller_test.rb
Normal file
299
test/controllers/oidc_pkce_controller_test.rb
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class OidcPkceControllerTest < ActionDispatch::IntegrationTest
|
||||||
|
def setup
|
||||||
|
@user = User.create!(email_address: "pkce_test@example.com", password: "password123")
|
||||||
|
@application = Application.create!(
|
||||||
|
name: "PKCE Test App",
|
||||||
|
slug: "pkce-test-app",
|
||||||
|
app_type: "oidc",
|
||||||
|
redirect_uris: ["http://localhost:4000/callback"].to_json,
|
||||||
|
active: true
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sign in the user using the test helper
|
||||||
|
sign_in_as(@user)
|
||||||
|
end
|
||||||
|
|
||||||
|
def teardown
|
||||||
|
Current.session&.destroy
|
||||||
|
OidcAuthorizationCode.where(application: @application).destroy_all
|
||||||
|
OidcAccessToken.where(application: @application).destroy_all
|
||||||
|
@user.destroy
|
||||||
|
@application.destroy
|
||||||
|
end
|
||||||
|
|
||||||
|
test "discovery endpoint includes PKCE support" do
|
||||||
|
get "/.well-known/openid-configuration"
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
config = JSON.parse(@response.body)
|
||||||
|
|
||||||
|
assert config.key?("code_challenge_methods_supported")
|
||||||
|
assert_includes config["code_challenge_methods_supported"], "S256"
|
||||||
|
assert_includes config["code_challenge_methods_supported"], "plain"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "authorization endpoint accepts PKCE parameters (S256)" do
|
||||||
|
code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
||||||
|
code_challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
|
||||||
|
|
||||||
|
auth_params = {
|
||||||
|
response_type: "code",
|
||||||
|
client_id: @application.client_id,
|
||||||
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
|
scope: "openid profile",
|
||||||
|
state: "test_state",
|
||||||
|
nonce: "test_nonce",
|
||||||
|
code_challenge: code_challenge,
|
||||||
|
code_challenge_method: "S256"
|
||||||
|
}
|
||||||
|
|
||||||
|
get "/oauth/authorize", params: auth_params
|
||||||
|
|
||||||
|
# Should show consent page (user is already authenticated)
|
||||||
|
assert_response :success
|
||||||
|
assert_match /consent/, @response.body.downcase
|
||||||
|
end
|
||||||
|
|
||||||
|
test "authorization endpoint accepts PKCE parameters (plain)" do
|
||||||
|
code_challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
|
||||||
|
|
||||||
|
auth_params = {
|
||||||
|
response_type: "code",
|
||||||
|
client_id: @application.client_id,
|
||||||
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
|
scope: "openid profile",
|
||||||
|
state: "test_state",
|
||||||
|
nonce: "test_nonce",
|
||||||
|
code_challenge: code_challenge,
|
||||||
|
code_challenge_method: "plain"
|
||||||
|
}
|
||||||
|
|
||||||
|
get "/oauth/authorize", params: auth_params
|
||||||
|
|
||||||
|
# Should show consent page (user is already authenticated)
|
||||||
|
assert_response :success
|
||||||
|
assert_match /consent/, @response.body.downcase
|
||||||
|
end
|
||||||
|
|
||||||
|
test "authorization endpoint rejects invalid code_challenge_method" do
|
||||||
|
auth_params = {
|
||||||
|
response_type: "code",
|
||||||
|
client_id: @application.client_id,
|
||||||
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
|
scope: "openid profile",
|
||||||
|
code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
|
||||||
|
code_challenge_method: "invalid_method"
|
||||||
|
}
|
||||||
|
|
||||||
|
get "/oauth/authorize", params: auth_params
|
||||||
|
|
||||||
|
assert_response :bad_request
|
||||||
|
assert_match(/Invalid code_challenge_method/, @response.body)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "authorization endpoint rejects invalid code_challenge format" do
|
||||||
|
# Contains + character which is not base64url
|
||||||
|
auth_params = {
|
||||||
|
response_type: "code",
|
||||||
|
client_id: @application.client_id,
|
||||||
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
|
scope: "openid profile",
|
||||||
|
code_challenge: "invalid+challenge",
|
||||||
|
code_challenge_method: "S256"
|
||||||
|
}
|
||||||
|
|
||||||
|
get "/oauth/authorize", params: auth_params
|
||||||
|
|
||||||
|
assert_response :bad_request
|
||||||
|
assert_match(/Invalid code_challenge format/, @response.body)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "token endpoint requires code_verifier when PKCE was used (S256)" do
|
||||||
|
# Create authorization code with PKCE S256
|
||||||
|
auth_code = OidcAuthorizationCode.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
code: SecureRandom.urlsafe_base64(32),
|
||||||
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
|
scope: "openid profile",
|
||||||
|
code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
|
||||||
|
code_challenge_method: "S256",
|
||||||
|
expires_at: 10.minutes.from_now
|
||||||
|
)
|
||||||
|
|
||||||
|
token_params = {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: auth_code.code,
|
||||||
|
redirect_uri: "http://localhost:4000/callback"
|
||||||
|
}
|
||||||
|
|
||||||
|
post "/oauth/token", params: token_params, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@application.client_secret}")
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :bad_request
|
||||||
|
error = JSON.parse(@response.body)
|
||||||
|
assert_equal "invalid_request", error["error"]
|
||||||
|
assert_match(/code_verifier is required/, error["error_description"])
|
||||||
|
end
|
||||||
|
|
||||||
|
test "token endpoint requires code_verifier when PKCE was used (plain)" do
|
||||||
|
# Create authorization code with PKCE plain
|
||||||
|
auth_code = OidcAuthorizationCode.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
code: SecureRandom.urlsafe_base64(32),
|
||||||
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
|
scope: "openid profile",
|
||||||
|
code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
|
||||||
|
code_challenge_method: "plain",
|
||||||
|
expires_at: 10.minutes.from_now
|
||||||
|
)
|
||||||
|
|
||||||
|
token_params = {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: auth_code.code,
|
||||||
|
redirect_uri: "http://localhost:4000/callback"
|
||||||
|
}
|
||||||
|
|
||||||
|
post "/oauth/token", params: token_params, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@application.client_secret}")
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :bad_request
|
||||||
|
error = JSON.parse(@response.body)
|
||||||
|
assert_equal "invalid_request", error["error"]
|
||||||
|
assert_match(/code_verifier is required/, error["error_description"])
|
||||||
|
end
|
||||||
|
|
||||||
|
test "token endpoint rejects invalid code_verifier (S256)" do
|
||||||
|
# Create authorization code with PKCE S256
|
||||||
|
auth_code = OidcAuthorizationCode.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
code: SecureRandom.urlsafe_base64(32),
|
||||||
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
|
scope: "openid profile",
|
||||||
|
code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
|
||||||
|
code_challenge_method: "S256",
|
||||||
|
expires_at: 10.minutes.from_now
|
||||||
|
)
|
||||||
|
|
||||||
|
token_params = {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: auth_code.code,
|
||||||
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
|
# Use a properly formatted but wrong verifier (43+ chars, base64url)
|
||||||
|
code_verifier: "wrongverifier_with_enough_characters_base64url"
|
||||||
|
}
|
||||||
|
|
||||||
|
post "/oauth/token", params: token_params, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@application.client_secret}")
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :bad_request
|
||||||
|
error = JSON.parse(@response.body)
|
||||||
|
assert_equal "invalid_grant", error["error"]
|
||||||
|
assert_match(/Invalid code verifier/, error["error_description"])
|
||||||
|
end
|
||||||
|
|
||||||
|
test "token endpoint accepts valid code_verifier (S256)" do
|
||||||
|
# Generate valid PKCE pair
|
||||||
|
code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
||||||
|
code_challenge = Digest::SHA256.base64digest(code_verifier)
|
||||||
|
.tr("+/", "-_")
|
||||||
|
.tr("=", "")
|
||||||
|
|
||||||
|
# Create authorization code with PKCE S256
|
||||||
|
auth_code = OidcAuthorizationCode.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
code: SecureRandom.urlsafe_base64(32),
|
||||||
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
|
scope: "openid profile",
|
||||||
|
code_challenge: code_challenge,
|
||||||
|
code_challenge_method: "S256",
|
||||||
|
expires_at: 10.minutes.from_now
|
||||||
|
)
|
||||||
|
|
||||||
|
token_params = {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: auth_code.code,
|
||||||
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
|
code_verifier: code_verifier
|
||||||
|
}
|
||||||
|
|
||||||
|
post "/oauth/token", params: token_params, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@application.client_secret}")
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
tokens = JSON.parse(@response.body)
|
||||||
|
assert tokens.key?("access_token")
|
||||||
|
assert tokens.key?("id_token")
|
||||||
|
assert_equal "Bearer", tokens["token_type"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "token endpoint accepts valid code_verifier (plain)" do
|
||||||
|
code_verifier = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
|
||||||
|
|
||||||
|
# Create authorization code with PKCE plain
|
||||||
|
auth_code = OidcAuthorizationCode.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
code: SecureRandom.urlsafe_base64(32),
|
||||||
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
|
scope: "openid profile",
|
||||||
|
code_challenge: code_verifier, # Same as verifier for plain method
|
||||||
|
code_challenge_method: "plain",
|
||||||
|
expires_at: 10.minutes.from_now
|
||||||
|
)
|
||||||
|
|
||||||
|
token_params = {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: auth_code.code,
|
||||||
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
|
code_verifier: code_verifier
|
||||||
|
}
|
||||||
|
|
||||||
|
post "/oauth/token", params: token_params, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@application.client_secret}")
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
tokens = JSON.parse(@response.body)
|
||||||
|
assert tokens.key?("access_token")
|
||||||
|
assert tokens.key?("id_token")
|
||||||
|
assert_equal "Bearer", tokens["token_type"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "token endpoint works without PKCE (backward compatibility)" do
|
||||||
|
# Create authorization code without PKCE
|
||||||
|
auth_code = OidcAuthorizationCode.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
code: SecureRandom.urlsafe_base64(32),
|
||||||
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
|
scope: "openid profile",
|
||||||
|
expires_at: 10.minutes.from_now
|
||||||
|
)
|
||||||
|
|
||||||
|
token_params = {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: auth_code.code,
|
||||||
|
redirect_uri: "http://localhost:4000/callback"
|
||||||
|
}
|
||||||
|
|
||||||
|
post "/oauth/token", params: token_params, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@application.client_secret}")
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
tokens = JSON.parse(@response.body)
|
||||||
|
assert tokens.key?("access_token")
|
||||||
|
assert tokens.key?("id_token")
|
||||||
|
assert_equal "Bearer", tokens["token_type"]
|
||||||
|
end
|
||||||
|
end
|
||||||
235
test/controllers/oidc_refresh_token_controller_test.rb
Normal file
235
test/controllers/oidc_refresh_token_controller_test.rb
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class OidcRefreshTokenControllerTest < ActionDispatch::IntegrationTest
|
||||||
|
setup do
|
||||||
|
@user = users(:alice)
|
||||||
|
@application = applications(:kavita_app)
|
||||||
|
# Store a known client secret for testing
|
||||||
|
@client_secret = SecureRandom.urlsafe_base64(48)
|
||||||
|
@application.client_secret = @client_secret
|
||||||
|
@application.save!
|
||||||
|
end
|
||||||
|
|
||||||
|
test "token endpoint returns refresh_token with authorization_code grant" do
|
||||||
|
# Create an authorization code
|
||||||
|
auth_code = OidcAuthorizationCode.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
code: SecureRandom.urlsafe_base64(32),
|
||||||
|
redirect_uri: @application.parsed_redirect_uris.first,
|
||||||
|
scope: "openid profile email",
|
||||||
|
expires_at: 10.minutes.from_now
|
||||||
|
)
|
||||||
|
|
||||||
|
# Exchange authorization code for tokens
|
||||||
|
post "/oauth/token", params: {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: auth_code.code,
|
||||||
|
redirect_uri: @application.parsed_redirect_uris.first,
|
||||||
|
client_id: @application.client_id,
|
||||||
|
client_secret: @client_secret
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
json = JSON.parse(response.body)
|
||||||
|
|
||||||
|
assert json["access_token"].present?
|
||||||
|
assert json["id_token"].present?
|
||||||
|
assert json["refresh_token"].present?
|
||||||
|
assert_equal "Bearer", json["token_type"]
|
||||||
|
assert_equal 3600, json["expires_in"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "refresh_token grant exchanges refresh token for new tokens" do
|
||||||
|
# Create access and refresh tokens
|
||||||
|
access_token = OidcAccessToken.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
scope: "openid profile email"
|
||||||
|
)
|
||||||
|
|
||||||
|
refresh_token = OidcRefreshToken.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
oidc_access_token: access_token,
|
||||||
|
scope: "openid profile email"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Store the plaintext refresh token (available only during creation)
|
||||||
|
plaintext_refresh_token = refresh_token.token
|
||||||
|
|
||||||
|
# Use refresh token to get new tokens
|
||||||
|
post "/oauth/token", params: {
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
refresh_token: plaintext_refresh_token,
|
||||||
|
client_id: @application.client_id,
|
||||||
|
client_secret: @client_secret
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
json = JSON.parse(response.body)
|
||||||
|
|
||||||
|
assert json["access_token"].present?
|
||||||
|
assert json["id_token"].present?
|
||||||
|
assert json["refresh_token"].present?
|
||||||
|
assert_equal "Bearer", json["token_type"]
|
||||||
|
|
||||||
|
# Old refresh token should be revoked
|
||||||
|
assert refresh_token.reload.revoked?
|
||||||
|
end
|
||||||
|
|
||||||
|
test "refresh_token grant fails with expired refresh token" do
|
||||||
|
access_token = OidcAccessToken.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
scope: "openid profile email"
|
||||||
|
)
|
||||||
|
|
||||||
|
refresh_token = OidcRefreshToken.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
oidc_access_token: access_token,
|
||||||
|
scope: "openid profile email",
|
||||||
|
expires_at: 1.hour.ago # Expired
|
||||||
|
)
|
||||||
|
|
||||||
|
plaintext_refresh_token = refresh_token.token
|
||||||
|
|
||||||
|
post "/oauth/token", params: {
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
refresh_token: plaintext_refresh_token,
|
||||||
|
client_id: @application.client_id,
|
||||||
|
client_secret: @client_secret
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :bad_request
|
||||||
|
json = JSON.parse(response.body)
|
||||||
|
assert_equal "invalid_grant", json["error"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "refresh_token grant fails with revoked refresh token" do
|
||||||
|
access_token = OidcAccessToken.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
scope: "openid profile email"
|
||||||
|
)
|
||||||
|
|
||||||
|
refresh_token = OidcRefreshToken.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
oidc_access_token: access_token,
|
||||||
|
scope: "openid profile email"
|
||||||
|
)
|
||||||
|
|
||||||
|
plaintext_refresh_token = refresh_token.token
|
||||||
|
refresh_token.revoke!
|
||||||
|
|
||||||
|
post "/oauth/token", params: {
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
refresh_token: plaintext_refresh_token,
|
||||||
|
client_id: @application.client_id,
|
||||||
|
client_secret: @client_secret
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :bad_request
|
||||||
|
json = JSON.parse(response.body)
|
||||||
|
assert_equal "invalid_grant", json["error"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "token revocation endpoint revokes access tokens" do
|
||||||
|
access_token = OidcAccessToken.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
scope: "openid profile email"
|
||||||
|
)
|
||||||
|
|
||||||
|
plaintext_access_token = access_token.plaintext_token
|
||||||
|
|
||||||
|
post "/oauth/revoke", params: {
|
||||||
|
token: plaintext_access_token,
|
||||||
|
token_type_hint: "access_token",
|
||||||
|
client_id: @application.client_id,
|
||||||
|
client_secret: @client_secret
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
assert access_token.reload.revoked?
|
||||||
|
end
|
||||||
|
|
||||||
|
test "token revocation endpoint revokes refresh tokens" do
|
||||||
|
access_token = OidcAccessToken.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
scope: "openid profile email"
|
||||||
|
)
|
||||||
|
|
||||||
|
refresh_token = OidcRefreshToken.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
oidc_access_token: access_token,
|
||||||
|
scope: "openid profile email"
|
||||||
|
)
|
||||||
|
|
||||||
|
plaintext_refresh_token = refresh_token.token
|
||||||
|
|
||||||
|
post "/oauth/revoke", params: {
|
||||||
|
token: plaintext_refresh_token,
|
||||||
|
token_type_hint: "refresh_token",
|
||||||
|
client_id: @application.client_id,
|
||||||
|
client_secret: @client_secret
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
assert refresh_token.reload.revoked?
|
||||||
|
end
|
||||||
|
|
||||||
|
test "token rotation: new refresh token has same family id" do
|
||||||
|
access_token = OidcAccessToken.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
scope: "openid profile email"
|
||||||
|
)
|
||||||
|
|
||||||
|
old_refresh_token = OidcRefreshToken.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
oidc_access_token: access_token,
|
||||||
|
scope: "openid profile email"
|
||||||
|
)
|
||||||
|
|
||||||
|
family_id = old_refresh_token.token_family_id
|
||||||
|
plaintext_refresh_token = old_refresh_token.token
|
||||||
|
|
||||||
|
post "/oauth/token", params: {
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
refresh_token: plaintext_refresh_token,
|
||||||
|
client_id: @application.client_id,
|
||||||
|
client_secret: @client_secret
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
|
||||||
|
# Find the new refresh token
|
||||||
|
new_refresh_token = OidcRefreshToken.active.where(user: @user, application: @application).last
|
||||||
|
assert_equal family_id, new_refresh_token.token_family_id
|
||||||
|
end
|
||||||
|
|
||||||
|
test "userinfo endpoint works with hashed access token" do
|
||||||
|
access_token = OidcAccessToken.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
scope: "openid profile email"
|
||||||
|
)
|
||||||
|
|
||||||
|
plaintext_token = access_token.plaintext_token
|
||||||
|
|
||||||
|
get "/oauth/userinfo", headers: {
|
||||||
|
"Authorization" => "Bearer #{plaintext_token}"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
json = JSON.parse(response.body)
|
||||||
|
assert_equal @user.id.to_s, json["sub"]
|
||||||
|
assert_equal @user.email_address, json["email"]
|
||||||
|
end
|
||||||
|
end
|
||||||
7
test/jobs/oidc_token_cleanup_job_test.rb
Normal file
7
test/jobs/oidc_token_cleanup_job_test.rb
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class OidcTokenCleanupJobTest < ActiveJob::TestCase
|
||||||
|
# test "the truth" do
|
||||||
|
# assert true
|
||||||
|
# end
|
||||||
|
end
|
||||||
177
test/models/pkce_authorization_code_test.rb
Normal file
177
test/models/pkce_authorization_code_test.rb
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class PkceAuthorizationCodeTest < ActiveSupport::TestCase
|
||||||
|
def setup
|
||||||
|
@user = User.create!(email_address: "pkce_test@example.com", password: "password123")
|
||||||
|
@application = Application.create!(
|
||||||
|
name: "PKCE Test App",
|
||||||
|
slug: "pkce-test-app",
|
||||||
|
app_type: "oidc",
|
||||||
|
redirect_uris: ["http://localhost:4000/callback"].to_json,
|
||||||
|
active: true
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def teardown
|
||||||
|
# Clean up any authorization codes first to avoid foreign key constraints
|
||||||
|
OidcAuthorizationCode.where(application: @application).destroy_all
|
||||||
|
@user.destroy
|
||||||
|
@application.destroy
|
||||||
|
end
|
||||||
|
|
||||||
|
test "authorization code can store PKCE challenge with S256 method" do
|
||||||
|
code_challenge = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
||||||
|
code_challenge_method = "S256"
|
||||||
|
|
||||||
|
auth_code = OidcAuthorizationCode.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
code: SecureRandom.urlsafe_base64(32),
|
||||||
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
|
scope: "openid profile",
|
||||||
|
code_challenge: code_challenge,
|
||||||
|
code_challenge_method: code_challenge_method,
|
||||||
|
expires_at: 10.minutes.from_now
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_equal code_challenge, auth_code.code_challenge
|
||||||
|
assert_equal code_challenge_method, auth_code.code_challenge_method
|
||||||
|
assert auth_code.uses_pkce?
|
||||||
|
end
|
||||||
|
|
||||||
|
test "authorization code can store PKCE challenge with plain method" do
|
||||||
|
code_challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
|
||||||
|
code_challenge_method = "plain"
|
||||||
|
|
||||||
|
auth_code = OidcAuthorizationCode.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
code: SecureRandom.urlsafe_base64(32),
|
||||||
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
|
scope: "openid profile",
|
||||||
|
code_challenge: code_challenge,
|
||||||
|
code_challenge_method: code_challenge_method,
|
||||||
|
expires_at: 10.minutes.from_now
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_equal code_challenge, auth_code.code_challenge
|
||||||
|
assert_equal code_challenge_method, auth_code.code_challenge_method
|
||||||
|
assert auth_code.uses_pkce?
|
||||||
|
end
|
||||||
|
|
||||||
|
test "authorization code works without PKCE (backward compatibility)" do
|
||||||
|
auth_code = OidcAuthorizationCode.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
code: SecureRandom.urlsafe_base64(32),
|
||||||
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
|
scope: "openid profile",
|
||||||
|
expires_at: 10.minutes.from_now
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_nil auth_code.code_challenge
|
||||||
|
assert_nil auth_code.code_challenge_method
|
||||||
|
assert_not auth_code.uses_pkce?
|
||||||
|
end
|
||||||
|
|
||||||
|
test "code_challenge_method validation accepts valid methods" do
|
||||||
|
auth_code = OidcAuthorizationCode.new(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
code: SecureRandom.urlsafe_base64(32),
|
||||||
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
|
scope: "openid profile",
|
||||||
|
code_challenge: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk",
|
||||||
|
code_challenge_method: "S256",
|
||||||
|
expires_at: 10.minutes.from_now
|
||||||
|
)
|
||||||
|
|
||||||
|
assert auth_code.valid?
|
||||||
|
end
|
||||||
|
|
||||||
|
test "code_challenge_method validation rejects invalid methods" do
|
||||||
|
auth_code = OidcAuthorizationCode.new(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
code: SecureRandom.urlsafe_base64(32),
|
||||||
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
|
scope: "openid profile",
|
||||||
|
code_challenge: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk",
|
||||||
|
code_challenge_method: "invalid_method",
|
||||||
|
expires_at: 10.minutes.from_now
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_not auth_code.valid?
|
||||||
|
assert_includes auth_code.errors[:code_challenge_method], "is not included in the list"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "code_challenge format validation accepts valid base64url" do
|
||||||
|
# Valid base64url encoded string (43 characters, valid characters)
|
||||||
|
valid_challenge = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
||||||
|
|
||||||
|
auth_code = OidcAuthorizationCode.new(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
code: SecureRandom.urlsafe_base64(32),
|
||||||
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
|
scope: "openid profile",
|
||||||
|
code_challenge: valid_challenge,
|
||||||
|
code_challenge_method: "S256",
|
||||||
|
expires_at: 10.minutes.from_now
|
||||||
|
)
|
||||||
|
|
||||||
|
assert auth_code.valid?
|
||||||
|
end
|
||||||
|
|
||||||
|
test "code_challenge format validation rejects invalid format" do
|
||||||
|
# Invalid: contains + character (not base64url)
|
||||||
|
invalid_challenge = "dBjftJeZ4CVP+mB92K27uhbUJU1p1r/wW1gFWFOEjXk"
|
||||||
|
|
||||||
|
auth_code = OidcAuthorizationCode.new(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
code: SecureRandom.urlsafe_base64(32),
|
||||||
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
|
scope: "openid profile",
|
||||||
|
code_challenge: invalid_challenge,
|
||||||
|
code_challenge_method: "S256",
|
||||||
|
expires_at: 10.minutes.from_now
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_not auth_code.valid?
|
||||||
|
assert_includes auth_code.errors[:code_challenge], "must be 43-128 characters of base64url encoding"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "code_challenge format validation rejects wrong length" do
|
||||||
|
# Invalid: too short (42 characters)
|
||||||
|
short_challenge = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjX"
|
||||||
|
|
||||||
|
auth_code = OidcAuthorizationCode.new(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
code: SecureRandom.urlsafe_base64(32),
|
||||||
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
|
scope: "openid profile",
|
||||||
|
code_challenge: short_challenge,
|
||||||
|
code_challenge_method: "S256",
|
||||||
|
expires_at: 10.minutes.from_now
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_not auth_code.valid?
|
||||||
|
assert_includes auth_code.errors[:code_challenge], "must be 43-128 characters of base64url encoding"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "code_challenge validation is skipped when no challenge present" do
|
||||||
|
auth_code = OidcAuthorizationCode.new(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
code: SecureRandom.urlsafe_base64(32),
|
||||||
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
|
scope: "openid profile",
|
||||||
|
expires_at: 10.minutes.from_now
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should be valid even without code_challenge
|
||||||
|
assert auth_code.valid?
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -230,4 +230,132 @@ class UserTest < ActiveSupport::TestCase
|
|||||||
assert_not user.valid?
|
assert_not user.valid?
|
||||||
assert_includes user.errors[:password], "is too short (minimum is 8 characters)"
|
assert_includes user.errors[:password], "is too short (minimum is 8 characters)"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Backup codes tests
|
||||||
|
test "generate_backup_codes returns 10 plain codes and stores BCrypt hashes" do
|
||||||
|
user = User.create!(
|
||||||
|
email_address: "test@example.com",
|
||||||
|
password: "password123"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate backup codes
|
||||||
|
plain_codes = user.send(:generate_backup_codes)
|
||||||
|
|
||||||
|
# Should return 10 plain codes
|
||||||
|
assert_equal 10, plain_codes.length
|
||||||
|
assert_kind_of Array, plain_codes
|
||||||
|
|
||||||
|
# All codes should be 8 characters, alphanumeric, uppercase
|
||||||
|
plain_codes.each do |code|
|
||||||
|
assert_equal 8, code.length
|
||||||
|
assert_match(/\A[A-Z0-9]+\z/, code)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Save user to persist the backup codes
|
||||||
|
user.save!
|
||||||
|
|
||||||
|
# Reload user from database to check stored values
|
||||||
|
user.reload
|
||||||
|
stored_hashes = user.backup_codes || []
|
||||||
|
|
||||||
|
# Should store 10 BCrypt hashes
|
||||||
|
assert_equal 10, stored_hashes.length
|
||||||
|
stored_hashes.each do |hash|
|
||||||
|
assert hash.start_with?('$2a$'), "Should be BCrypt hash"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Verify each plain code matches its corresponding hash
|
||||||
|
plain_codes.each_with_index do |code, index|
|
||||||
|
assert BCrypt::Password.new(stored_hashes[index]) == code, "Plain code should match stored hash"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "verify_backup_code works with BCrypt hashes" do
|
||||||
|
user = User.create!(
|
||||||
|
email_address: "test@example.com",
|
||||||
|
password: "password123"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate backup codes using the new flow (simulate what happens in controller)
|
||||||
|
plain_codes = user.send(:generate_backup_codes)
|
||||||
|
user.save!
|
||||||
|
user.reload
|
||||||
|
|
||||||
|
# Should successfully verify a valid backup code
|
||||||
|
assert user.verify_backup_code(plain_codes.first), "Should verify first backup code"
|
||||||
|
|
||||||
|
# Code should be deleted after use (single-use property)
|
||||||
|
user.reload
|
||||||
|
assert user.verify_backup_code(plain_codes.first) == false, "Used code should not be verifiable again"
|
||||||
|
|
||||||
|
# Should still verify other unused codes
|
||||||
|
assert user.verify_backup_code(plain_codes.second), "Should verify second backup code"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "verify_backup_code returns false for invalid codes" do
|
||||||
|
user = User.create!(
|
||||||
|
email_address: "test@example.com",
|
||||||
|
password: "password123"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate backup codes
|
||||||
|
plain_codes = user.send(:generate_backup_codes)
|
||||||
|
user.save!
|
||||||
|
user.reload
|
||||||
|
|
||||||
|
# Should fail for invalid codes
|
||||||
|
assert_not user.verify_backup_code("INVALID123"), "Should fail for invalid code"
|
||||||
|
assert_not user.verify_backup_code(""), "Should fail for empty code"
|
||||||
|
assert_not user.verify_backup_code(plain_codes.first + "X"), "Should fail for modified valid code"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "verify_backup_code returns false when no backup codes exist" do
|
||||||
|
user = User.create!(
|
||||||
|
email_address: "test@example.com",
|
||||||
|
password: "password123"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should return false when user has no backup codes
|
||||||
|
assert_not user.verify_backup_code("ANYCODE123"), "Should fail when no backup codes exist"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "verify_backup_code respects rate limiting" do
|
||||||
|
# Temporarily use memory store for this test
|
||||||
|
original_cache_store = Rails.cache
|
||||||
|
Rails.cache = ActiveSupport::Cache::MemoryStore.new
|
||||||
|
|
||||||
|
user = User.create!(
|
||||||
|
email_address: "test@example.com",
|
||||||
|
password: "password123"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate backup codes
|
||||||
|
plain_codes = user.send(:generate_backup_codes)
|
||||||
|
user.save!
|
||||||
|
user.reload
|
||||||
|
|
||||||
|
# Make 5 failed attempts to trigger rate limit
|
||||||
|
5.times do |i|
|
||||||
|
result = user.verify_backup_code("INVALID123")
|
||||||
|
assert_not result, "Failed attempt #{i+1} should return false"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check that the cache is tracking attempts
|
||||||
|
attempts = Rails.cache.read("backup_code_failed_attempts_#{user.id}") || 0
|
||||||
|
assert_equal 5, attempts, "Should have 5 failed attempts tracked"
|
||||||
|
|
||||||
|
# 6th attempt should be rate limited (both valid and invalid codes should fail)
|
||||||
|
assert_not user.verify_backup_code(plain_codes.first), "Valid code should be rate limited after 5 failed attempts"
|
||||||
|
assert_not user.verify_backup_code("INVALID123"), "Invalid code should also be rate limited"
|
||||||
|
|
||||||
|
# Valid code should still work if we clear the rate limit
|
||||||
|
Rails.cache.delete("backup_code_failed_attempts_#{user.id}")
|
||||||
|
assert user.verify_backup_code(plain_codes.first), "Should work after clearing rate limit"
|
||||||
|
|
||||||
|
# Restore original cache store
|
||||||
|
Rails.cache = original_cache_store
|
||||||
|
end
|
||||||
|
|
||||||
|
# Note: parsed_backup_codes method and legacy tests removed
|
||||||
|
# All users now use BCrypt hashes stored in JSON column
|
||||||
end
|
end
|
||||||
|
|||||||
Reference in New Issue
Block a user