Compare commits
21 Commits
631b2b53bb
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab362aabac | ||
|
|
283feea175 | ||
|
|
7af8624bf8 | ||
|
|
f8543f98cc | ||
|
|
6be23c2c37 | ||
|
|
eb2d7379bf | ||
|
|
67d86e5835 | ||
|
|
d6029556d3 | ||
|
|
7796c38c08 | ||
|
|
e882a4d6d1 | ||
|
|
ab0085e9c9 | ||
|
|
1ee3302319 | ||
|
|
67f28faaca | ||
|
|
33ad956508 | ||
|
|
11ec753c68 | ||
|
|
4df2eee4d9 | ||
|
|
d9f11abbbf | ||
|
|
c92e69fa4a | ||
|
|
038801f34b | ||
|
|
8e0b2c28eb | ||
|
|
f02665f690 |
@@ -11,6 +11,8 @@
|
|||||||
ARG RUBY_VERSION=3.4.6
|
ARG RUBY_VERSION=3.4.6
|
||||||
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
|
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
|
||||||
|
|
||||||
|
LABEL org.opencontainers.image.source=https://github.com/dkam/clinch
|
||||||
|
|
||||||
# Rails app lives here
|
# Rails app lives here
|
||||||
WORKDIR /rails
|
WORKDIR /rails
|
||||||
|
|
||||||
|
|||||||
6
Gemfile
6
Gemfile
@@ -35,11 +35,11 @@ gem "jwt", "~> 3.1"
|
|||||||
gem "webauthn", "~> 3.0"
|
gem "webauthn", "~> 3.0"
|
||||||
|
|
||||||
# Public Suffix List for domain parsing
|
# Public Suffix List for domain parsing
|
||||||
gem "public_suffix", "~> 6.0"
|
gem "public_suffix", "~> 7.0"
|
||||||
|
|
||||||
# Error tracking and performance monitoring (optional, configured via SENTRY_DSN)
|
# Error tracking and performance monitoring (optional, configured via SENTRY_DSN)
|
||||||
gem "sentry-ruby", "~> 5.18"
|
gem "sentry-ruby", "~> 6.2"
|
||||||
gem "sentry-rails", "~> 5.18"
|
gem "sentry-rails", "~> 6.2"
|
||||||
|
|
||||||
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
|
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
|
||||||
gem "tzinfo-data", platforms: %i[ windows jruby ]
|
gem "tzinfo-data", platforms: %i[ windows jruby ]
|
||||||
|
|||||||
92
Gemfile.lock
92
Gemfile.lock
@@ -75,8 +75,8 @@ GEM
|
|||||||
securerandom (>= 0.3)
|
securerandom (>= 0.3)
|
||||||
tzinfo (~> 2.0, >= 2.0.5)
|
tzinfo (~> 2.0, >= 2.0.5)
|
||||||
uri (>= 0.13.1)
|
uri (>= 0.13.1)
|
||||||
addressable (2.8.7)
|
addressable (2.8.8)
|
||||||
public_suffix (>= 2.0.2, < 7.0)
|
public_suffix (>= 2.0.2, < 8.0)
|
||||||
android_key_attestation (0.3.0)
|
android_key_attestation (0.3.0)
|
||||||
ast (2.4.3)
|
ast (2.4.3)
|
||||||
base64 (0.3.0)
|
base64 (0.3.0)
|
||||||
@@ -85,13 +85,13 @@ GEM
|
|||||||
bigdecimal (3.3.1)
|
bigdecimal (3.3.1)
|
||||||
bindata (2.5.1)
|
bindata (2.5.1)
|
||||||
bindex (0.8.1)
|
bindex (0.8.1)
|
||||||
bootsnap (1.18.6)
|
bootsnap (1.19.0)
|
||||||
msgpack (~> 1.2)
|
msgpack (~> 1.2)
|
||||||
brakeman (7.1.0)
|
brakeman (7.1.1)
|
||||||
racc
|
racc
|
||||||
builder (3.3.0)
|
builder (3.3.0)
|
||||||
bundler-audit (0.9.2)
|
bundler-audit (0.9.3)
|
||||||
bundler (>= 1.2.0, < 3)
|
bundler (>= 1.2.0)
|
||||||
thor (~> 1.0)
|
thor (~> 1.0)
|
||||||
capybara (3.40.0)
|
capybara (3.40.0)
|
||||||
addressable
|
addressable
|
||||||
@@ -107,7 +107,7 @@ GEM
|
|||||||
logger (~> 1.5)
|
logger (~> 1.5)
|
||||||
chunky_png (1.4.0)
|
chunky_png (1.4.0)
|
||||||
concurrent-ruby (1.3.5)
|
concurrent-ruby (1.3.5)
|
||||||
connection_pool (2.5.4)
|
connection_pool (2.5.5)
|
||||||
cose (1.3.1)
|
cose (1.3.1)
|
||||||
cbor (~> 0.5.9)
|
cbor (~> 0.5.9)
|
||||||
openssl-signature_algorithm (~> 1.0)
|
openssl-signature_algorithm (~> 1.0)
|
||||||
@@ -119,7 +119,7 @@ GEM
|
|||||||
dotenv (3.1.8)
|
dotenv (3.1.8)
|
||||||
drb (2.2.3)
|
drb (2.2.3)
|
||||||
ed25519 (1.4.0)
|
ed25519 (1.4.0)
|
||||||
erb (5.1.3)
|
erb (6.0.0)
|
||||||
erubi (1.13.1)
|
erubi (1.13.1)
|
||||||
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)
|
||||||
@@ -147,10 +147,10 @@ GEM
|
|||||||
jbuilder (2.14.1)
|
jbuilder (2.14.1)
|
||||||
actionview (>= 7.0.0)
|
actionview (>= 7.0.0)
|
||||||
activesupport (>= 7.0.0)
|
activesupport (>= 7.0.0)
|
||||||
json (2.15.2)
|
json (2.16.0)
|
||||||
jwt (3.1.2)
|
jwt (3.1.2)
|
||||||
base64
|
base64
|
||||||
kamal (2.8.1)
|
kamal (2.9.0)
|
||||||
activesupport (>= 7.0)
|
activesupport (>= 7.0)
|
||||||
base64 (~> 0.2)
|
base64 (~> 0.2)
|
||||||
bcrypt_pbkdf (~> 1.0)
|
bcrypt_pbkdf (~> 1.0)
|
||||||
@@ -184,7 +184,7 @@ GEM
|
|||||||
mini_magick (5.3.1)
|
mini_magick (5.3.1)
|
||||||
logger
|
logger
|
||||||
mini_mime (1.1.5)
|
mini_mime (1.1.5)
|
||||||
minitest (5.26.0)
|
minitest (5.26.2)
|
||||||
msgpack (1.8.0)
|
msgpack (1.8.0)
|
||||||
net-imap (0.5.12)
|
net-imap (0.5.12)
|
||||||
date
|
date
|
||||||
@@ -220,7 +220,7 @@ GEM
|
|||||||
openssl (> 2.0)
|
openssl (> 2.0)
|
||||||
ostruct (0.6.3)
|
ostruct (0.6.3)
|
||||||
parallel (1.27.0)
|
parallel (1.27.0)
|
||||||
parser (3.3.9.0)
|
parser (3.3.10.0)
|
||||||
ast (~> 2.4.1)
|
ast (~> 2.4.1)
|
||||||
racc
|
racc
|
||||||
pp (0.6.3)
|
pp (0.6.3)
|
||||||
@@ -234,7 +234,7 @@ GEM
|
|||||||
psych (5.2.6)
|
psych (5.2.6)
|
||||||
date
|
date
|
||||||
stringio
|
stringio
|
||||||
public_suffix (6.0.2)
|
public_suffix (7.0.0)
|
||||||
puma (7.1.0)
|
puma (7.1.0)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
racc (1.8.1)
|
racc (1.8.1)
|
||||||
@@ -278,20 +278,20 @@ GEM
|
|||||||
zeitwerk (~> 2.6)
|
zeitwerk (~> 2.6)
|
||||||
rainbow (3.1.1)
|
rainbow (3.1.1)
|
||||||
rake (13.3.1)
|
rake (13.3.1)
|
||||||
rdoc (6.15.1)
|
rdoc (6.16.1)
|
||||||
erb
|
erb
|
||||||
psych (>= 4.0.0)
|
psych (>= 4.0.0)
|
||||||
tsort
|
tsort
|
||||||
regexp_parser (2.11.3)
|
regexp_parser (2.11.3)
|
||||||
reline (0.6.2)
|
reline (0.6.3)
|
||||||
io-console (~> 0.5)
|
io-console (~> 0.5)
|
||||||
rexml (3.4.4)
|
rexml (3.4.4)
|
||||||
rotp (6.3.0)
|
rotp (6.3.0)
|
||||||
rqrcode (3.1.0)
|
rqrcode (3.1.1)
|
||||||
chunky_png (~> 1.0)
|
chunky_png (~> 1.0)
|
||||||
rqrcode_core (~> 2.0)
|
rqrcode_core (~> 2.0)
|
||||||
rqrcode_core (2.0.0)
|
rqrcode_core (2.0.1)
|
||||||
rubocop (1.81.6)
|
rubocop (1.81.7)
|
||||||
json (~> 2.3)
|
json (~> 2.3)
|
||||||
language_server-protocol (~> 3.17.0.2)
|
language_server-protocol (~> 3.17.0.2)
|
||||||
lint_roller (~> 1.1.0)
|
lint_roller (~> 1.1.0)
|
||||||
@@ -302,14 +302,14 @@ GEM
|
|||||||
rubocop-ast (>= 1.47.1, < 2.0)
|
rubocop-ast (>= 1.47.1, < 2.0)
|
||||||
ruby-progressbar (~> 1.7)
|
ruby-progressbar (~> 1.7)
|
||||||
unicode-display_width (>= 2.4.0, < 4.0)
|
unicode-display_width (>= 2.4.0, < 4.0)
|
||||||
rubocop-ast (1.47.1)
|
rubocop-ast (1.48.0)
|
||||||
parser (>= 3.3.7.2)
|
parser (>= 3.3.7.2)
|
||||||
prism (~> 1.4)
|
prism (~> 1.4)
|
||||||
rubocop-performance (1.26.1)
|
rubocop-performance (1.26.1)
|
||||||
lint_roller (~> 1.1)
|
lint_roller (~> 1.1)
|
||||||
rubocop (>= 1.75.0, < 2.0)
|
rubocop (>= 1.75.0, < 2.0)
|
||||||
rubocop-ast (>= 1.47.1, < 2.0)
|
rubocop-ast (>= 1.47.1, < 2.0)
|
||||||
rubocop-rails (2.33.4)
|
rubocop-rails (2.34.2)
|
||||||
activesupport (>= 4.2.0)
|
activesupport (>= 4.2.0)
|
||||||
lint_roller (~> 1.1)
|
lint_roller (~> 1.1)
|
||||||
rack (>= 1.1)
|
rack (>= 1.1)
|
||||||
@@ -323,7 +323,7 @@ GEM
|
|||||||
ruby-vips (2.2.5)
|
ruby-vips (2.2.5)
|
||||||
ffi (~> 1.12)
|
ffi (~> 1.12)
|
||||||
logger
|
logger
|
||||||
rubyzip (3.2.1)
|
rubyzip (3.2.2)
|
||||||
safety_net_attestation (0.5.0)
|
safety_net_attestation (0.5.0)
|
||||||
jwt (>= 2.0, < 4.0)
|
jwt (>= 2.0, < 4.0)
|
||||||
securerandom (0.4.1)
|
securerandom (0.4.1)
|
||||||
@@ -333,10 +333,10 @@ GEM
|
|||||||
rexml (~> 3.2, >= 3.2.5)
|
rexml (~> 3.2, >= 3.2.5)
|
||||||
rubyzip (>= 1.2.2, < 4.0)
|
rubyzip (>= 1.2.2, < 4.0)
|
||||||
websocket (~> 1.0)
|
websocket (~> 1.0)
|
||||||
sentry-rails (5.28.0)
|
sentry-rails (6.2.0)
|
||||||
railties (>= 5.0)
|
railties (>= 5.2.0)
|
||||||
sentry-ruby (~> 5.28.0)
|
sentry-ruby (~> 6.2.0)
|
||||||
sentry-ruby (5.28.0)
|
sentry-ruby (6.2.0)
|
||||||
bigdecimal
|
bigdecimal
|
||||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||||
solid_cable (3.0.12)
|
solid_cable (3.0.12)
|
||||||
@@ -344,17 +344,17 @@ GEM
|
|||||||
activejob (>= 7.2)
|
activejob (>= 7.2)
|
||||||
activerecord (>= 7.2)
|
activerecord (>= 7.2)
|
||||||
railties (>= 7.2)
|
railties (>= 7.2)
|
||||||
solid_cache (1.0.8)
|
solid_cache (1.0.10)
|
||||||
activejob (>= 7.2)
|
activejob (>= 7.2)
|
||||||
activerecord (>= 7.2)
|
activerecord (>= 7.2)
|
||||||
railties (>= 7.2)
|
railties (>= 7.2)
|
||||||
sqlite3 (2.7.4-aarch64-linux-gnu)
|
sqlite3 (2.8.1-aarch64-linux-gnu)
|
||||||
sqlite3 (2.7.4-aarch64-linux-musl)
|
sqlite3 (2.8.1-aarch64-linux-musl)
|
||||||
sqlite3 (2.7.4-arm-linux-gnu)
|
sqlite3 (2.8.1-arm-linux-gnu)
|
||||||
sqlite3 (2.7.4-arm-linux-musl)
|
sqlite3 (2.8.1-arm-linux-musl)
|
||||||
sqlite3 (2.7.4-arm64-darwin)
|
sqlite3 (2.8.1-arm64-darwin)
|
||||||
sqlite3 (2.7.4-x86_64-linux-gnu)
|
sqlite3 (2.8.1-x86_64-linux-gnu)
|
||||||
sqlite3 (2.7.4-x86_64-linux-musl)
|
sqlite3 (2.8.1-x86_64-linux-musl)
|
||||||
sshkit (1.24.0)
|
sshkit (1.24.0)
|
||||||
base64
|
base64
|
||||||
logger
|
logger
|
||||||
@@ -364,16 +364,16 @@ GEM
|
|||||||
ostruct
|
ostruct
|
||||||
stimulus-rails (1.3.4)
|
stimulus-rails (1.3.4)
|
||||||
railties (>= 6.0.0)
|
railties (>= 6.0.0)
|
||||||
stringio (3.1.7)
|
stringio (3.1.8)
|
||||||
tailwindcss-rails (4.3.0)
|
tailwindcss-rails (4.4.0)
|
||||||
railties (>= 7.0.0)
|
railties (>= 7.0.0)
|
||||||
tailwindcss-ruby (~> 4.0)
|
tailwindcss-ruby (~> 4.0)
|
||||||
tailwindcss-ruby (4.1.13)
|
tailwindcss-ruby (4.1.16)
|
||||||
tailwindcss-ruby (4.1.13-aarch64-linux-gnu)
|
tailwindcss-ruby (4.1.16-aarch64-linux-gnu)
|
||||||
tailwindcss-ruby (4.1.13-aarch64-linux-musl)
|
tailwindcss-ruby (4.1.16-aarch64-linux-musl)
|
||||||
tailwindcss-ruby (4.1.13-arm64-darwin)
|
tailwindcss-ruby (4.1.16-arm64-darwin)
|
||||||
tailwindcss-ruby (4.1.13-x86_64-linux-gnu)
|
tailwindcss-ruby (4.1.16-x86_64-linux-gnu)
|
||||||
tailwindcss-ruby (4.1.13-x86_64-linux-musl)
|
tailwindcss-ruby (4.1.16-x86_64-linux-musl)
|
||||||
thor (1.4.0)
|
thor (1.4.0)
|
||||||
thruster (0.1.16)
|
thruster (0.1.16)
|
||||||
thruster (0.1.16-aarch64-linux)
|
thruster (0.1.16-aarch64-linux)
|
||||||
@@ -385,7 +385,7 @@ GEM
|
|||||||
openssl (> 2.0)
|
openssl (> 2.0)
|
||||||
openssl-signature_algorithm (~> 1.0)
|
openssl-signature_algorithm (~> 1.0)
|
||||||
tsort (0.2.0)
|
tsort (0.2.0)
|
||||||
turbo-rails (2.0.17)
|
turbo-rails (2.0.20)
|
||||||
actionpack (>= 7.1.0)
|
actionpack (>= 7.1.0)
|
||||||
railties (>= 7.1.0)
|
railties (>= 7.1.0)
|
||||||
tzinfo (2.0.6)
|
tzinfo (2.0.6)
|
||||||
@@ -393,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.1.0)
|
uri (1.1.1)
|
||||||
useragent (0.16.11)
|
useragent (0.16.11)
|
||||||
web-console (4.2.1)
|
web-console (4.2.1)
|
||||||
actionview (>= 6.0.0)
|
actionview (>= 6.0.0)
|
||||||
@@ -442,15 +442,15 @@ DEPENDENCIES
|
|||||||
kamal
|
kamal
|
||||||
letter_opener
|
letter_opener
|
||||||
propshaft
|
propshaft
|
||||||
public_suffix (~> 6.0)
|
public_suffix (~> 7.0)
|
||||||
puma (>= 5.0)
|
puma (>= 5.0)
|
||||||
rails (~> 8.1.1)
|
rails (~> 8.1.1)
|
||||||
rotp (~> 6.3)
|
rotp (~> 6.3)
|
||||||
rqrcode (~> 3.1)
|
rqrcode (~> 3.1)
|
||||||
rubocop-rails-omakase
|
rubocop-rails-omakase
|
||||||
selenium-webdriver
|
selenium-webdriver
|
||||||
sentry-rails (~> 5.18)
|
sentry-rails (~> 6.2)
|
||||||
sentry-ruby (~> 5.18)
|
sentry-ruby (~> 6.2)
|
||||||
solid_cable
|
solid_cable
|
||||||
solid_cache
|
solid_cache
|
||||||
sqlite3 (>= 2.1)
|
sqlite3 (>= 2.1)
|
||||||
|
|||||||
95
README.md
95
README.md
@@ -13,11 +13,14 @@ I've completed all planned features:
|
|||||||
* TOTP ( QR Code ) 2FA, with backup codes ( encrypted at rest )
|
* TOTP ( QR Code ) 2FA, with backup codes ( encrypted at rest )
|
||||||
* Passkey generation and login, with detection of Passkey during login
|
* Passkey generation and login, with detection of Passkey during login
|
||||||
* Forward Auth configured and working
|
* Forward Auth configured and working
|
||||||
* OIDC provider with auto discovery working
|
* OIDC provider with auto discovery, refresh tokens, and token revocation
|
||||||
|
* Configurable token expiry per application (access, refresh, ID tokens)
|
||||||
|
* Backchannel Logout
|
||||||
|
* Per-application logout / revoke
|
||||||
* Invite users by email, assign to groups
|
* Invite users by email, assign to groups
|
||||||
* Self managed password reset by email
|
* Self managed password reset by email
|
||||||
* Use Groups to assign Applications ( Family group can access Kavita, Developers can access Gitea )
|
* Use Groups to assign Applications ( Family group can access Kavita, Developers can access Gitea )
|
||||||
* Configurable Group and User custom claims for OIDC token
|
* Configurable Group, User & App+User custom claims for OIDC token
|
||||||
* Display all Applications available to the user on their Dashboard
|
* Display all Applications available to the user on their Dashboard
|
||||||
* Display all logged in sessions and OIDC logged in sessions
|
* Display all logged in sessions and OIDC logged in sessions
|
||||||
|
|
||||||
@@ -75,22 +78,30 @@ Clinch sits in a sweet spot between two excellent open-source identity solutions
|
|||||||
- **User statuses** - Active, disabled, or pending invitation
|
- **User statuses** - Active, disabled, or pending invitation
|
||||||
|
|
||||||
### Authentication Methods
|
### Authentication Methods
|
||||||
|
- **WebAuthn/Passkeys** - Modern passwordless authentication using FIDO2 standards
|
||||||
- **Password authentication** - Secure bcrypt-based password storage
|
- **Password authentication** - Secure bcrypt-based password storage
|
||||||
- **Magic login links** - Passwordless login via email (15-minute expiry)
|
|
||||||
- **TOTP 2FA** - Optional time-based one-time passwords with QR code setup
|
- **TOTP 2FA** - Optional time-based one-time passwords with QR code setup
|
||||||
- **Backup codes** - 10 single-use recovery codes per user
|
- **Backup codes** - 10 single-use recovery codes per user
|
||||||
- **Configurable 2FA enforcement** - Admins can require TOTP for specific users/groups
|
- **Configurable 2FA enforcement** - Admins can require TOTP for specific users
|
||||||
|
|
||||||
### SSO Protocols
|
### SSO Protocols
|
||||||
|
|
||||||
#### OpenID Connect (OIDC)
|
#### OpenID Connect (OIDC)
|
||||||
Standard OAuth2/OIDC provider with endpoints:
|
Standard OAuth2/OIDC provider with endpoints:
|
||||||
- `/.well-known/openid-configuration` - Discovery endpoint
|
- `/.well-known/openid-configuration` - Discovery endpoint
|
||||||
- `/authorize` - Authorization endpoint
|
- `/authorize` - Authorization endpoint with PKCE support
|
||||||
- `/token` - Token endpoint
|
- `/token` - Token endpoint (authorization_code and refresh_token grants)
|
||||||
- `/userinfo` - User info endpoint
|
- `/userinfo` - User info endpoint
|
||||||
|
- `/revoke` - Token revocation endpoint (RFC 7009)
|
||||||
|
|
||||||
Client apps (Audiobookshelf, Kavita, Grafana, etc.) redirect to Clinch for login and receive ID tokens and access tokens.
|
Features:
|
||||||
|
- **Refresh tokens** - Long-lived tokens (30 days default) with automatic rotation and revocation
|
||||||
|
- **Token family tracking** - Advanced security detects token replay attacks and revokes compromised token families
|
||||||
|
- **Configurable token expiry** - Set access token (5min-24hr), refresh token (1-90 days), and ID token TTL per application
|
||||||
|
- **Token security** - BCrypt-hashed tokens, automatic cleanup of expired tokens
|
||||||
|
- **Pairwise subject identifiers** - Each user gets a unique, stable `sub` claim per application for enhanced privacy
|
||||||
|
|
||||||
|
Client apps (Audiobookshelf, Kavita, Grafana, etc.) redirect to Clinch for login and receive ID tokens, access tokens, and refresh tokens.
|
||||||
|
|
||||||
#### Trusted-Header SSO (ForwardAuth)
|
#### Trusted-Header SSO (ForwardAuth)
|
||||||
Works with reverse proxies (Caddy, Traefik, Nginx):
|
Works with reverse proxies (Caddy, Traefik, Nginx):
|
||||||
@@ -114,10 +125,54 @@ Send emails for:
|
|||||||
- **Session revocation** - Users and admins can revoke individual sessions
|
- **Session revocation** - Users and admins can revoke individual sessions
|
||||||
|
|
||||||
### Access Control
|
### Access Control
|
||||||
- **Group-based allowlists** - Restrict applications to specific user groups
|
|
||||||
- **Per-application access** - Each app defines which groups can access it
|
#### Group-Based Application Access
|
||||||
- **Automatic enforcement** - Access checks during OIDC authorization and ForwardAuth
|
Clinch uses groups to control which users can access which applications:
|
||||||
- **Custom claims** - Add arbitrary claims to OIDC tokens via groups and users (perfect for app-specific roles)
|
|
||||||
|
- **Create groups** - Organize users into logical groups (readers, editors, family, developers, etc.)
|
||||||
|
- **Assign groups to applications** - Each app defines which groups are allowed to access it
|
||||||
|
- Example: Kavita app allows the "readers" group → only users in the "readers" group can sign in
|
||||||
|
- If no groups are assigned to an app → all active users can access it
|
||||||
|
- **Automatic enforcement** - Access checks happen automatically:
|
||||||
|
- During OIDC authorization flow (before consent)
|
||||||
|
- During ForwardAuth verification (before proxying requests)
|
||||||
|
- Users not in allowed groups receive a "You do not have permission" error
|
||||||
|
|
||||||
|
#### Group Claims in Tokens
|
||||||
|
- **OIDC tokens include group membership** - ID tokens contain a `groups` claim with all user's groups
|
||||||
|
- **Custom claims** - Add arbitrary key-value pairs to tokens via groups and users
|
||||||
|
- Group claims apply to all members (e.g., `{"role": "viewer"}`)
|
||||||
|
- User claims override group claims for fine-grained control
|
||||||
|
- Perfect for app-specific authorization (e.g., admin vs. read-only roles)
|
||||||
|
|
||||||
|
#### Custom Claims Merging
|
||||||
|
Custom claims from groups and users are merged into OIDC ID tokens with the following precedence:
|
||||||
|
|
||||||
|
1. **Default OIDC claims** - Standard claims (`iss`, `sub`, `aud`, `exp`, `email`, etc.)
|
||||||
|
2. **Standard Clinch claims** - `groups` array (list of user's group names)
|
||||||
|
3. **Group custom claims** - Merged in order; later groups override earlier ones
|
||||||
|
4. **User custom claims** - Override all group claims
|
||||||
|
5. **Application-specific claims** - Highest priority; override all other claims
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
- Group "readers" has `{"role": "viewer", "max_items": 10}`
|
||||||
|
- Group "premium" has `{"role": "subscriber", "max_items": 100}`
|
||||||
|
- User (in both groups) has `{"max_items": 500}`
|
||||||
|
- **Result:** `{"role": "subscriber", "max_items": 500}` (user overrides max_items, premium overrides role)
|
||||||
|
|
||||||
|
#### Application-Specific Claims
|
||||||
|
Configure different claims for different applications on a per-user basis:
|
||||||
|
|
||||||
|
- **Per-app customization** - Each application can have unique claims for each user
|
||||||
|
- **Highest precedence** - App-specific claims override group and user global claims
|
||||||
|
- **Use case** - Different roles in different apps (e.g., admin in Kavita, user in Audiobookshelf)
|
||||||
|
- **Admin UI** - Configure via Admin → Users → Edit User → App-Specific Claim Overrides
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
- User Alice, global claims: `{"theme": "dark"}`
|
||||||
|
- Kavita app-specific: `{"kavita_groups": ["admin"]}`
|
||||||
|
- Audiobookshelf app-specific: `{"abs_groups": ["user"]}`
|
||||||
|
- **Result:** Kavita receives `{"theme": "dark", "kavita_groups": ["admin"]}`, Audiobookshelf receives `{"theme": "dark", "abs_groups": ["user"]}`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -156,25 +211,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`
|
||||||
@@ -258,6 +317,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
|
||||||
|
|||||||
@@ -16,16 +16,82 @@ class ActiveSessionsController < ApplicationController
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Send backchannel logout notification before revoking consent
|
||||||
|
if application.supports_backchannel_logout?
|
||||||
|
BackchannelLogoutJob.perform_later(
|
||||||
|
user_id: @user.id,
|
||||||
|
application_id: application.id,
|
||||||
|
consent_sid: consent.sid
|
||||||
|
)
|
||||||
|
Rails.logger.info "ActiveSessionsController: Enqueued backchannel logout for #{application.name}"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Revoke all tokens for this user-application pair
|
||||||
|
now = Time.current
|
||||||
|
revoked_access_tokens = OidcAccessToken.where(application: application, user: @user, revoked_at: nil)
|
||||||
|
.update_all(revoked_at: now)
|
||||||
|
revoked_refresh_tokens = OidcRefreshToken.where(application: application, user: @user, revoked_at: nil)
|
||||||
|
.update_all(revoked_at: now)
|
||||||
|
|
||||||
|
Rails.logger.info "ActiveSessionsController: Revoked #{revoked_access_tokens} access tokens and #{revoked_refresh_tokens} refresh tokens for #{application.name}"
|
||||||
|
|
||||||
# Revoke the consent
|
# Revoke the consent
|
||||||
consent.destroy
|
consent.destroy
|
||||||
redirect_to active_sessions_path, notice: "Successfully revoked access to #{application.name}."
|
redirect_to active_sessions_path, notice: "Successfully revoked access to #{application.name}."
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def logout_from_app
|
||||||
|
@user = Current.session.user
|
||||||
|
application = Application.find(params[:application_id])
|
||||||
|
|
||||||
|
# Check if user has consent for this application
|
||||||
|
consent = @user.oidc_user_consents.find_by(application: application)
|
||||||
|
unless consent
|
||||||
|
redirect_to root_path, alert: "No active session found for this application."
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Send backchannel logout notification
|
||||||
|
if application.supports_backchannel_logout?
|
||||||
|
BackchannelLogoutJob.perform_later(
|
||||||
|
user_id: @user.id,
|
||||||
|
application_id: application.id,
|
||||||
|
consent_sid: consent.sid
|
||||||
|
)
|
||||||
|
Rails.logger.info "ActiveSessionsController: Enqueued backchannel logout for #{application.name}"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Revoke all tokens for this user-application pair
|
||||||
|
now = Time.current
|
||||||
|
revoked_access_tokens = OidcAccessToken.where(application: application, user: @user, revoked_at: nil)
|
||||||
|
.update_all(revoked_at: now)
|
||||||
|
revoked_refresh_tokens = OidcRefreshToken.where(application: application, user: @user, revoked_at: nil)
|
||||||
|
.update_all(revoked_at: now)
|
||||||
|
|
||||||
|
Rails.logger.info "ActiveSessionsController: Logged out from #{application.name} - revoked #{revoked_access_tokens} access tokens and #{revoked_refresh_tokens} refresh tokens"
|
||||||
|
|
||||||
|
# Keep the consent intact - this is the key difference from revoke_consent
|
||||||
|
redirect_to root_path, notice: "Successfully logged out of #{application.name}."
|
||||||
|
end
|
||||||
|
|
||||||
def revoke_all_consents
|
def revoke_all_consents
|
||||||
@user = Current.session.user
|
@user = Current.session.user
|
||||||
count = @user.oidc_user_consents.count
|
consents = @user.oidc_user_consents.includes(:application)
|
||||||
|
count = consents.count
|
||||||
|
|
||||||
if count > 0
|
if count > 0
|
||||||
|
# Send backchannel logout notifications before revoking consents
|
||||||
|
consents.each do |consent|
|
||||||
|
next unless consent.application.supports_backchannel_logout?
|
||||||
|
|
||||||
|
BackchannelLogoutJob.perform_later(
|
||||||
|
user_id: @user.id,
|
||||||
|
application_id: consent.application.id,
|
||||||
|
consent_sid: consent.sid
|
||||||
|
)
|
||||||
|
end
|
||||||
|
Rails.logger.info "ActiveSessionsController: Enqueued #{count} backchannel logout notifications"
|
||||||
|
|
||||||
@user.oidc_user_consents.destroy_all
|
@user.oidc_user_consents.destroy_all
|
||||||
redirect_to active_sessions_path, notice: "Successfully revoked access to #{count} applications."
|
redirect_to active_sessions_path, notice: "Successfully revoked access to #{count} applications."
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -99,7 +99,9 @@ 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,
|
||||||
|
:icon, :backchannel_logout_uri,
|
||||||
|
headers_config: {}
|
||||||
).tap do |whitelisted|
|
).tap do |whitelisted|
|
||||||
# Remove client_secret from params if present (shouldn't be updated via form)
|
# Remove client_secret from params if present (shouldn't be updated via form)
|
||||||
whitelisted.delete(:client_secret)
|
whitelisted.delete(:client_secret)
|
||||||
|
|||||||
@@ -18,7 +18,25 @@ module Admin
|
|||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
@group = Group.new(group_params)
|
create_params = group_params
|
||||||
|
|
||||||
|
# Parse custom_claims JSON if provided
|
||||||
|
if create_params[:custom_claims].present?
|
||||||
|
begin
|
||||||
|
create_params[:custom_claims] = JSON.parse(create_params[:custom_claims])
|
||||||
|
rescue JSON::ParserError
|
||||||
|
@group = Group.new
|
||||||
|
@group.errors.add(:custom_claims, "must be valid JSON")
|
||||||
|
@available_users = User.order(:email_address)
|
||||||
|
render :new, status: :unprocessable_entity
|
||||||
|
return
|
||||||
|
end
|
||||||
|
else
|
||||||
|
# If empty or blank, set to empty hash (NOT NULL constraint)
|
||||||
|
create_params[:custom_claims] = {}
|
||||||
|
end
|
||||||
|
|
||||||
|
@group = Group.new(create_params)
|
||||||
|
|
||||||
if @group.save
|
if @group.save
|
||||||
# Handle user assignments
|
# Handle user assignments
|
||||||
@@ -39,7 +57,24 @@ module Admin
|
|||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
if @group.update(group_params)
|
update_params = group_params
|
||||||
|
|
||||||
|
# Parse custom_claims JSON if provided
|
||||||
|
if update_params[:custom_claims].present?
|
||||||
|
begin
|
||||||
|
update_params[:custom_claims] = JSON.parse(update_params[:custom_claims])
|
||||||
|
rescue JSON::ParserError
|
||||||
|
@group.errors.add(:custom_claims, "must be valid JSON")
|
||||||
|
@available_users = User.order(:email_address)
|
||||||
|
render :edit, status: :unprocessable_entity
|
||||||
|
return
|
||||||
|
end
|
||||||
|
else
|
||||||
|
# If empty or blank, set to empty hash (NOT NULL constraint)
|
||||||
|
update_params[:custom_claims] = {}
|
||||||
|
end
|
||||||
|
|
||||||
|
if @group.update(update_params)
|
||||||
# Handle user assignments
|
# Handle user assignments
|
||||||
if params[:group][:user_ids].present?
|
if params[:group][:user_ids].present?
|
||||||
user_ids = params[:group][:user_ids].reject(&:blank?)
|
user_ids = params[:group][:user_ids].reject(&:blank?)
|
||||||
@@ -67,7 +102,7 @@ module Admin
|
|||||||
end
|
end
|
||||||
|
|
||||||
def group_params
|
def group_params
|
||||||
params.require(:group).permit(:name, :description, custom_claims: {})
|
params.require(:group).permit(:name, :description, :custom_claims)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
module Admin
|
module Admin
|
||||||
class UsersController < BaseController
|
class UsersController < BaseController
|
||||||
before_action :set_user, only: [:show, :edit, :update, :destroy, :resend_invitation]
|
before_action :set_user, only: [:show, :edit, :update, :destroy, :resend_invitation, :update_application_claims, :delete_application_claims]
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@users = User.order(created_at: :desc)
|
@users = User.order(created_at: :desc)
|
||||||
@@ -27,23 +27,34 @@ module Admin
|
|||||||
end
|
end
|
||||||
|
|
||||||
def edit
|
def edit
|
||||||
|
@applications = Application.active.order(:name)
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
# Prevent changing params for the current user's email and admin status
|
update_params = user_params
|
||||||
# to avoid locking themselves out
|
|
||||||
update_params = user_params.dup
|
|
||||||
|
|
||||||
if @user == Current.session.user
|
|
||||||
update_params.delete(:admin)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Only update password if provided
|
# Only update password if provided
|
||||||
update_params.delete(:password) if update_params[:password].blank?
|
update_params.delete(:password) if update_params[:password].blank?
|
||||||
|
|
||||||
|
# Parse custom_claims JSON if provided
|
||||||
|
if update_params[:custom_claims].present?
|
||||||
|
begin
|
||||||
|
update_params[:custom_claims] = JSON.parse(update_params[:custom_claims])
|
||||||
|
rescue JSON::ParserError
|
||||||
|
@user.errors.add(:custom_claims, "must be valid JSON")
|
||||||
|
@applications = Application.active.order(:name)
|
||||||
|
render :edit, status: :unprocessable_entity
|
||||||
|
return
|
||||||
|
end
|
||||||
|
else
|
||||||
|
# If empty or blank, set to empty hash (NOT NULL constraint)
|
||||||
|
update_params[:custom_claims] = {}
|
||||||
|
end
|
||||||
|
|
||||||
if @user.update(update_params)
|
if @user.update(update_params)
|
||||||
redirect_to admin_users_path, notice: "User updated successfully."
|
redirect_to admin_users_path, notice: "User updated successfully."
|
||||||
else
|
else
|
||||||
|
@applications = Application.active.order(:name)
|
||||||
render :edit, status: :unprocessable_entity
|
render :edit, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -69,6 +80,41 @@ module Admin
|
|||||||
redirect_to admin_users_path, notice: "User deleted successfully."
|
redirect_to admin_users_path, notice: "User deleted successfully."
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# POST /admin/users/:id/update_application_claims
|
||||||
|
def update_application_claims
|
||||||
|
application = Application.find(params[:application_id])
|
||||||
|
|
||||||
|
claims_json = params[:custom_claims].presence || "{}"
|
||||||
|
begin
|
||||||
|
claims = JSON.parse(claims_json)
|
||||||
|
rescue JSON::ParserError
|
||||||
|
redirect_to edit_admin_user_path(@user), alert: "Invalid JSON format for claims."
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
app_claim = @user.application_user_claims.find_or_initialize_by(application: application)
|
||||||
|
app_claim.custom_claims = claims
|
||||||
|
|
||||||
|
if app_claim.save
|
||||||
|
redirect_to edit_admin_user_path(@user), notice: "App-specific claims updated for #{application.name}."
|
||||||
|
else
|
||||||
|
error_message = app_claim.errors.full_messages.join(", ")
|
||||||
|
redirect_to edit_admin_user_path(@user), alert: "Failed to update claims: #{error_message}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# DELETE /admin/users/:id/delete_application_claims
|
||||||
|
def delete_application_claims
|
||||||
|
application = Application.find(params[:application_id])
|
||||||
|
app_claim = @user.application_user_claims.find_by(application: application)
|
||||||
|
|
||||||
|
if app_claim&.destroy
|
||||||
|
redirect_to edit_admin_user_path(@user), notice: "App-specific claims removed for #{application.name}."
|
||||||
|
else
|
||||||
|
redirect_to edit_admin_user_path(@user), alert: "No claims found to remove."
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def set_user
|
def set_user
|
||||||
@@ -76,7 +122,15 @@ module Admin
|
|||||||
end
|
end
|
||||||
|
|
||||||
def user_params
|
def user_params
|
||||||
params.require(:user).permit(:email_address, :name, :password, :admin, :status, custom_claims: {})
|
# Base attributes that all admins can modify
|
||||||
|
base_params = params.require(:user).permit(:email_address, :username, :name, :password, :status, :totp_required, :custom_claims)
|
||||||
|
|
||||||
|
# Only allow modifying admin status when editing other users (prevent self-demotion)
|
||||||
|
if params[:id] != Current.session.user.id.to_s
|
||||||
|
base_params[:admin] = params[:user][:admin] if params[:user][:admin].present?
|
||||||
|
end
|
||||||
|
|
||||||
|
base_params
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -10,6 +10,13 @@ module Api
|
|||||||
report_data = JSON.parse(request.body.read)
|
report_data = JSON.parse(request.body.read)
|
||||||
csp_report = report_data['csp-report']
|
csp_report = report_data['csp-report']
|
||||||
|
|
||||||
|
# Validate that we have a proper CSP report
|
||||||
|
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: #{csp_report['blocked-uri']}"
|
Rails.logger.warn " Blocked URI: #{csp_report['blocked-uri']}"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ module Api
|
|||||||
# ForwardAuth endpoints need session storage for return URL
|
# ForwardAuth endpoints need session storage for return URL
|
||||||
allow_unauthenticated_access
|
allow_unauthenticated_access
|
||||||
skip_before_action :verify_authenticity_token
|
skip_before_action :verify_authenticity_token
|
||||||
rate_limit to: 100, within: 1.minute, only: :verify, with: -> { head :too_many_requests }
|
# No rate limiting on forward_auth endpoint - proxy middleware hits this frequently
|
||||||
|
|
||||||
# GET /api/verify
|
# GET /api/verify
|
||||||
# This endpoint is called by reverse proxies (Traefik, Caddy, nginx)
|
# This endpoint is called by reverse proxies (Traefik, Caddy, nginx)
|
||||||
@@ -221,7 +221,9 @@ module Api
|
|||||||
|
|
||||||
# Try CLINCH_HOST environment variable first
|
# Try CLINCH_HOST environment variable first
|
||||||
if ENV['CLINCH_HOST'].present?
|
if ENV['CLINCH_HOST'].present?
|
||||||
"https://#{ENV['CLINCH_HOST']}"
|
host = ENV['CLINCH_HOST']
|
||||||
|
# Ensure URL has https:// protocol
|
||||||
|
host.match?(/^https?:\/\//) ? host : "https://#{host}"
|
||||||
else
|
else
|
||||||
# Fallback to the request host
|
# Fallback to the request host
|
||||||
request_host = request.host || request.headers['X-Forwarded-Host']
|
request_host = request.host || request.headers['X-Forwarded-Host']
|
||||||
|
|||||||
@@ -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,6 +134,8 @@ 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)
|
||||||
|
|
||||||
|
# Skip adding fa_token for OAuth URLs (OAuth flow should not have forward auth tokens)
|
||||||
|
unless uri.path&.start_with?("/oauth/")
|
||||||
# Add token as query parameter
|
# Add token as query parameter
|
||||||
query_params = URI.decode_www_form(uri.query || "").to_h
|
query_params = URI.decode_www_form(uri.query || "").to_h
|
||||||
query_params['fa_token'] = token
|
query_params['fa_token'] = token
|
||||||
@@ -143,4 +145,5 @@ module Authentication
|
|||||||
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,21 @@ class OidcController < ApplicationController
|
|||||||
issuer: base_url,
|
issuer: base_url,
|
||||||
authorization_endpoint: "#{base_url}/oauth/authorize",
|
authorization_endpoint: "#{base_url}/oauth/authorize",
|
||||||
token_endpoint: "#{base_url}/oauth/token",
|
token_endpoint: "#{base_url}/oauth/token",
|
||||||
|
revocation_endpoint: "#{base_url}/oauth/revoke",
|
||||||
userinfo_endpoint: "#{base_url}/oauth/userinfo",
|
userinfo_endpoint: "#{base_url}/oauth/userinfo",
|
||||||
jwks_uri: "#{base_url}/.well-known/jwks.json",
|
jwks_uri: "#{base_url}/.well-known/jwks.json",
|
||||||
end_session_endpoint: "#{base_url}/logout",
|
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", "offline_access"],
|
||||||
token_endpoint_auth_methods_supported: ["client_secret_post", "client_secret_basic"],
|
token_endpoint_auth_methods_supported: ["client_secret_post", "client_secret_basic"],
|
||||||
claims_supported: ["sub", "email", "email_verified", "name", "preferred_username", "groups", "admin"]
|
claims_supported: ["sub", "email", "email_verified", "name", "preferred_username", "groups", "admin"],
|
||||||
|
code_challenge_methods_supported: ["plain", "S256"],
|
||||||
|
backchannel_logout_supported: true,
|
||||||
|
backchannel_logout_session_supported: true
|
||||||
}
|
}
|
||||||
|
|
||||||
render json: config
|
render json: config
|
||||||
@@ -32,30 +38,71 @@ class OidcController < ApplicationController
|
|||||||
|
|
||||||
# GET /oauth/authorize
|
# GET /oauth/authorize
|
||||||
def authorize
|
def authorize
|
||||||
# Get parameters
|
# Get parameters (ignore forward auth tokens and other unknown params)
|
||||||
client_id = params[:client_id]
|
client_id = params[:client_id]
|
||||||
redirect_uri = params[:redirect_uri]
|
redirect_uri = params[:redirect_uri]
|
||||||
state = params[:state]
|
state = params[:state]
|
||||||
nonce = params[:nonce]
|
nonce = params[:nonce]
|
||||||
scope = params[:scope] || "openid"
|
scope = params[:scope] || "openid"
|
||||||
response_type = params[:response_type]
|
response_type = params[:response_type]
|
||||||
|
code_challenge = params[:code_challenge]
|
||||||
|
code_challenge_method = params[:code_challenge_method] || "plain"
|
||||||
|
|
||||||
# Validate required parameters
|
# Validate required parameters
|
||||||
unless client_id.present? && redirect_uri.present? && response_type == "code"
|
unless client_id.present? && redirect_uri.present? && response_type == "code"
|
||||||
render plain: "Invalid request: missing required parameters", status: :bad_request
|
error_details = []
|
||||||
|
error_details << "client_id is required" unless client_id.present?
|
||||||
|
error_details << "redirect_uri is required" unless redirect_uri.present?
|
||||||
|
error_details << "response_type must be 'code'" unless response_type == "code"
|
||||||
|
|
||||||
|
render plain: "Invalid request: #{error_details.join(', ')}", status: :bad_request
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Validate PKCE parameters if present
|
||||||
|
if code_challenge.present?
|
||||||
|
unless %w[plain S256].include?(code_challenge_method)
|
||||||
|
render plain: "Invalid code_challenge_method: must be 'plain' or 'S256'", status: :bad_request
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Validate code challenge format (base64url-encoded, 43-128 characters)
|
||||||
|
unless code_challenge.match?(/\A[A-Za-z0-9\-_]{43,128}\z/)
|
||||||
|
render plain: "Invalid code_challenge format: must be 43-128 characters of base64url encoding", status: :bad_request
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# Find the application
|
# Find the application
|
||||||
@application = Application.find_by(client_id: client_id, app_type: "oidc")
|
@application = Application.find_by(client_id: client_id, app_type: "oidc")
|
||||||
unless @application
|
unless @application
|
||||||
render plain: "Invalid client_id", status: :bad_request
|
# Log all OIDC applications for debugging
|
||||||
|
all_oidc_apps = Application.where(app_type: "oidc")
|
||||||
|
Rails.logger.error "OAuth: Invalid request - application not found for client_id: #{client_id}"
|
||||||
|
Rails.logger.error "OAuth: Available OIDC applications: #{all_oidc_apps.pluck(:id, :client_id, :name)}"
|
||||||
|
|
||||||
|
error_msg = if Rails.env.development?
|
||||||
|
"Invalid request: Application not found for client_id '#{client_id}'. Available OIDC applications: #{all_oidc_apps.pluck(:name, :client_id).map { |name, id| "#{name} (#{id})" }.join(', ')}"
|
||||||
|
else
|
||||||
|
"Invalid request: Application not found"
|
||||||
|
end
|
||||||
|
|
||||||
|
render plain: error_msg, status: :bad_request
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Validate redirect URI
|
# Validate redirect URI
|
||||||
unless @application.parsed_redirect_uris.include?(redirect_uri)
|
unless @application.parsed_redirect_uris.include?(redirect_uri)
|
||||||
render plain: "Invalid redirect_uri", status: :bad_request
|
Rails.logger.error "OAuth: Invalid request - redirect URI mismatch. Expected: #{@application.parsed_redirect_uris}, Got: #{redirect_uri}"
|
||||||
|
|
||||||
|
# For development, show detailed error
|
||||||
|
error_msg = if Rails.env.development?
|
||||||
|
"Invalid request: Redirect URI mismatch. Application is configured for: #{@application.parsed_redirect_uris.join(', ')}, but received: #{redirect_uri}"
|
||||||
|
else
|
||||||
|
"Invalid request: Redirect URI not registered for this application"
|
||||||
|
end
|
||||||
|
|
||||||
|
render plain: error_msg, status: :bad_request
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -67,7 +114,9 @@ class OidcController < ApplicationController
|
|||||||
redirect_uri: redirect_uri,
|
redirect_uri: redirect_uri,
|
||||||
state: state,
|
state: state,
|
||||||
nonce: nonce,
|
nonce: nonce,
|
||||||
scope: scope
|
scope: scope,
|
||||||
|
code_challenge: code_challenge,
|
||||||
|
code_challenge_method: code_challenge_method
|
||||||
}
|
}
|
||||||
redirect_to signin_path, alert: "Please sign in to continue"
|
redirect_to signin_path, alert: "Please sign in to continue"
|
||||||
return
|
return
|
||||||
@@ -96,6 +145,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 +163,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 +238,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 +257,17 @@ class OidcController < ApplicationController
|
|||||||
def token
|
def token
|
||||||
grant_type = params[:grant_type]
|
grant_type = params[:grant_type]
|
||||||
|
|
||||||
unless grant_type == "authorization_code"
|
case grant_type
|
||||||
|
when "authorization_code"
|
||||||
|
handle_authorization_code_grant
|
||||||
|
when "refresh_token"
|
||||||
|
handle_refresh_token_grant
|
||||||
|
else
|
||||||
render json: { error: "unsupported_grant_type" }, status: :bad_request
|
render json: { error: "unsupported_grant_type" }, status: :bad_request
|
||||||
return
|
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_authorization_code_grant
|
||||||
|
|
||||||
# Get client credentials from Authorization header or params
|
# Get client credentials from Authorization header or params
|
||||||
client_id, client_secret = extract_client_credentials
|
client_id, client_secret = extract_client_credentials
|
||||||
@@ -205,11 +287,11 @@ class OidcController < ApplicationController
|
|||||||
# Get the authorization code
|
# Get the authorization code
|
||||||
code = params[:code]
|
code = params[:code]
|
||||||
redirect_uri = params[:redirect_uri]
|
redirect_uri = params[:redirect_uri]
|
||||||
|
code_verifier = params[:code_verifier]
|
||||||
|
|
||||||
auth_code = OidcAuthorizationCode.find_by(
|
auth_code = OidcAuthorizationCode.find_by(
|
||||||
application: application,
|
application: application,
|
||||||
code: code,
|
code: code
|
||||||
used: false
|
|
||||||
)
|
)
|
||||||
|
|
||||||
unless auth_code
|
unless auth_code
|
||||||
@@ -217,6 +299,31 @@ class OidcController < ApplicationController
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Use a transaction with pessimistic locking to prevent code reuse
|
||||||
|
begin
|
||||||
|
OidcAuthorizationCode.transaction do
|
||||||
|
# Lock the record to prevent concurrent access
|
||||||
|
auth_code.lock!
|
||||||
|
|
||||||
|
# Check if code has already been used (CRITICAL: check AFTER locking)
|
||||||
|
if auth_code.used?
|
||||||
|
# Per OAuth 2.0 spec, if an auth code is reused, revoke all tokens issued from it
|
||||||
|
Rails.logger.warn "OAuth Security: Authorization code reuse detected for code #{auth_code.id}"
|
||||||
|
|
||||||
|
# Revoke all access tokens issued from this authorization code
|
||||||
|
OidcAccessToken.where(
|
||||||
|
application: application,
|
||||||
|
user: auth_code.user,
|
||||||
|
created_at: auth_code.created_at..Time.current
|
||||||
|
).update_all(expires_at: Time.current)
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
error: "invalid_grant",
|
||||||
|
error_description: "Authorization code has already been used"
|
||||||
|
}, status: :bad_request
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
# Check if code is expired
|
# Check if code is expired
|
||||||
if auth_code.expires_at < Time.current
|
if auth_code.expires_at < Time.current
|
||||||
render json: { error: "invalid_grant", error_description: "Authorization code expired" }, status: :bad_request
|
render json: { error: "invalid_grant", error_description: "Authorization code expired" }, status: :bad_request
|
||||||
@@ -229,34 +336,162 @@ class OidcController < ApplicationController
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Mark code as used
|
# Validate PKCE if code challenge is present
|
||||||
|
pkce_result = validate_pkce(auth_code, code_verifier)
|
||||||
|
unless pkce_result[:valid]
|
||||||
|
render json: {
|
||||||
|
error: pkce_result[:error],
|
||||||
|
error_description: pkce_result[:error_description]
|
||||||
|
}, status: pkce_result[:status]
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Mark code as used BEFORE generating tokens (prevents reuse)
|
||||||
auth_code.update!(used: true)
|
auth_code.update!(used: true)
|
||||||
|
|
||||||
# Get the user
|
# Get the user
|
||||||
user = auth_code.user
|
user = auth_code.user
|
||||||
|
|
||||||
# Generate access token
|
# Generate access token record (opaque token with BCrypt hashing)
|
||||||
access_token = SecureRandom.urlsafe_base64(32)
|
access_token_record = OidcAccessToken.create!(
|
||||||
OidcAccessToken.create!(
|
|
||||||
application: application,
|
application: application,
|
||||||
user: user,
|
user: user,
|
||||||
token: access_token,
|
scope: auth_code.scope
|
||||||
scope: auth_code.scope,
|
|
||||||
expires_at: 1.hour.from_now
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Generate ID token
|
# Generate refresh token (opaque, with hashing)
|
||||||
id_token = OidcJwtService.generate_id_token(user, application, nonce: auth_code.nonce)
|
refresh_token_record = OidcRefreshToken.create!(
|
||||||
|
application: application,
|
||||||
|
user: user,
|
||||||
|
oidc_access_token: access_token_record,
|
||||||
|
scope: auth_code.scope
|
||||||
|
)
|
||||||
|
|
||||||
|
# Find user consent for this application
|
||||||
|
consent = OidcUserConsent.find_by(user: user, application: application)
|
||||||
|
|
||||||
|
unless consent
|
||||||
|
Rails.logger.error "OIDC Security: Token requested without consent record (user: #{user.id}, app: #{application.id})"
|
||||||
|
render json: { error: "invalid_grant", error_description: "Authorization consent not found" }, status: :bad_request
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Generate ID token (JWT) with pairwise SID
|
||||||
|
id_token = OidcJwtService.generate_id_token(user, application, consent: consent, nonce: auth_code.nonce)
|
||||||
|
|
||||||
# Return tokens
|
# Return tokens
|
||||||
render json: {
|
render json: {
|
||||||
access_token: access_token,
|
access_token: access_token_record.plaintext_token, # Opaque token
|
||||||
token_type: "Bearer",
|
token_type: "Bearer",
|
||||||
expires_in: 3600,
|
expires_in: application.access_token_ttl || 3600,
|
||||||
id_token: id_token,
|
id_token: id_token, # JWT
|
||||||
|
refresh_token: refresh_token_record.token, # Opaque token
|
||||||
scope: auth_code.scope
|
scope: auth_code.scope
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
render json: { error: "invalid_grant" }, status: :bad_request
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_refresh_token_grant
|
||||||
|
# Get client credentials from Authorization header or params
|
||||||
|
client_id, client_secret = extract_client_credentials
|
||||||
|
|
||||||
|
unless client_id && client_secret
|
||||||
|
render json: { error: "invalid_client" }, status: :unauthorized
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Find and validate the application
|
||||||
|
application = Application.find_by(client_id: client_id)
|
||||||
|
unless application && application.authenticate_client_secret(client_secret)
|
||||||
|
render json: { error: "invalid_client" }, status: :unauthorized
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Get the refresh token
|
||||||
|
refresh_token = params[:refresh_token]
|
||||||
|
unless refresh_token.present?
|
||||||
|
render json: { error: "invalid_request", error_description: "refresh_token is required" }, status: :bad_request
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Find the refresh token record
|
||||||
|
# Note: This is inefficient with BCrypt hashing, but necessary for security
|
||||||
|
# In production, consider adding a token prefix for faster lookup
|
||||||
|
refresh_token_record = OidcRefreshToken.where(application: application).find do |rt|
|
||||||
|
rt.token_matches?(refresh_token)
|
||||||
|
end
|
||||||
|
|
||||||
|
unless refresh_token_record
|
||||||
|
render json: { error: "invalid_grant", error_description: "Invalid refresh token" }, status: :bad_request
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if refresh token is expired
|
||||||
|
if refresh_token_record.expired?
|
||||||
|
render json: { error: "invalid_grant", error_description: "Refresh token expired" }, status: :bad_request
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if refresh token is revoked
|
||||||
|
if refresh_token_record.revoked?
|
||||||
|
# If a revoked refresh token is used, it's a security issue
|
||||||
|
# Revoke all tokens in the family (token rotation attack detection)
|
||||||
|
Rails.logger.warn "OAuth Security: Revoked refresh token reuse detected for token family #{refresh_token_record.token_family_id}"
|
||||||
|
refresh_token_record.revoke_family!
|
||||||
|
|
||||||
|
render json: { error: "invalid_grant", error_description: "Refresh token has been revoked" }, status: :bad_request
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Get the user
|
||||||
|
user = refresh_token_record.user
|
||||||
|
|
||||||
|
# Revoke the old refresh token (token rotation)
|
||||||
|
refresh_token_record.revoke!
|
||||||
|
|
||||||
|
# Generate new access token record (opaque token with BCrypt hashing)
|
||||||
|
new_access_token = OidcAccessToken.create!(
|
||||||
|
application: application,
|
||||||
|
user: user,
|
||||||
|
scope: refresh_token_record.scope
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate new refresh token (token rotation)
|
||||||
|
new_refresh_token = OidcRefreshToken.create!(
|
||||||
|
application: application,
|
||||||
|
user: user,
|
||||||
|
oidc_access_token: new_access_token,
|
||||||
|
scope: refresh_token_record.scope,
|
||||||
|
token_family_id: refresh_token_record.token_family_id # Keep same family for rotation tracking
|
||||||
|
)
|
||||||
|
|
||||||
|
# Find user consent for this application
|
||||||
|
consent = OidcUserConsent.find_by(user: user, application: application)
|
||||||
|
|
||||||
|
unless consent
|
||||||
|
Rails.logger.error "OIDC Security: Refresh token used without consent record (user: #{user.id}, app: #{application.id})"
|
||||||
|
render json: { error: "invalid_grant", error_description: "Authorization consent not found" }, status: :bad_request
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Generate new ID token (JWT with pairwise SID, no nonce for refresh grants)
|
||||||
|
id_token = OidcJwtService.generate_id_token(user, application, consent: consent)
|
||||||
|
|
||||||
|
# Return new tokens
|
||||||
|
render json: {
|
||||||
|
access_token: new_access_token.plaintext_token, # Opaque token
|
||||||
|
token_type: "Bearer",
|
||||||
|
expires_in: application.access_token_ttl || 3600,
|
||||||
|
id_token: id_token, # JWT
|
||||||
|
refresh_token: new_refresh_token.token, # Opaque token
|
||||||
|
scope: refresh_token_record.scope
|
||||||
|
}
|
||||||
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
render json: { error: "invalid_grant" }, status: :bad_request
|
||||||
|
end
|
||||||
|
|
||||||
# GET /oauth/userinfo
|
# GET /oauth/userinfo
|
||||||
def userinfo
|
def userinfo
|
||||||
@@ -267,27 +502,29 @@ class OidcController < ApplicationController
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
access_token = auth_header.sub("Bearer ", "")
|
token = auth_header.sub("Bearer ", "")
|
||||||
|
|
||||||
# Find the access token
|
# Find and validate access token (opaque token with BCrypt hashing)
|
||||||
token_record = OidcAccessToken.find_by(token: access_token)
|
access_token = OidcAccessToken.find_by_token(token)
|
||||||
unless token_record
|
unless access_token&.active?
|
||||||
head :unauthorized
|
head :unauthorized
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Check if token is expired
|
# Get the user (with fresh data from database)
|
||||||
if token_record.expires_at < Time.current
|
user = access_token.user
|
||||||
|
unless user
|
||||||
head :unauthorized
|
head :unauthorized
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Get the user
|
# Find user consent for this application to get pairwise SID
|
||||||
user = token_record.user
|
consent = OidcUserConsent.find_by(user: user, application: access_token.application)
|
||||||
|
subject = consent&.sid || user.id.to_s
|
||||||
|
|
||||||
# Return user claims
|
# Return user claims
|
||||||
claims = {
|
claims = {
|
||||||
sub: user.id.to_s,
|
sub: subject,
|
||||||
email: user.email_address,
|
email: user.email_address,
|
||||||
email_verified: true,
|
email_verified: true,
|
||||||
preferred_username: user.email_address,
|
preferred_username: user.email_address,
|
||||||
@@ -299,9 +536,6 @@ class OidcController < ApplicationController
|
|||||||
claims[:groups] = user.groups.pluck(:name)
|
claims[:groups] = user.groups.pluck(:name)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Add admin claim if user is admin
|
|
||||||
claims[:admin] = true if user.admin?
|
|
||||||
|
|
||||||
# Merge custom claims from groups
|
# Merge custom claims from groups
|
||||||
user.groups.each do |group|
|
user.groups.each do |group|
|
||||||
claims.merge!(group.parsed_custom_claims)
|
claims.merge!(group.parsed_custom_claims)
|
||||||
@@ -310,9 +544,80 @@ class OidcController < ApplicationController
|
|||||||
# Merge custom claims from user (overrides group claims)
|
# Merge custom claims from user (overrides group claims)
|
||||||
claims.merge!(user.parsed_custom_claims)
|
claims.merge!(user.parsed_custom_claims)
|
||||||
|
|
||||||
|
# Merge app-specific custom claims (highest priority)
|
||||||
|
application = access_token.application
|
||||||
|
claims.merge!(application.custom_claims_for_user(user))
|
||||||
|
|
||||||
render json: claims
|
render json: claims
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# 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
|
||||||
@@ -324,16 +629,29 @@ class OidcController < ApplicationController
|
|||||||
|
|
||||||
# If user is authenticated, log them out
|
# If user is authenticated, log them out
|
||||||
if authenticated?
|
if authenticated?
|
||||||
|
user = Current.session.user
|
||||||
|
|
||||||
|
# Send backchannel logout notifications to all connected applications
|
||||||
|
send_backchannel_logout_notifications(user)
|
||||||
|
|
||||||
# Invalidate the current session
|
# Invalidate the current session
|
||||||
Current.session&.destroy
|
Current.session&.destroy
|
||||||
reset_session
|
reset_session
|
||||||
end
|
end
|
||||||
|
|
||||||
# If post_logout_redirect_uri is provided, redirect there
|
# If post_logout_redirect_uri is provided, validate and redirect
|
||||||
if post_logout_redirect_uri.present?
|
if post_logout_redirect_uri.present?
|
||||||
redirect_uri = post_logout_redirect_uri
|
validated_uri = validate_logout_redirect_uri(post_logout_redirect_uri)
|
||||||
|
|
||||||
|
if validated_uri
|
||||||
|
redirect_uri = validated_uri
|
||||||
redirect_uri += "?state=#{state}" if state.present?
|
redirect_uri += "?state=#{state}" if state.present?
|
||||||
redirect_to redirect_uri, allow_other_host: true
|
redirect_to redirect_uri, allow_other_host: true
|
||||||
|
else
|
||||||
|
# Invalid redirect URI - log warning and go to default
|
||||||
|
Rails.logger.warn "OIDC Logout: Invalid post_logout_redirect_uri attempted: #{post_logout_redirect_uri}"
|
||||||
|
redirect_to root_path
|
||||||
|
end
|
||||||
else
|
else
|
||||||
# Default redirect to home page
|
# Default redirect to home page
|
||||||
redirect_to root_path
|
redirect_to root_path
|
||||||
@@ -342,6 +660,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 ")
|
||||||
@@ -353,4 +723,76 @@ class OidcController < ApplicationController
|
|||||||
[params[:client_id], params[:client_secret]]
|
[params[:client_id], params[:client_secret]]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def validate_logout_redirect_uri(uri)
|
||||||
|
return nil unless uri.present?
|
||||||
|
|
||||||
|
begin
|
||||||
|
parsed_uri = URI.parse(uri)
|
||||||
|
|
||||||
|
# Only allow HTTP/HTTPS schemes (prevent javascript:, data:, etc.)
|
||||||
|
return nil unless parsed_uri.is_a?(URI::HTTP) || parsed_uri.is_a?(URI::HTTPS)
|
||||||
|
|
||||||
|
# Only allow HTTPS in production
|
||||||
|
return nil if Rails.env.production? && parsed_uri.scheme != 'https'
|
||||||
|
|
||||||
|
# Check if URI matches any registered OIDC application's redirect URIs
|
||||||
|
# According to OIDC spec, post_logout_redirect_uri should be pre-registered
|
||||||
|
Application.oidc.active.find_each do |app|
|
||||||
|
# Check if this URI matches any of the app's registered redirect URIs
|
||||||
|
if app.parsed_redirect_uris.any? { |registered_uri| logout_uri_matches?(uri, registered_uri) }
|
||||||
|
return uri
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# No matching application found
|
||||||
|
nil
|
||||||
|
rescue URI::InvalidURIError
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if logout URI matches a registered redirect URI
|
||||||
|
# More lenient than exact match - allows same host/path with different query params
|
||||||
|
def logout_uri_matches?(provided, registered)
|
||||||
|
# Exact match is always valid
|
||||||
|
return true if provided == registered
|
||||||
|
|
||||||
|
# Parse both URIs to compare components
|
||||||
|
begin
|
||||||
|
provided_parsed = URI.parse(provided)
|
||||||
|
registered_parsed = URI.parse(registered)
|
||||||
|
|
||||||
|
# Match if scheme, host, port, and path are the same
|
||||||
|
# (allows different query params which is common for logout redirects)
|
||||||
|
provided_parsed.scheme == registered_parsed.scheme &&
|
||||||
|
provided_parsed.host == registered_parsed.host &&
|
||||||
|
provided_parsed.port == registered_parsed.port &&
|
||||||
|
provided_parsed.path == registered_parsed.path
|
||||||
|
rescue URI::InvalidURIError
|
||||||
|
false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def send_backchannel_logout_notifications(user)
|
||||||
|
# Find all active OIDC consents for this user
|
||||||
|
consents = OidcUserConsent.where(user: user).includes(:application)
|
||||||
|
|
||||||
|
consents.each do |consent|
|
||||||
|
# Skip if application doesn't support backchannel logout
|
||||||
|
next unless consent.application.supports_backchannel_logout?
|
||||||
|
|
||||||
|
# Enqueue background job to send logout notification
|
||||||
|
BackchannelLogoutJob.perform_later(
|
||||||
|
user_id: user.id,
|
||||||
|
application_id: consent.application.id,
|
||||||
|
consent_sid: consent.sid
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
Rails.logger.info "OidcController: Enqueued #{consents.count} backchannel logout notifications for user #{user.id}"
|
||||||
|
rescue => e
|
||||||
|
# Log error but don't block logout
|
||||||
|
Rails.logger.error "OidcController: Failed to enqueue backchannel logout: #{e.class} - #{e.message}"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class PasswordsController < ApplicationController
|
|||||||
PasswordsMailer.reset(user).deliver_later
|
PasswordsMailer.reset(user).deliver_later
|
||||||
end
|
end
|
||||||
|
|
||||||
redirect_to new_session_path, notice: "Password reset instructions sent (if user with that email address exists)."
|
redirect_to signin_path, notice: "Password reset instructions sent (if user with that email address exists)."
|
||||||
end
|
end
|
||||||
|
|
||||||
def edit
|
def edit
|
||||||
@@ -20,7 +20,7 @@ class PasswordsController < ApplicationController
|
|||||||
def update
|
def update
|
||||||
if @user.update(params.permit(:password, :password_confirmation))
|
if @user.update(params.permit(:password, :password_confirmation))
|
||||||
@user.sessions.destroy_all
|
@user.sessions.destroy_all
|
||||||
redirect_to new_session_path, notice: "Password has been reset."
|
redirect_to signin_path, notice: "Password has been reset."
|
||||||
else
|
else
|
||||||
redirect_to edit_password_path(params[:token]), alert: "Passwords did not match."
|
redirect_to edit_password_path(params[:token]), alert: "Passwords did not match."
|
||||||
end
|
end
|
||||||
@@ -29,6 +29,7 @@ class PasswordsController < ApplicationController
|
|||||||
private
|
private
|
||||||
def set_user_by_token
|
def set_user_by_token
|
||||||
@user = User.find_by_token_for(:password_reset, params[:token])
|
@user = User.find_by_token_for(:password_reset, params[:token])
|
||||||
|
redirect_to new_password_path, alert: "Password reset link is invalid or has expired." if @user.nil?
|
||||||
rescue ActiveSupport::MessageVerifier::InvalidSignature
|
rescue ActiveSupport::MessageVerifier::InvalidSignature
|
||||||
redirect_to new_password_path, alert: "Password reset link is invalid or has expired."
|
redirect_to new_password_path, alert: "Password reset link is invalid or has expired."
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -6,7 +6,18 @@ class SessionsController < ApplicationController
|
|||||||
|
|
||||||
def new
|
def new
|
||||||
# Redirect to signup if this is first run
|
# Redirect to signup if this is first run
|
||||||
redirect_to signup_path if User.count.zero?
|
if User.count.zero?
|
||||||
|
respond_to do |format|
|
||||||
|
format.html { redirect_to signup_path }
|
||||||
|
format.json { render json: { error: "No users exist. Please complete initial setup." }, status: :service_unavailable }
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
respond_to do |format|
|
||||||
|
format.html # render HTML login page
|
||||||
|
format.json { render json: { error: "Authentication required" }, status: :unauthorized }
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
@@ -33,8 +44,22 @@ class SessionsController < ApplicationController
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Check if TOTP is required
|
# Check if TOTP is required or enabled
|
||||||
if user.totp_enabled?
|
if user.totp_required? || user.totp_enabled?
|
||||||
|
# If TOTP is required but not yet set up, redirect to setup
|
||||||
|
if user.totp_required? && !user.totp_enabled?
|
||||||
|
# Store user ID in session for TOTP setup
|
||||||
|
session[:pending_totp_setup_user_id] = user.id
|
||||||
|
# Preserve the redirect URL through TOTP setup
|
||||||
|
if params[:rd].present?
|
||||||
|
validated_url = validate_redirect_url(params[:rd])
|
||||||
|
session[:totp_redirect_url] = validated_url if validated_url
|
||||||
|
end
|
||||||
|
redirect_to new_totp_path, alert: "Your administrator requires two-factor authentication. Please set it up now to continue."
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# TOTP is enabled, proceed to verification
|
||||||
# Store user ID in session temporarily for TOTP verification
|
# Store user ID in session temporarily for TOTP verification
|
||||||
session[:pending_totp_user_id] = user.id
|
session[:pending_totp_user_id] = user.id
|
||||||
# Preserve the redirect URL through TOTP verification (after validation)
|
# Preserve the redirect URL through TOTP verification (after validation)
|
||||||
@@ -109,6 +134,12 @@ class SessionsController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
|
# Send backchannel logout notifications before terminating session
|
||||||
|
if authenticated?
|
||||||
|
user = Current.session.user
|
||||||
|
send_backchannel_logout_notifications(user)
|
||||||
|
end
|
||||||
|
|
||||||
terminate_session
|
terminate_session
|
||||||
redirect_to signin_path, status: :see_other, notice: "Signed out successfully."
|
redirect_to signin_path, status: :see_other, notice: "Signed out successfully."
|
||||||
end
|
end
|
||||||
@@ -275,15 +306,37 @@ class SessionsController < ApplicationController
|
|||||||
redirect_domain = uri.host.downcase
|
redirect_domain = uri.host.downcase
|
||||||
return nil unless redirect_domain.present?
|
return nil unless redirect_domain.present?
|
||||||
|
|
||||||
# Check against our ForwardAuthRules
|
# Check against our forward auth applications
|
||||||
matching_rule = ForwardAuthRule.active.find do |rule|
|
matching_app = Application.forward_auth.active.find do |app|
|
||||||
rule.matches_domain?(redirect_domain)
|
app.matches_domain?(redirect_domain)
|
||||||
end
|
end
|
||||||
|
|
||||||
matching_rule ? url : nil
|
matching_app ? url : nil
|
||||||
|
|
||||||
rescue URI::InvalidURIError
|
rescue URI::InvalidURIError
|
||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def send_backchannel_logout_notifications(user)
|
||||||
|
# Find all active OIDC consents for this user
|
||||||
|
consents = OidcUserConsent.where(user: user).includes(:application)
|
||||||
|
|
||||||
|
consents.each do |consent|
|
||||||
|
# Skip if application doesn't support backchannel logout
|
||||||
|
next unless consent.application.supports_backchannel_logout?
|
||||||
|
|
||||||
|
# Enqueue background job to send logout notification
|
||||||
|
BackchannelLogoutJob.perform_later(
|
||||||
|
user_id: user.id,
|
||||||
|
application_id: consent.application.id,
|
||||||
|
consent_sid: consent.sid
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
Rails.logger.info "SessionsController: Enqueued #{consents.count} backchannel logout notifications for user #{user.id}"
|
||||||
|
rescue => e
|
||||||
|
# Log error but don't block logout
|
||||||
|
Rails.logger.error "SessionsController: Failed to enqueue backchannel logout: #{e.class} - #{e.message}"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ class TotpController < ApplicationController
|
|||||||
|
|
||||||
# GET /totp/new - Show QR code to set up TOTP
|
# GET /totp/new - Show QR code to set up TOTP
|
||||||
def new
|
def new
|
||||||
|
# Check if user is being forced to set up TOTP by admin
|
||||||
|
@totp_setup_required = session[:pending_totp_setup_user_id].present?
|
||||||
|
|
||||||
# Generate TOTP secret but don't save yet
|
# Generate TOTP secret but don't save yet
|
||||||
@totp_secret = ROTP::Base32.random
|
@totp_secret = ROTP::Base32.random
|
||||||
@provisioning_uri = ROTP::TOTP.new(@totp_secret, issuer: "Clinch").provisioning_uri(@user.email_address)
|
@provisioning_uri = ROTP::TOTP.new(@totp_secret, issuer: "Clinch").provisioning_uri(@user.email_address)
|
||||||
@@ -30,8 +33,16 @@ class TotpController < ApplicationController
|
|||||||
# Store plain codes temporarily in session for display after redirect
|
# Store plain codes temporarily in session for display after redirect
|
||||||
session[:temp_backup_codes] = plain_codes
|
session[:temp_backup_codes] = plain_codes
|
||||||
|
|
||||||
# Redirect to backup codes page with success message
|
# Check if this was a required setup from login
|
||||||
|
if session[:pending_totp_setup_user_id].present?
|
||||||
|
session.delete(:pending_totp_setup_user_id)
|
||||||
|
# Mark that user should be auto-signed in after viewing backup codes
|
||||||
|
session[:auto_signin_after_forced_totp] = true
|
||||||
|
redirect_to backup_codes_totp_path, notice: "Two-factor authentication has been enabled successfully! Save these backup codes, then you'll be signed in."
|
||||||
|
else
|
||||||
|
# Regular setup from profile
|
||||||
redirect_to backup_codes_totp_path, notice: "Two-factor authentication has been enabled successfully! Save these backup codes now."
|
redirect_to backup_codes_totp_path, notice: "Two-factor authentication has been enabled successfully! Save these backup codes now."
|
||||||
|
end
|
||||||
else
|
else
|
||||||
redirect_to new_totp_path, alert: "Invalid verification code. Please try again."
|
redirect_to new_totp_path, alert: "Invalid verification code. Please try again."
|
||||||
end
|
end
|
||||||
@@ -43,6 +54,12 @@ class TotpController < ApplicationController
|
|||||||
if session[:temp_backup_codes].present?
|
if session[:temp_backup_codes].present?
|
||||||
@backup_codes = session[:temp_backup_codes]
|
@backup_codes = session[:temp_backup_codes]
|
||||||
session.delete(:temp_backup_codes) # Clear after use
|
session.delete(:temp_backup_codes) # Clear after use
|
||||||
|
|
||||||
|
# Check if this was a forced TOTP setup during login
|
||||||
|
@auto_signin_pending = session[:auto_signin_after_forced_totp].present?
|
||||||
|
if @auto_signin_pending
|
||||||
|
session.delete(:auto_signin_after_forced_totp)
|
||||||
|
end
|
||||||
else
|
else
|
||||||
# This will be shown after password verification for existing users
|
# This will be shown after password verification for existing users
|
||||||
# Since we can't display BCrypt hashes, redirect to regenerate
|
# Since we can't display BCrypt hashes, redirect to regenerate
|
||||||
@@ -81,6 +98,18 @@ class TotpController < ApplicationController
|
|||||||
redirect_to backup_codes_totp_path, notice: "New backup codes have been generated. Save them now!"
|
redirect_to backup_codes_totp_path, notice: "New backup codes have been generated. Save them now!"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# POST /totp/complete_setup - Complete forced TOTP setup and sign in
|
||||||
|
def complete_setup
|
||||||
|
# Sign in the user after they've saved their backup codes
|
||||||
|
# This is only used when admin requires TOTP and user just set it up during login
|
||||||
|
if session[:totp_redirect_url].present?
|
||||||
|
session[:return_to_after_authenticating] = session.delete(:totp_redirect_url)
|
||||||
|
end
|
||||||
|
|
||||||
|
start_new_session_for @user
|
||||||
|
redirect_to after_authentication_url, notice: "Two-factor authentication enabled. Signed in successfully.", allow_other_host: true
|
||||||
|
end
|
||||||
|
|
||||||
# DELETE /totp - Disable TOTP (requires password)
|
# DELETE /totp - Disable TOTP (requires password)
|
||||||
def destroy
|
def destroy
|
||||||
unless @user.authenticate(params[:password])
|
unless @user.authenticate(params[:password])
|
||||||
@@ -88,6 +117,12 @@ class TotpController < ApplicationController
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Prevent disabling if admin requires TOTP
|
||||||
|
if @user.totp_required?
|
||||||
|
redirect_to profile_path, alert: "Two-factor authentication is required by your administrator and cannot be disabled."
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
@user.disable_totp!
|
@user.disable_totp!
|
||||||
redirect_to profile_path, notice: "Two-factor authentication has been disabled."
|
redirect_to profile_path, notice: "Two-factor authentication has been disabled."
|
||||||
end
|
end
|
||||||
@@ -99,7 +134,8 @@ class TotpController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def redirect_if_totp_enabled
|
def redirect_if_totp_enabled
|
||||||
if @user.totp_enabled?
|
# Allow setup if admin requires it, even if already enabled (for regeneration)
|
||||||
|
if @user.totp_enabled? && !session[:pending_totp_setup_user_id].present?
|
||||||
redirect_to profile_path, alert: "Two-factor authentication is already enabled."
|
redirect_to profile_path, alert: "Two-factor authentication is already enabled."
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
69
app/helpers/claims_helper.rb
Normal file
69
app/helpers/claims_helper.rb
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
module ClaimsHelper
|
||||||
|
include ClaimsMerger
|
||||||
|
|
||||||
|
# Preview final merged claims for a user accessing an application
|
||||||
|
def preview_user_claims(user, application)
|
||||||
|
claims = {
|
||||||
|
# Standard OIDC claims
|
||||||
|
email: user.email_address,
|
||||||
|
email_verified: true,
|
||||||
|
preferred_username: user.username.presence || user.email_address,
|
||||||
|
name: user.name.presence || user.email_address
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add groups
|
||||||
|
if user.groups.any?
|
||||||
|
claims[:groups] = user.groups.pluck(:name)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Merge group custom claims (arrays are combined, not overwritten)
|
||||||
|
user.groups.each do |group|
|
||||||
|
claims = deep_merge_claims(claims, group.parsed_custom_claims)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Merge user custom claims (arrays are combined, other values override)
|
||||||
|
claims = deep_merge_claims(claims, user.parsed_custom_claims)
|
||||||
|
|
||||||
|
# Merge app-specific claims (arrays are combined)
|
||||||
|
claims = deep_merge_claims(claims, application.custom_claims_for_user(user))
|
||||||
|
|
||||||
|
claims
|
||||||
|
end
|
||||||
|
|
||||||
|
# Get claim sources breakdown for display
|
||||||
|
def claim_sources(user, application)
|
||||||
|
sources = []
|
||||||
|
|
||||||
|
# Group claims
|
||||||
|
user.groups.each do |group|
|
||||||
|
if group.parsed_custom_claims.any?
|
||||||
|
sources << {
|
||||||
|
type: :group,
|
||||||
|
name: group.name,
|
||||||
|
claims: group.parsed_custom_claims
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# User claims
|
||||||
|
if user.parsed_custom_claims.any?
|
||||||
|
sources << {
|
||||||
|
type: :user,
|
||||||
|
name: "User Override",
|
||||||
|
claims: user.parsed_custom_claims
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
# App-specific claims
|
||||||
|
app_claims = application.custom_claims_for_user(user)
|
||||||
|
if app_claims.any?
|
||||||
|
sources << {
|
||||||
|
type: :application,
|
||||||
|
name: "App-Specific (#{application.name})",
|
||||||
|
claims: app_claims
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
sources
|
||||||
|
end
|
||||||
|
end
|
||||||
96
app/javascript/controllers/file_drop_controller.js
Normal file
96
app/javascript/controllers/file_drop_controller.js
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = ["input", "dropzone", "preview", "previewImage", "filename", "filesize"]
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
// Prevent default drag behaviors on the whole document
|
||||||
|
["dragenter", "dragover", "dragleave", "drop"].forEach(eventName => {
|
||||||
|
document.body.addEventListener(eventName, this.preventDefaults, false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
["dragenter", "dragover", "dragleave", "drop"].forEach(eventName => {
|
||||||
|
document.body.removeEventListener(eventName, this.preventDefaults, false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
preventDefaults(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
}
|
||||||
|
|
||||||
|
dragover(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
this.dropzoneTarget.classList.add("border-blue-500", "bg-blue-50")
|
||||||
|
}
|
||||||
|
|
||||||
|
dragleave(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
this.dropzoneTarget.classList.remove("border-blue-500", "bg-blue-50")
|
||||||
|
}
|
||||||
|
|
||||||
|
drop(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
this.dropzoneTarget.classList.remove("border-blue-500", "bg-blue-50")
|
||||||
|
|
||||||
|
const files = e.dataTransfer.files
|
||||||
|
if (files.length > 0) {
|
||||||
|
// Set the file to the input element
|
||||||
|
this.inputTarget.files = files
|
||||||
|
this.handleFiles()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleFiles() {
|
||||||
|
const file = this.inputTarget.files[0]
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
// Validate file type
|
||||||
|
const validTypes = ["image/png", "image/jpg", "image/jpeg", "image/gif", "image/svg+xml"]
|
||||||
|
if (!validTypes.includes(file.type)) {
|
||||||
|
alert("Please upload a PNG, JPG, GIF, or SVG image")
|
||||||
|
this.clear()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate file size (2MB)
|
||||||
|
if (file.size > 2 * 1024 * 1024) {
|
||||||
|
alert("File size must be less than 2MB")
|
||||||
|
this.clear()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show preview
|
||||||
|
this.filenameTarget.textContent = file.name
|
||||||
|
this.filesizeTarget.textContent = this.formatFileSize(file.size)
|
||||||
|
|
||||||
|
// Create preview image
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = (e) => {
|
||||||
|
this.previewImageTarget.src = e.target.result
|
||||||
|
this.previewTarget.classList.remove("hidden")
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(e) {
|
||||||
|
if (e) {
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
this.inputTarget.value = ""
|
||||||
|
this.previewTarget.classList.add("hidden")
|
||||||
|
}
|
||||||
|
|
||||||
|
formatFileSize(bytes) {
|
||||||
|
if (bytes === 0) return "0 Bytes"
|
||||||
|
const k = 1024
|
||||||
|
const sizes = ["Bytes", "KB", "MB"]
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||||
|
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + " " + sizes[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
85
app/javascript/controllers/flash_controller.js
Normal file
85
app/javascript/controllers/flash_controller.js
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages flash message display, auto-dismissal, and user interactions
|
||||||
|
* Supports different flash types with appropriate styling and behavior
|
||||||
|
*/
|
||||||
|
export default class extends Controller {
|
||||||
|
static values = {
|
||||||
|
autoDismiss: String, // "false" or delay in milliseconds
|
||||||
|
type: String
|
||||||
|
}
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
// Auto-dismiss if enabled
|
||||||
|
if (this.autoDismissValue && this.autoDismissValue !== "false") {
|
||||||
|
this.scheduleAutoDismiss()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Smooth entrance animation
|
||||||
|
this.element.classList.add('transition-all', 'duration-300', 'ease-out')
|
||||||
|
this.element.style.opacity = '0'
|
||||||
|
this.element.style.transform = 'translateY(-10px)'
|
||||||
|
|
||||||
|
// Animate in
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
this.element.style.opacity = '1'
|
||||||
|
this.element.style.transform = 'translateY(0)'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dismisses the flash message with smooth animation
|
||||||
|
*/
|
||||||
|
dismiss() {
|
||||||
|
// Add dismiss animation
|
||||||
|
this.element.classList.add('transition-all', 'duration-300', 'ease-in')
|
||||||
|
this.element.style.opacity = '0'
|
||||||
|
this.element.style.transform = 'translateY(-10px)'
|
||||||
|
|
||||||
|
// Remove from DOM after animation
|
||||||
|
setTimeout(() => {
|
||||||
|
this.element.remove()
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedules auto-dismissal based on the configured delay
|
||||||
|
*/
|
||||||
|
scheduleAutoDismiss() {
|
||||||
|
const delay = parseInt(this.autoDismissValue)
|
||||||
|
if (delay > 0) {
|
||||||
|
setTimeout(() => {
|
||||||
|
this.dismiss()
|
||||||
|
}, delay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pause auto-dismissal on hover (for user reading)
|
||||||
|
*/
|
||||||
|
mouseEnter() {
|
||||||
|
if (this.autoDismissTimer) {
|
||||||
|
clearTimeout(this.autoDismissTimer)
|
||||||
|
this.autoDismissTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resume auto-dismissal when hover ends
|
||||||
|
*/
|
||||||
|
mouseLeave() {
|
||||||
|
if (this.autoDismissValue && this.autoDismissValue !== "false") {
|
||||||
|
this.scheduleAutoDismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle keyboard interactions
|
||||||
|
*/
|
||||||
|
keydown(event) {
|
||||||
|
if (event.key === 'Escape' || event.key === 'Enter') {
|
||||||
|
this.dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
89
app/javascript/controllers/form_errors_controller.js
Normal file
89
app/javascript/controllers/form_errors_controller.js
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages form error display and dismissal
|
||||||
|
* Provides consistent error handling across all forms
|
||||||
|
*/
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = ["container"]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dismisses the error container with a smooth fade-out animation
|
||||||
|
*/
|
||||||
|
dismiss() {
|
||||||
|
if (!this.hasContainerTarget) return
|
||||||
|
|
||||||
|
// Add transition classes
|
||||||
|
this.containerTarget.classList.add('transition-all', 'duration-300', 'opacity-0', 'transform', 'scale-95')
|
||||||
|
|
||||||
|
// Remove from DOM after animation completes
|
||||||
|
setTimeout(() => {
|
||||||
|
this.containerTarget.remove()
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows server-side validation errors after form submission
|
||||||
|
* Auto-focuses the first error field for better accessibility
|
||||||
|
*/
|
||||||
|
connect() {
|
||||||
|
// Auto-focus first error field if errors exist
|
||||||
|
this.focusFirstErrorField()
|
||||||
|
|
||||||
|
// Scroll to errors if needed
|
||||||
|
this.scrollToErrors()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Focuses the first field with validation errors
|
||||||
|
*/
|
||||||
|
focusFirstErrorField() {
|
||||||
|
if (!this.hasContainerTarget) return
|
||||||
|
|
||||||
|
// Find first form field with errors (look for error classes or aria-invalid)
|
||||||
|
const form = this.element.closest('form')
|
||||||
|
if (!form) return
|
||||||
|
|
||||||
|
const errorField = form.querySelector('[aria-invalid="true"], .border-red-500, .ring-red-500')
|
||||||
|
if (errorField) {
|
||||||
|
setTimeout(() => {
|
||||||
|
errorField.focus()
|
||||||
|
errorField.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scrolls error container into view if it's not visible
|
||||||
|
*/
|
||||||
|
scrollToErrors() {
|
||||||
|
if (!this.hasContainerTarget) return
|
||||||
|
|
||||||
|
const rect = this.containerTarget.getBoundingClientRect()
|
||||||
|
const isInViewport = rect.top >= 0 && rect.left >= 0 &&
|
||||||
|
rect.bottom <= window.innerHeight &&
|
||||||
|
rect.right <= window.innerWidth
|
||||||
|
|
||||||
|
if (!isInViewport) {
|
||||||
|
setTimeout(() => {
|
||||||
|
this.containerTarget.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'start',
|
||||||
|
inline: 'nearest'
|
||||||
|
})
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-dismisses success messages after a delay
|
||||||
|
* Can be called from other controllers
|
||||||
|
*/
|
||||||
|
autoDismiss(delay = 5000) {
|
||||||
|
if (!this.hasContainerTarget) return
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
this.dismiss()
|
||||||
|
}, delay)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
52
app/jobs/backchannel_logout_job.rb
Normal file
52
app/jobs/backchannel_logout_job.rb
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
class BackchannelLogoutJob < ApplicationJob
|
||||||
|
queue_as :default
|
||||||
|
|
||||||
|
# Retry with exponential backoff: 1s, 5s, 25s
|
||||||
|
retry_on StandardError, wait: :exponentially_longer, attempts: 3
|
||||||
|
|
||||||
|
def perform(user_id:, application_id:, consent_sid:)
|
||||||
|
# Find the records
|
||||||
|
user = User.find_by(id: user_id)
|
||||||
|
application = Application.find_by(id: application_id)
|
||||||
|
consent = OidcUserConsent.find_by(sid: consent_sid)
|
||||||
|
|
||||||
|
# Validate we have all required data
|
||||||
|
unless user && application && consent
|
||||||
|
Rails.logger.warn "BackchannelLogout: Missing data - user: #{user.present?}, app: #{application.present?}, consent: #{consent.present?}"
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Skip if application doesn't support backchannel logout
|
||||||
|
unless application.supports_backchannel_logout?
|
||||||
|
Rails.logger.debug "BackchannelLogout: Application #{application.name} doesn't support backchannel logout"
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Generate the logout token
|
||||||
|
logout_token = OidcJwtService.generate_logout_token(user, application, consent)
|
||||||
|
|
||||||
|
# Send HTTP POST to the application's backchannel logout URI
|
||||||
|
uri = URI.parse(application.backchannel_logout_uri)
|
||||||
|
|
||||||
|
begin
|
||||||
|
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https', open_timeout: 5, read_timeout: 5) do |http|
|
||||||
|
request = Net::HTTP::Post.new(uri.path.presence || '/')
|
||||||
|
request['Content-Type'] = 'application/x-www-form-urlencoded'
|
||||||
|
request.set_form_data({ logout_token: logout_token })
|
||||||
|
http.request(request)
|
||||||
|
end
|
||||||
|
|
||||||
|
if response.code.to_i == 200
|
||||||
|
Rails.logger.info "BackchannelLogout: Successfully sent logout notification to #{application.name} (#{application.backchannel_logout_uri})"
|
||||||
|
else
|
||||||
|
Rails.logger.warn "BackchannelLogout: Application #{application.name} returned HTTP #{response.code} from #{application.backchannel_logout_uri}"
|
||||||
|
end
|
||||||
|
rescue Net::OpenTimeout, Net::ReadTimeout => e
|
||||||
|
Rails.logger.warn "BackchannelLogout: Timeout sending logout to #{application.name} (#{application.backchannel_logout_uri}): #{e.message}"
|
||||||
|
raise # Retry on timeout
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error "BackchannelLogout: Failed to send logout to #{application.name} (#{application.backchannel_logout_uri}): #{e.class} - #{e.message}"
|
||||||
|
raise # Retry on error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
29
app/jobs/oidc_token_cleanup_job.rb
Normal file
29
app/jobs/oidc_token_cleanup_job.rb
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
class OidcTokenCleanupJob < ApplicationJob
|
||||||
|
queue_as :default
|
||||||
|
|
||||||
|
def perform
|
||||||
|
# Delete expired access tokens (keep revoked ones for audit trail)
|
||||||
|
expired_access_tokens = OidcAccessToken.where("expires_at < ?", 7.days.ago)
|
||||||
|
deleted_count = expired_access_tokens.delete_all
|
||||||
|
Rails.logger.info "OIDC Token Cleanup: Deleted #{deleted_count} expired access tokens"
|
||||||
|
|
||||||
|
# Delete expired refresh tokens (keep revoked ones for audit trail)
|
||||||
|
expired_refresh_tokens = OidcRefreshToken.where("expires_at < ?", 7.days.ago)
|
||||||
|
deleted_count = expired_refresh_tokens.delete_all
|
||||||
|
Rails.logger.info "OIDC Token Cleanup: Deleted #{deleted_count} expired refresh tokens"
|
||||||
|
|
||||||
|
# Delete old revoked tokens (after 30 days for audit trail)
|
||||||
|
old_revoked_access_tokens = OidcAccessToken.where("revoked_at < ?", 30.days.ago)
|
||||||
|
deleted_count = old_revoked_access_tokens.delete_all
|
||||||
|
Rails.logger.info "OIDC Token Cleanup: Deleted #{deleted_count} old revoked access tokens"
|
||||||
|
|
||||||
|
old_revoked_refresh_tokens = OidcRefreshToken.where("revoked_at < ?", 30.days.ago)
|
||||||
|
deleted_count = old_revoked_refresh_tokens.delete_all
|
||||||
|
Rails.logger.info "OIDC Token Cleanup: Deleted #{deleted_count} old revoked refresh tokens"
|
||||||
|
|
||||||
|
# Delete old used authorization codes (after 7 days)
|
||||||
|
old_auth_codes = OidcAuthorizationCode.where("created_at < ?", 7.days.ago)
|
||||||
|
deleted_count = old_auth_codes.delete_all
|
||||||
|
Rails.logger.info "OIDC Token Cleanup: Deleted #{deleted_count} old authorization codes"
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
class ApplicationMailer < ActionMailer::Base
|
class ApplicationMailer < ActionMailer::Base
|
||||||
default from: ENV.fetch('CLINCH_EMAIL_FROM', 'clinch@example.com')
|
default from: ENV.fetch('CLINCH_FROM_EMAIL', 'clinch@example.com')
|
||||||
layout "mailer"
|
layout "mailer"
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
class Application < ApplicationRecord
|
class Application < ApplicationRecord
|
||||||
has_secure_password :client_secret, validations: false
|
has_secure_password :client_secret, validations: false
|
||||||
|
|
||||||
|
has_one_attached :icon
|
||||||
|
|
||||||
|
# Fix SVG content type after attachment
|
||||||
|
after_save :fix_icon_content_type, if: -> { icon.attached? && saved_change_to_attribute?(:id) == false }
|
||||||
|
|
||||||
has_many :application_groups, dependent: :destroy
|
has_many :application_groups, dependent: :destroy
|
||||||
has_many :allowed_groups, through: :application_groups, source: :group
|
has_many :allowed_groups, through: :application_groups, source: :group
|
||||||
|
has_many :application_user_claims, dependent: :destroy
|
||||||
has_many :oidc_authorization_codes, dependent: :destroy
|
has_many :oidc_authorization_codes, dependent: :destroy
|
||||||
has_many :oidc_access_tokens, dependent: :destroy
|
has_many :oidc_access_tokens, dependent: :destroy
|
||||||
|
has_many :oidc_refresh_tokens, dependent: :destroy
|
||||||
has_many :oidc_user_consents, dependent: :destroy
|
has_many :oidc_user_consents, dependent: :destroy
|
||||||
|
|
||||||
validates :name, presence: true
|
validates :name, presence: true
|
||||||
@@ -16,9 +23,30 @@ class Application < ApplicationRecord
|
|||||||
validates :client_secret, presence: true, on: :create, 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" }
|
||||||
|
validates :backchannel_logout_uri, format: {
|
||||||
|
with: URI::regexp(%w[http https]),
|
||||||
|
allow_nil: true,
|
||||||
|
message: "must be a valid HTTP or HTTPS URL"
|
||||||
|
}
|
||||||
|
validate :backchannel_logout_uri_must_be_https_in_production, if: -> { backchannel_logout_uri.present? }
|
||||||
|
|
||||||
|
# Icon validation using ActiveStorage validators
|
||||||
|
validate :icon_validation, if: -> { icon.attached? }
|
||||||
|
|
||||||
|
# Token TTL validations (for OIDC apps)
|
||||||
|
validates :access_token_ttl, numericality: { greater_than_or_equal_to: 300, less_than_or_equal_to: 86400 }, if: :oidc? # 5 min - 24 hours
|
||||||
|
validates :refresh_token_ttl, numericality: { greater_than_or_equal_to: 86400, less_than_or_equal_to: 7776000 }, if: :oidc? # 1 day - 90 days
|
||||||
|
validates :id_token_ttl, numericality: { greater_than_or_equal_to: 300, less_than_or_equal_to: 86400 }, if: :oidc? # 5 min - 24 hours
|
||||||
|
|
||||||
normalizes :slug, with: ->(slug) { slug.strip.downcase }
|
normalizes :slug, with: ->(slug) { slug.strip.downcase }
|
||||||
normalizes :domain_pattern, with: ->(pattern) { pattern&.strip&.downcase }
|
normalizes :domain_pattern, with: ->(pattern) {
|
||||||
|
normalized = pattern&.strip&.downcase
|
||||||
|
normalized.blank? ? nil : normalized
|
||||||
|
}
|
||||||
|
normalizes :backchannel_logout_uri, with: ->(uri) {
|
||||||
|
normalized = uri&.strip
|
||||||
|
normalized.blank? ? nil : normalized
|
||||||
|
}
|
||||||
|
|
||||||
before_validation :generate_client_credentials, on: :create, if: :oidc?
|
before_validation :generate_client_credentials, on: :create, if: :oidc?
|
||||||
|
|
||||||
@@ -151,8 +179,86 @@ 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
|
||||||
|
|
||||||
|
# Get app-specific custom claims for a user
|
||||||
|
def custom_claims_for_user(user)
|
||||||
|
app_claim = application_user_claims.find_by(user: user)
|
||||||
|
app_claim&.parsed_custom_claims || {}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if this application supports backchannel logout
|
||||||
|
def supports_backchannel_logout?
|
||||||
|
backchannel_logout_uri.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if a user has an active session with this application
|
||||||
|
# (i.e., has valid, non-revoked tokens)
|
||||||
|
def user_has_active_session?(user)
|
||||||
|
oidc_access_tokens.where(user: user).valid.exists? ||
|
||||||
|
oidc_refresh_tokens.where(user: user).valid.exists?
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def fix_icon_content_type
|
||||||
|
return unless icon.attached?
|
||||||
|
|
||||||
|
# Fix SVG content type if it was detected incorrectly
|
||||||
|
if icon.filename.extension == "svg" && icon.content_type == "application/octet-stream"
|
||||||
|
icon.blob.update(content_type: "image/svg+xml")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def icon_validation
|
||||||
|
return unless icon.attached?
|
||||||
|
|
||||||
|
# Check content type
|
||||||
|
allowed_types = ['image/png', 'image/jpg', 'image/jpeg', 'image/gif', 'image/svg+xml']
|
||||||
|
unless allowed_types.include?(icon.content_type)
|
||||||
|
errors.add(:icon, 'must be a PNG, JPG, GIF, or SVG image')
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check file size (2MB limit)
|
||||||
|
if icon.blob.byte_size > 2.megabytes
|
||||||
|
errors.add(:icon, 'must be less than 2MB')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def duration_to_human(seconds)
|
||||||
|
if seconds < 3600
|
||||||
|
"#{seconds / 60} minutes"
|
||||||
|
elsif seconds < 86400
|
||||||
|
"#{seconds / 3600} hours"
|
||||||
|
else
|
||||||
|
"#{seconds / 86400} days"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def generate_client_credentials
|
def generate_client_credentials
|
||||||
self.client_id ||= SecureRandom.urlsafe_base64(32)
|
self.client_id ||= SecureRandom.urlsafe_base64(32)
|
||||||
# Generate and hash the client secret
|
# Generate and hash the client secret
|
||||||
@@ -161,4 +267,18 @@ class Application < ApplicationRecord
|
|||||||
self.client_secret = secret
|
self.client_secret = secret
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def backchannel_logout_uri_must_be_https_in_production
|
||||||
|
return unless Rails.env.production?
|
||||||
|
return unless backchannel_logout_uri.present?
|
||||||
|
|
||||||
|
begin
|
||||||
|
uri = URI.parse(backchannel_logout_uri)
|
||||||
|
unless uri.scheme == 'https'
|
||||||
|
errors.add(:backchannel_logout_uri, 'must use HTTPS in production')
|
||||||
|
end
|
||||||
|
rescue URI::InvalidURIError
|
||||||
|
# Let the format validator handle invalid URIs
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
31
app/models/application_user_claim.rb
Normal file
31
app/models/application_user_claim.rb
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
class ApplicationUserClaim < ApplicationRecord
|
||||||
|
belongs_to :application
|
||||||
|
belongs_to :user
|
||||||
|
|
||||||
|
# Reserved OIDC claim names that should not be overridden
|
||||||
|
RESERVED_CLAIMS = %w[
|
||||||
|
iss sub aud exp iat nbf jti nonce azp
|
||||||
|
email email_verified preferred_username name
|
||||||
|
groups
|
||||||
|
].freeze
|
||||||
|
|
||||||
|
validates :user_id, uniqueness: { scope: :application_id }
|
||||||
|
validate :no_reserved_claim_names
|
||||||
|
|
||||||
|
# Parse custom_claims JSON field
|
||||||
|
def parsed_custom_claims
|
||||||
|
return {} if custom_claims.blank?
|
||||||
|
custom_claims.is_a?(Hash) ? custom_claims : {}
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def no_reserved_claim_names
|
||||||
|
return if custom_claims.blank?
|
||||||
|
|
||||||
|
reserved_used = parsed_custom_claims.keys.map(&:to_s) & RESERVED_CLAIMS
|
||||||
|
if reserved_used.any?
|
||||||
|
errors.add(:custom_claims, "cannot override reserved OIDC claims: #{reserved_used.join(', ')}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -4,11 +4,31 @@ class Group < ApplicationRecord
|
|||||||
has_many :application_groups, dependent: :destroy
|
has_many :application_groups, dependent: :destroy
|
||||||
has_many :applications, through: :application_groups
|
has_many :applications, through: :application_groups
|
||||||
|
|
||||||
|
# Reserved OIDC claim names that should not be overridden
|
||||||
|
RESERVED_CLAIMS = %w[
|
||||||
|
iss sub aud exp iat nbf jti nonce azp
|
||||||
|
email email_verified preferred_username name
|
||||||
|
groups
|
||||||
|
].freeze
|
||||||
|
|
||||||
validates :name, presence: true, uniqueness: { case_sensitive: false }
|
validates :name, presence: true, uniqueness: { case_sensitive: false }
|
||||||
normalizes :name, with: ->(name) { name.strip.downcase }
|
normalizes :name, with: ->(name) { name.strip.downcase }
|
||||||
|
validate :no_reserved_claim_names
|
||||||
|
|
||||||
# Parse custom_claims JSON field
|
# Parse custom_claims JSON field
|
||||||
def parsed_custom_claims
|
def parsed_custom_claims
|
||||||
custom_claims || {}
|
return {} if custom_claims.blank?
|
||||||
|
custom_claims.is_a?(Hash) ? custom_claims : {}
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def no_reserved_claim_names
|
||||||
|
return if custom_claims.blank?
|
||||||
|
|
||||||
|
reserved_used = parsed_custom_claims.keys.map(&:to_s) & RESERVED_CLAIMS
|
||||||
|
if reserved_used.any?
|
||||||
|
errors.add(:custom_claims, "cannot override reserved OIDC claims: #{reserved_used.join(', ')}")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -6,6 +6,7 @@ class OidcUserConsent < ApplicationRecord
|
|||||||
validates :user_id, uniqueness: { scope: :application_id }
|
validates :user_id, uniqueness: { scope: :application_id }
|
||||||
|
|
||||||
before_validation :set_granted_at, on: :create
|
before_validation :set_granted_at, on: :create
|
||||||
|
before_validation :set_sid, on: :create
|
||||||
|
|
||||||
# Parse scopes_granted into an array
|
# Parse scopes_granted into an array
|
||||||
def scopes
|
def scopes
|
||||||
@@ -44,9 +45,18 @@ class OidcUserConsent < ApplicationRecord
|
|||||||
end.join(', ')
|
end.join(', ')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Find consent by SID
|
||||||
|
def self.find_by_sid(sid)
|
||||||
|
find_by(sid: sid)
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def set_granted_at
|
def set_granted_at
|
||||||
self.granted_at ||= Time.current
|
self.granted_at ||= Time.current
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def set_sid
|
||||||
|
self.sid ||= SecureRandom.uuid
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ class User < ApplicationRecord
|
|||||||
has_many :sessions, dependent: :destroy
|
has_many :sessions, dependent: :destroy
|
||||||
has_many :user_groups, dependent: :destroy
|
has_many :user_groups, dependent: :destroy
|
||||||
has_many :groups, through: :user_groups
|
has_many :groups, through: :user_groups
|
||||||
|
has_many :application_user_claims, dependent: :destroy
|
||||||
has_many :oidc_user_consents, dependent: :destroy
|
has_many :oidc_user_consents, dependent: :destroy
|
||||||
has_many :webauthn_credentials, dependent: :destroy
|
has_many :webauthn_credentials, dependent: :destroy
|
||||||
|
|
||||||
@@ -20,10 +21,22 @@ class User < ApplicationRecord
|
|||||||
end
|
end
|
||||||
|
|
||||||
normalizes :email_address, with: ->(e) { e.strip.downcase }
|
normalizes :email_address, with: ->(e) { e.strip.downcase }
|
||||||
|
normalizes :username, with: ->(u) { u.strip.downcase if u.present? }
|
||||||
|
|
||||||
|
# Reserved OIDC claim names that should not be overridden
|
||||||
|
RESERVED_CLAIMS = %w[
|
||||||
|
iss sub aud exp iat nbf jti nonce azp
|
||||||
|
email email_verified preferred_username name
|
||||||
|
groups
|
||||||
|
].freeze
|
||||||
|
|
||||||
validates :email_address, presence: true, uniqueness: { case_sensitive: false },
|
validates :email_address, presence: true, uniqueness: { case_sensitive: false },
|
||||||
format: { with: URI::MailTo::EMAIL_REGEXP }
|
format: { with: URI::MailTo::EMAIL_REGEXP }
|
||||||
|
validates :username, uniqueness: { case_sensitive: false }, allow_nil: true,
|
||||||
|
format: { with: /\A[a-zA-Z0-9_-]+\z/, message: "can only contain letters, numbers, underscores, and hyphens" },
|
||||||
|
length: { minimum: 2, maximum: 30 }
|
||||||
validates :password, length: { minimum: 8 }, allow_nil: true
|
validates :password, length: { minimum: 8 }, allow_nil: true
|
||||||
|
validate :no_reserved_claim_names
|
||||||
|
|
||||||
# Enum - automatically creates scopes (User.active, User.disabled, etc.)
|
# Enum - automatically creates scopes (User.active, User.disabled, etc.)
|
||||||
enum :status, { active: 0, disabled: 1, pending_invitation: 2 }
|
enum :status, { active: 0, disabled: 1, pending_invitation: 2 }
|
||||||
@@ -44,7 +57,9 @@ class User < ApplicationRecord
|
|||||||
end
|
end
|
||||||
|
|
||||||
def disable_totp!
|
def disable_totp!
|
||||||
update!(totp_secret: nil, totp_required: false, backup_codes: nil)
|
# Note: This does NOT clear totp_required flag
|
||||||
|
# Admins control that flag via admin panel, users cannot remove admin-required 2FA
|
||||||
|
update!(totp_secret: nil, backup_codes: nil)
|
||||||
end
|
end
|
||||||
|
|
||||||
def totp_provisioning_uri(issuer: "Clinch")
|
def totp_provisioning_uri(issuer: "Clinch")
|
||||||
@@ -180,11 +195,39 @@ class User < ApplicationRecord
|
|||||||
|
|
||||||
# Parse custom_claims JSON field
|
# Parse custom_claims JSON field
|
||||||
def parsed_custom_claims
|
def parsed_custom_claims
|
||||||
custom_claims || {}
|
return {} if custom_claims.blank?
|
||||||
|
custom_claims.is_a?(Hash) ? custom_claims : {}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Get fully merged claims for a specific application
|
||||||
|
def merged_claims_for_application(application)
|
||||||
|
merged = {}
|
||||||
|
|
||||||
|
# Start with group claims (in order)
|
||||||
|
groups.each do |group|
|
||||||
|
merged.merge!(group.parsed_custom_claims)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Merge user global claims
|
||||||
|
merged.merge!(parsed_custom_claims)
|
||||||
|
|
||||||
|
# Merge app-specific claims (highest priority)
|
||||||
|
merged.merge!(application.custom_claims_for_user(self))
|
||||||
|
|
||||||
|
merged
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def no_reserved_claim_names
|
||||||
|
return if custom_claims.blank?
|
||||||
|
|
||||||
|
reserved_used = parsed_custom_claims.keys.map(&:to_s) & RESERVED_CLAIMS
|
||||||
|
if reserved_used.any?
|
||||||
|
errors.add(:custom_claims, "cannot override reserved OIDC claims: #{reserved_used.join(', ')}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def generate_backup_codes
|
def generate_backup_codes
|
||||||
# Generate plain codes for user to see/save
|
# Generate plain codes for user to see/save
|
||||||
plain_codes = Array.new(10) { SecureRandom.alphanumeric(8).upcase }
|
plain_codes = Array.new(10) { SecureRandom.alphanumeric(8).upcase }
|
||||||
|
|||||||
35
app/services/concerns/claims_merger.rb
Normal file
35
app/services/concerns/claims_merger.rb
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
module ClaimsMerger
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
# Deep merge claims, combining arrays instead of overwriting them
|
||||||
|
# This ensures that array values (like roles) are combined across group/user/app claims
|
||||||
|
#
|
||||||
|
# Example:
|
||||||
|
# base = { "roles" => ["user"], "level" => 1 }
|
||||||
|
# incoming = { "roles" => ["admin"], "department" => "IT" }
|
||||||
|
# deep_merge_claims(base, incoming)
|
||||||
|
# # => { "roles" => ["user", "admin"], "level" => 1, "department" => "IT" }
|
||||||
|
def deep_merge_claims(base, incoming)
|
||||||
|
result = base.dup
|
||||||
|
|
||||||
|
incoming.each do |key, value|
|
||||||
|
if result.key?(key)
|
||||||
|
# If both values are arrays, combine them (union to avoid duplicates)
|
||||||
|
if result[key].is_a?(Array) && value.is_a?(Array)
|
||||||
|
result[key] = (result[key] + value).uniq
|
||||||
|
# If both values are hashes, recursively merge them
|
||||||
|
elsif result[key].is_a?(Hash) && value.is_a?(Hash)
|
||||||
|
result[key] = deep_merge_claims(result[key], value)
|
||||||
|
else
|
||||||
|
# Otherwise, incoming value wins (override)
|
||||||
|
result[key] = value
|
||||||
|
end
|
||||||
|
else
|
||||||
|
# New key, just add it
|
||||||
|
result[key] = value
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
result
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,18 +1,25 @@
|
|||||||
class OidcJwtService
|
class OidcJwtService
|
||||||
|
extend ClaimsMerger
|
||||||
|
|
||||||
class << self
|
class << self
|
||||||
# Generate an ID token (JWT) for the user
|
# Generate an ID token (JWT) for the user
|
||||||
def generate_id_token(user, application, nonce: nil)
|
def generate_id_token(user, application, consent: nil, nonce: nil)
|
||||||
now = Time.current.to_i
|
now = Time.current.to_i
|
||||||
|
# Use application's configured ID token TTL (defaults to 1 hour)
|
||||||
|
ttl = application.id_token_expiry_seconds
|
||||||
|
|
||||||
|
# Use pairwise SID from consent if available, fallback to user ID
|
||||||
|
subject = consent&.sid || user.id.to_s
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
iss: issuer_url,
|
iss: issuer_url,
|
||||||
sub: user.id.to_s,
|
sub: subject,
|
||||||
aud: application.client_id,
|
aud: application.client_id,
|
||||||
exp: now + 3600, # 1 hour
|
exp: now + ttl,
|
||||||
iat: now,
|
iat: now,
|
||||||
email: user.email_address,
|
email: user.email_address,
|
||||||
email_verified: true,
|
email_verified: true,
|
||||||
preferred_username: user.email_address,
|
preferred_username: user.username.presence || user.email_address,
|
||||||
name: user.name.presence || user.email_address
|
name: user.name.presence || user.email_address
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,17 +31,41 @@ class OidcJwtService
|
|||||||
payload[:groups] = user.groups.pluck(:name)
|
payload[:groups] = user.groups.pluck(:name)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Add admin claim if user is admin
|
# Merge custom claims from groups (arrays are combined, not overwritten)
|
||||||
payload[:admin] = true if user.admin?
|
|
||||||
|
|
||||||
# Merge custom claims from groups
|
|
||||||
user.groups.each do |group|
|
user.groups.each do |group|
|
||||||
payload.merge!(group.parsed_custom_claims)
|
payload = deep_merge_claims(payload, group.parsed_custom_claims)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Merge custom claims from user (overrides group claims)
|
# Merge custom claims from user (arrays are combined, other values override)
|
||||||
payload.merge!(user.parsed_custom_claims)
|
payload = deep_merge_claims(payload, user.parsed_custom_claims)
|
||||||
|
|
||||||
|
# Merge app-specific custom claims (highest priority, arrays are combined)
|
||||||
|
payload = deep_merge_claims(payload, application.custom_claims_for_user(user))
|
||||||
|
|
||||||
|
JWT.encode(payload, private_key, "RS256", { kid: key_id, typ: "JWT" })
|
||||||
|
end
|
||||||
|
|
||||||
|
# Generate a backchannel logout token (JWT)
|
||||||
|
# Per OIDC Back-Channel Logout spec, this token:
|
||||||
|
# - MUST include iss, aud, iat, jti, events claims
|
||||||
|
# - MUST include sub or sid (or both) - we always include both
|
||||||
|
# - MUST NOT include nonce claim
|
||||||
|
def generate_logout_token(user, application, consent)
|
||||||
|
now = Time.current.to_i
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
iss: issuer_url,
|
||||||
|
sub: consent.sid, # Pairwise subject identifier
|
||||||
|
aud: application.client_id,
|
||||||
|
iat: now,
|
||||||
|
jti: SecureRandom.uuid, # Unique identifier for this logout token
|
||||||
|
sid: consent.sid, # Session ID - always included for granular logout
|
||||||
|
events: {
|
||||||
|
"http://schemas.openid.net/event/backchannel-logout" => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Important: Do NOT include nonce in logout tokens (spec requirement)
|
||||||
JWT.encode(payload, private_key, "RS256", { kid: key_id, typ: "JWT" })
|
JWT.encode(payload, private_key, "RS256", { kid: key_id, typ: "JWT" })
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -63,7 +94,14 @@ class OidcJwtService
|
|||||||
def issuer_url
|
def issuer_url
|
||||||
# In production, this should come from ENV or config
|
# In production, this should come from ENV or config
|
||||||
# For now, we'll use a placeholder that can be overridden
|
# For now, we'll use a placeholder that can be overridden
|
||||||
"https://#{ENV.fetch("CLINCH_HOST", "localhost:3000")}"
|
host = ENV.fetch("CLINCH_HOST", "localhost:3000")
|
||||||
|
# Ensure URL has protocol - use https:// in production, http:// in development
|
||||||
|
if host.match?(/^https?:\/\//)
|
||||||
|
host
|
||||||
|
else
|
||||||
|
protocol = Rails.env.production? ? "https" : "http"
|
||||||
|
"#{protocol}://#{host}"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
@@ -71,17 +109,37 @@ class OidcJwtService
|
|||||||
# Get or generate RSA private key
|
# Get or generate RSA private key
|
||||||
def private_key
|
def private_key
|
||||||
@private_key ||= begin
|
@private_key ||= begin
|
||||||
|
key_source = nil
|
||||||
|
|
||||||
# Try ENV variable first (best for Docker/Kamal)
|
# Try ENV variable first (best for Docker/Kamal)
|
||||||
if ENV["OIDC_PRIVATE_KEY"].present?
|
if ENV["OIDC_PRIVATE_KEY"].present?
|
||||||
OpenSSL::PKey::RSA.new(ENV["OIDC_PRIVATE_KEY"])
|
key_source = ENV["OIDC_PRIVATE_KEY"]
|
||||||
# Then try Rails credentials
|
# Then try Rails credentials
|
||||||
elsif Rails.application.credentials.oidc_private_key.present?
|
elsif Rails.application.credentials.oidc_private_key.present?
|
||||||
OpenSSL::PKey::RSA.new(Rails.application.credentials.oidc_private_key)
|
key_source = Rails.application.credentials.oidc_private_key
|
||||||
|
end
|
||||||
|
|
||||||
|
if key_source.present?
|
||||||
|
begin
|
||||||
|
# Handle both actual newlines and escaped \n sequences
|
||||||
|
# Some .env loaders may escape newlines, so we need to convert them back
|
||||||
|
key_data = key_source.gsub("\\n", "\n")
|
||||||
|
OpenSSL::PKey::RSA.new(key_data)
|
||||||
|
rescue OpenSSL::PKey::RSAError => e
|
||||||
|
Rails.logger.error "OIDC: Failed to load private key: #{e.message}"
|
||||||
|
Rails.logger.error "OIDC: Key source length: #{key_source.length}, starts with: #{key_source[0..50]}"
|
||||||
|
raise "Invalid OIDC private key format. Please ensure the key is in PEM format with proper newlines."
|
||||||
|
end
|
||||||
else
|
else
|
||||||
# Generate a new key for development
|
# In production, we should never generate a key on the fly
|
||||||
# In production, you MUST set OIDC_PRIVATE_KEY env var or add to credentials
|
# because it would be different across servers/deployments
|
||||||
|
if Rails.env.production?
|
||||||
|
raise "OIDC private key not configured. Set OIDC_PRIVATE_KEY environment variable or add to Rails credentials."
|
||||||
|
end
|
||||||
|
|
||||||
|
# Generate a new key for development/test only
|
||||||
Rails.logger.warn "OIDC: No private key found in ENV or credentials, generating new key (development only)"
|
Rails.logger.warn "OIDC: No private key found in ENV or credentials, generating new key (development only)"
|
||||||
Rails.logger.warn "OIDC: Set OIDC_PRIVATE_KEY environment variable in production!"
|
Rails.logger.warn "OIDC: Set OIDC_PRIVATE_KEY environment variable for consistency across restarts"
|
||||||
OpenSSL::PKey::RSA.new(2048)
|
OpenSSL::PKey::RSA.new(2048)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,22 +1,5 @@
|
|||||||
<%= form_with(model: [:admin, application], class: "space-y-6", data: { controller: "application-form" }) do |form| %>
|
<%= form_with(model: [:admin, application], class: "space-y-6", data: { controller: "application-form form-errors" }) do |form| %>
|
||||||
<% if application.errors.any? %>
|
<%= render "shared/form_errors", form: form %>
|
||||||
<div class="rounded-md bg-red-50 p-4">
|
|
||||||
<div class="flex">
|
|
||||||
<div class="ml-3">
|
|
||||||
<h3 class="text-sm font-medium text-red-800">
|
|
||||||
<%= pluralize(application.errors.count, "error") %> prohibited this application from being saved:
|
|
||||||
</h3>
|
|
||||||
<div class="mt-2 text-sm text-red-700">
|
|
||||||
<ul class="list-disc pl-5 space-y-1">
|
|
||||||
<% application.errors.full_messages.each do |message| %>
|
|
||||||
<li><%= message %></li>
|
|
||||||
<% end %>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<%= form.label :name, class: "block text-sm font-medium text-gray-700" %>
|
<%= form.label :name, class: "block text-sm font-medium text-gray-700" %>
|
||||||
@@ -34,6 +17,87 @@
|
|||||||
<%= form.text_area :description, rows: 3, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Optional description of this application" %>
|
<%= form.text_area :description, rows: 3, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Optional description of this application" %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<%= form.label :icon, "Application Icon", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<a href="https://dashboardicons.com" target="_blank" rel="noopener noreferrer" class="text-xs text-blue-600 hover:text-blue-800 flex items-center gap-1">
|
||||||
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path>
|
||||||
|
</svg>
|
||||||
|
Browse icons at dashboardicons.com
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<% if application.icon.attached? && application.persisted? %>
|
||||||
|
<% begin %>
|
||||||
|
<%# Only show icon if we can successfully get its URL (blob is persisted) %>
|
||||||
|
<% if application.icon.blob&.persisted? && application.icon.blob.key.present? %>
|
||||||
|
<div class="mt-2 mb-3 flex items-center gap-4">
|
||||||
|
<%= image_tag application.icon, class: "h-16 w-16 rounded-lg object-cover border border-gray-200", alt: "Current icon" %>
|
||||||
|
<div class="text-sm text-gray-600">
|
||||||
|
<p class="font-medium">Current icon</p>
|
||||||
|
<p class="text-xs"><%= number_to_human_size(application.icon.blob.byte_size) %></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<% rescue ArgumentError => e %>
|
||||||
|
<%# Handle case where icon attachment exists but can't generate signed_id %>
|
||||||
|
<% if e.message.include?("Cannot get a signed_id for a new record") %>
|
||||||
|
<div class="mt-2 mb-3 text-sm text-gray-600">
|
||||||
|
<p class="font-medium">Icon uploaded</p>
|
||||||
|
<p class="text-xs">File will be processed shortly</p>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<%# Re-raise if it's a different error %>
|
||||||
|
<% raise e %>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<div class="mt-2" data-controller="file-drop image-paste">
|
||||||
|
<div class="flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 border-dashed rounded-md hover:border-blue-400 transition-colors"
|
||||||
|
data-file-drop-target="dropzone"
|
||||||
|
data-image-paste-target="dropzone"
|
||||||
|
data-action="dragover->file-drop#dragover dragleave->file-drop#dragleave drop->file-drop#drop paste->image-paste#handlePaste"
|
||||||
|
tabindex="0">
|
||||||
|
<div class="space-y-1 text-center">
|
||||||
|
<svg class="mx-auto h-12 w-12 text-gray-400" stroke="currentColor" fill="none" viewBox="0 0 48 48">
|
||||||
|
<path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
<div class="flex text-sm text-gray-600">
|
||||||
|
<label for="<%= form.field_id(:icon) %>" class="relative cursor-pointer bg-white rounded-md font-medium text-blue-600 hover:text-blue-500 focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2 focus-within:ring-blue-500">
|
||||||
|
<span>Upload a file</span>
|
||||||
|
<%= form.file_field :icon,
|
||||||
|
accept: "image/png,image/jpg,image/jpeg,image/gif,image/svg+xml",
|
||||||
|
class: "sr-only",
|
||||||
|
data: {
|
||||||
|
file_drop_target: "input",
|
||||||
|
image_paste_target: "input",
|
||||||
|
action: "change->file-drop#handleFiles"
|
||||||
|
} %>
|
||||||
|
</label>
|
||||||
|
<p class="pl-1">or drag and drop</p>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500">PNG, JPG, GIF, or SVG up to 2MB</p>
|
||||||
|
<p class="text-xs text-blue-600 font-medium mt-2">💡 Tip: Click here and press Ctrl+V (or Cmd+V) to paste an image from your clipboard</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div data-file-drop-target="preview" class="mt-3 hidden">
|
||||||
|
<div class="flex items-center gap-3 p-3 bg-blue-50 rounded-md border border-blue-200">
|
||||||
|
<img data-file-drop-target="previewImage" class="h-12 w-12 rounded object-cover" alt="Preview">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-sm font-medium text-gray-900" data-file-drop-target="filename"></p>
|
||||||
|
<p class="text-xs text-gray-500" data-file-drop-target="filesize"></p>
|
||||||
|
</div>
|
||||||
|
<button type="button" data-action="click->file-drop#clear" class="text-gray-400 hover:text-gray-600">
|
||||||
|
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<%= form.label :landing_url, "Landing URL", class: "block text-sm font-medium text-gray-700" %>
|
<%= form.label :landing_url, "Landing URL", class: "block text-sm font-medium text-gray-700" %>
|
||||||
<%= form.url_field :landing_url, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "https://app.example.com" %>
|
<%= form.url_field :landing_url, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "https://app.example.com" %>
|
||||||
@@ -61,6 +125,63 @@
|
|||||||
<%= form.text_area :redirect_uris, rows: 4, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono", placeholder: "https://example.com/callback\nhttps://app.example.com/auth/callback" %>
|
<%= form.text_area :redirect_uris, rows: 4, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono", placeholder: "https://example.com/callback\nhttps://app.example.com/auth/callback" %>
|
||||||
<p class="mt-1 text-sm text-gray-500">One URI per line. These are the allowed callback URLs for your application.</p>
|
<p class="mt-1 text-sm text-gray-500">One URI per line. These are the allowed callback URLs for your application.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= form.label :backchannel_logout_uri, "Backchannel Logout URI (Optional)", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.url_field :backchannel_logout_uri, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono", placeholder: "https://app.example.com/oidc/backchannel-logout" %>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">
|
||||||
|
If the application supports OpenID Connect Backchannel Logout, enter the logout endpoint URL.
|
||||||
|
When users log out, Clinch will send logout notifications to this endpoint for immediate session termination.
|
||||||
|
Leave blank if the application doesn't support backchannel logout.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-t border-gray-200 pt-4 mt-4">
|
||||||
|
<h4 class="text-sm font-semibold text-gray-900 mb-3">Token Expiration Settings</h4>
|
||||||
|
<p class="text-sm text-gray-500 mb-4">Configure how long tokens remain valid. Shorter times are more secure but require more frequent refreshes.</p>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<%= form.label :access_token_ttl, "Access Token TTL (seconds)", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.number_field :access_token_ttl, value: application.access_token_ttl || 3600, min: 300, max: 86400, step: 60, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
||||||
|
<p class="mt-1 text-xs text-gray-500">
|
||||||
|
Range: 5 min - 24 hours
|
||||||
|
<br>Default: 1 hour (3600s)
|
||||||
|
<br>Current: <span class="font-medium"><%= application.access_token_ttl_human || "1 hour" %></span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= form.label :refresh_token_ttl, "Refresh Token TTL (seconds)", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.number_field :refresh_token_ttl, value: application.refresh_token_ttl || 2592000, min: 86400, max: 7776000, step: 86400, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
||||||
|
<p class="mt-1 text-xs text-gray-500">
|
||||||
|
Range: 1 day - 90 days
|
||||||
|
<br>Default: 30 days (2592000s)
|
||||||
|
<br>Current: <span class="font-medium"><%= application.refresh_token_ttl_human || "30 days" %></span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= form.label :id_token_ttl, "ID Token TTL (seconds)", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.number_field :id_token_ttl, value: application.id_token_ttl || 3600, min: 300, max: 86400, step: 60, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
||||||
|
<p class="mt-1 text-xs text-gray-500">
|
||||||
|
Range: 5 min - 24 hours
|
||||||
|
<br>Default: 1 hour (3600s)
|
||||||
|
<br>Current: <span class="font-medium"><%= application.id_token_ttl_human || "1 hour" %></span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details class="mt-3">
|
||||||
|
<summary class="cursor-pointer text-sm text-blue-600 hover:text-blue-800">Understanding Token Types</summary>
|
||||||
|
<div class="mt-2 ml-4 space-y-2 text-sm text-gray-600">
|
||||||
|
<p><strong>Access Token:</strong> Used to access protected resources (APIs). Shorter lifetime = more secure. Users won't notice automatic refreshes.</p>
|
||||||
|
<p><strong>Refresh Token:</strong> Used to get new access tokens without re-authentication. Longer lifetime = better UX (less re-logins).</p>
|
||||||
|
<p><strong>ID Token:</strong> Contains user identity information (JWT). Should match access token lifetime in most cases.</p>
|
||||||
|
<p class="text-xs italic mt-2">💡 Tip: Banking apps use 5-15 min access tokens. Internal tools use 1-4 hours.</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Forward Auth-specific fields -->
|
<!-- Forward Auth-specific fields -->
|
||||||
@@ -73,12 +194,25 @@
|
|||||||
<p class="mt-1 text-sm text-gray-500">Domain pattern to match. Use * for wildcard subdomains (e.g., *.example.com matches app.example.com, api.example.com, etc.)</p>
|
<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">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
<p class="font-medium">Optional: Customize header names sent to your application.</p>
|
<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">
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
<table class="min-w-full divide-y divide-gray-300">
|
<table class="min-w-full divide-y divide-gray-300">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-0">Name</th>
|
<th scope="col" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-0">Application</th>
|
||||||
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Slug</th>
|
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Slug</th>
|
||||||
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Type</th>
|
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Type</th>
|
||||||
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Status</th>
|
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Status</th>
|
||||||
@@ -28,7 +28,18 @@
|
|||||||
<% @applications.each do |application| %>
|
<% @applications.each do |application| %>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-0">
|
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-0">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<% if application.icon.attached? %>
|
||||||
|
<%= image_tag application.icon, class: "h-10 w-10 rounded-lg object-cover border border-gray-200 flex-shrink-0", alt: "#{application.name} icon" %>
|
||||||
|
<% else %>
|
||||||
|
<div class="h-10 w-10 rounded-lg bg-gray-100 border border-gray-200 flex items-center justify-center flex-shrink-0">
|
||||||
|
<svg class="h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
<%= link_to application.name, admin_application_path(application), class: "text-blue-600 hover:text-blue-900" %>
|
<%= link_to application.name, admin_application_path(application), class: "text-blue-600 hover:text-blue-900" %>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||||
<code class="text-xs bg-gray-100 px-2 py-1 rounded"><%= application.slug %></code>
|
<code class="text-xs bg-gray-100 px-2 py-1 rounded"><%= application.slug %></code>
|
||||||
@@ -37,6 +48,8 @@
|
|||||||
<% case application.app_type %>
|
<% case application.app_type %>
|
||||||
<% when "oidc" %>
|
<% when "oidc" %>
|
||||||
<span class="inline-flex items-center rounded-full bg-purple-100 px-2 py-1 text-xs font-medium text-purple-700">OIDC</span>
|
<span class="inline-flex items-center rounded-full bg-purple-100 px-2 py-1 text-xs font-medium text-purple-700">OIDC</span>
|
||||||
|
<% when "forward_auth" %>
|
||||||
|
<span class="inline-flex items-center rounded-full bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700">Forward Auth</span>
|
||||||
<% when "saml" %>
|
<% when "saml" %>
|
||||||
<span class="inline-flex items-center rounded-full bg-orange-100 px-2 py-1 text-xs font-medium text-orange-700">SAML</span>
|
<span class="inline-flex items-center rounded-full bg-orange-100 px-2 py-1 text-xs font-medium text-orange-700">SAML</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -16,11 +16,22 @@
|
|||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<div class="sm:flex sm:items-center sm:justify-between">
|
<div class="sm:flex sm:items-start sm:justify-between">
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<% if @application.icon.attached? %>
|
||||||
|
<%= image_tag @application.icon, class: "h-16 w-16 rounded-lg object-cover border border-gray-200 shrink-0", alt: "#{@application.name} icon" %>
|
||||||
|
<% else %>
|
||||||
|
<div class="h-16 w-16 rounded-lg bg-gray-100 border border-gray-200 flex items-center justify-center shrink-0">
|
||||||
|
<svg class="h-8 w-8 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-semibold text-gray-900"><%= @application.name %></h1>
|
<h1 class="text-2xl font-semibold text-gray-900"><%= @application.name %></h1>
|
||||||
<p class="mt-1 text-sm text-gray-500"><%= @application.description %></p>
|
<p class="mt-1 text-sm text-gray-500"><%= @application.description %></p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="mt-4 sm:mt-0 flex gap-3">
|
<div class="mt-4 sm:mt-0 flex gap-3">
|
||||||
<%= link_to "Edit", edit_admin_application_path(@application), class: "rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50" %>
|
<%= link_to "Edit", edit_admin_application_path(@application), class: "rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50" %>
|
||||||
<%= button_to "Delete", admin_application_path(@application), method: :delete, data: { turbo_confirm: "Are you sure?" }, class: "rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500" %>
|
<%= button_to "Delete", admin_application_path(@application), method: :delete, data: { turbo_confirm: "Are you sure?" }, class: "rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500" %>
|
||||||
@@ -78,10 +89,11 @@
|
|||||||
<div class="bg-white shadow sm:rounded-lg">
|
<div class="bg-white shadow sm:rounded-lg">
|
||||||
<div class="px-4 py-5 sm:p-6">
|
<div class="px-4 py-5 sm:p-6">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h3 class="text-base font-semibold leading-6 text-gray-900">OIDC Credentials</h3>
|
<h3 class="text-base font-semibold leading-6 text-gray-900">OIDC Configuration</h3>
|
||||||
<%= button_to "Regenerate Credentials", regenerate_credentials_admin_application_path(@application), method: :post, data: { turbo_confirm: "This will invalidate the current credentials. Continue?" }, class: "text-sm text-red-600 hover:text-red-900" %>
|
<%= button_to "Regenerate Credentials", regenerate_credentials_admin_application_path(@application), method: :post, data: { turbo_confirm: "This will invalidate the current credentials. Continue?" }, class: "text-sm text-red-600 hover:text-red-900" %>
|
||||||
</div>
|
</div>
|
||||||
<dl class="space-y-4">
|
<dl class="space-y-4">
|
||||||
|
<% unless flash[:client_id] && flash[:client_secret] %>
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-sm font-medium text-gray-500">Client ID</dt>
|
<dt class="text-sm font-medium text-gray-500">Client ID</dt>
|
||||||
<dd class="mt-1 text-sm text-gray-900">
|
<dd class="mt-1 text-sm text-gray-900">
|
||||||
@@ -99,6 +111,7 @@
|
|||||||
</p>
|
</p>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
|
<% end %>
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-sm font-medium text-gray-500">Redirect URIs</dt>
|
<dt class="text-sm font-medium text-gray-500">Redirect URIs</dt>
|
||||||
<dd class="mt-1 text-sm text-gray-900">
|
<dd class="mt-1 text-sm text-gray-900">
|
||||||
@@ -111,6 +124,27 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">
|
||||||
|
Backchannel Logout URI
|
||||||
|
<% if @application.supports_backchannel_logout? %>
|
||||||
|
<span class="ml-2 inline-flex items-center rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700">Enabled</span>
|
||||||
|
<% end %>
|
||||||
|
</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900">
|
||||||
|
<% if @application.backchannel_logout_uri.present? %>
|
||||||
|
<code class="block bg-gray-100 px-3 py-2 rounded font-mono text-xs break-all"><%= @application.backchannel_logout_uri %></code>
|
||||||
|
<p class="mt-2 text-xs text-gray-500">
|
||||||
|
When users log out, Clinch will send logout notifications to this endpoint for immediate session termination.
|
||||||
|
</p>
|
||||||
|
<% else %>
|
||||||
|
<span class="text-gray-400 italic">Not configured</span>
|
||||||
|
<p class="mt-1 text-xs text-gray-500">
|
||||||
|
Backchannel logout is optional. Configure it if the application supports OpenID Connect Backchannel Logout.
|
||||||
|
</p>
|
||||||
|
<% end %>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 %>
|
||||||
|
|||||||
185
app/views/admin/users/_application_claims.html.erb
Normal file
185
app/views/admin/users/_application_claims.html.erb
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
<% oidc_apps = applications.select(&:oidc?) %>
|
||||||
|
<% forward_auth_apps = applications.select(&:forward_auth?) %>
|
||||||
|
|
||||||
|
<!-- OIDC Apps: Custom Claims -->
|
||||||
|
<% if oidc_apps.any? %>
|
||||||
|
<div class="mt-12 border-t pt-8">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 mb-4">OIDC App-Specific Claims</h2>
|
||||||
|
<p class="text-sm text-gray-600 mb-6">
|
||||||
|
Configure custom claims that apply only to specific OIDC applications. These override both group and user global claims and are included in ID tokens.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<% oidc_apps.each do |app| %>
|
||||||
|
<% app_claim = user.application_user_claims.find_by(application: app) %>
|
||||||
|
<details class="border rounded-lg" <%= "open" if app_claim&.custom_claims&.any? %>>
|
||||||
|
<summary class="cursor-pointer bg-gray-50 px-4 py-3 hover:bg-gray-100 rounded-t-lg flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="font-medium text-gray-900"><%= app.name %></span>
|
||||||
|
<span class="text-xs px-2 py-1 rounded-full bg-blue-100 text-blue-700">
|
||||||
|
OIDC
|
||||||
|
</span>
|
||||||
|
<% if app_claim&.custom_claims&.any? %>
|
||||||
|
<span class="text-xs px-2 py-1 rounded-full bg-amber-100 text-amber-700">
|
||||||
|
<%= app_claim.custom_claims.keys.count %> claim(s)
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<svg class="h-5 w-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
<div class="p-4 space-y-4">
|
||||||
|
<%= form_with url: update_application_claims_admin_user_path(user), method: :post, class: "space-y-4", data: { controller: "json-validator" } do |form| %>
|
||||||
|
<%= hidden_field_tag :application_id, app.id %>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Custom Claims (JSON)</label>
|
||||||
|
<%= text_area_tag :custom_claims,
|
||||||
|
(app_claim&.custom_claims.present? ? JSON.pretty_generate(app_claim.custom_claims) : ""),
|
||||||
|
rows: 8,
|
||||||
|
class: "w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono",
|
||||||
|
placeholder: '{"kavita_groups": ["admin"], "library_access": "all"}',
|
||||||
|
data: {
|
||||||
|
action: "input->json-validator#validate blur->json-validator#format",
|
||||||
|
json_validator_target: "textarea"
|
||||||
|
} %>
|
||||||
|
<div class="mt-2 space-y-1">
|
||||||
|
<p class="text-xs text-gray-600">
|
||||||
|
Example for <%= app.name %>: Add claims that this app specifically needs to read.
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-amber-600">
|
||||||
|
<strong>Note:</strong> Do not use reserved claim names (<code class="bg-amber-50 px-1 rounded">groups</code>, <code class="bg-amber-50 px-1 rounded">email</code>, <code class="bg-amber-50 px-1 rounded">name</code>, etc.). Use app-specific names like <code class="bg-amber-50 px-1 rounded">kavita_groups</code> instead.
|
||||||
|
</p>
|
||||||
|
<div data-json-validator-target="status" class="text-xs font-medium"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<%= button_tag type: :submit, class: "rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500" do %>
|
||||||
|
<%= app_claim ? "Update" : "Add" %> Claims
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<% if app_claim %>
|
||||||
|
<%= button_to "Remove Override",
|
||||||
|
delete_application_claims_admin_user_path(user, application_id: app.id),
|
||||||
|
method: :delete,
|
||||||
|
data: { turbo_confirm: "Remove app-specific claims for #{app.name}?" },
|
||||||
|
class: "rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50" %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<!-- Preview merged claims -->
|
||||||
|
<div class="mt-4 border-t pt-4">
|
||||||
|
<h4 class="text-sm font-medium text-gray-700 mb-2">Preview: Final ID Token Claims for <%= app.name %></h4>
|
||||||
|
<div class="bg-gray-50 rounded-lg p-3">
|
||||||
|
<pre class="text-xs font-mono text-gray-800 overflow-x-auto"><%= JSON.pretty_generate(preview_user_claims(user, app)) %></pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details class="mt-2">
|
||||||
|
<summary class="cursor-pointer text-xs text-gray-600 hover:text-gray-900">Show claim sources</summary>
|
||||||
|
<div class="mt-2 space-y-1">
|
||||||
|
<% claim_sources(user, app).each do |source| %>
|
||||||
|
<div class="flex gap-2 items-start text-xs">
|
||||||
|
<span class="px-2 py-1 rounded <%= source[:type] == :group ? 'bg-blue-100 text-blue-700' : (source[:type] == :user ? 'bg-green-100 text-green-700' : 'bg-amber-100 text-amber-700') %>">
|
||||||
|
<%= source[:name] %>
|
||||||
|
</span>
|
||||||
|
<code class="text-gray-700"><%= source[:claims].to_json %></code>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<!-- ForwardAuth Apps: Headers Preview -->
|
||||||
|
<% if forward_auth_apps.any? %>
|
||||||
|
<div class="mt-12 border-t pt-8">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 mb-4">ForwardAuth Headers Preview</h2>
|
||||||
|
<p class="text-sm text-gray-600 mb-6">
|
||||||
|
ForwardAuth applications receive HTTP headers (not OIDC tokens). Headers are based on user's email, name, groups, and admin status.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<% forward_auth_apps.each do |app| %>
|
||||||
|
<details class="border rounded-lg">
|
||||||
|
<summary class="cursor-pointer bg-gray-50 px-4 py-3 hover:bg-gray-100 rounded-t-lg flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="font-medium text-gray-900"><%= app.name %></span>
|
||||||
|
<span class="text-xs px-2 py-1 rounded-full bg-green-100 text-green-700">
|
||||||
|
FORWARD AUTH
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-gray-500">
|
||||||
|
<%= app.domain_pattern %>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<svg class="h-5 w-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
<div class="p-4 space-y-4">
|
||||||
|
<div class="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||||
|
<div class="flex items-start">
|
||||||
|
<svg class="h-5 w-5 text-blue-400 mr-2 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 class="text-sm font-medium text-gray-700 mb-2">Headers Sent to <%= app.name %></h4>
|
||||||
|
<div class="bg-gray-50 rounded-lg p-3 border">
|
||||||
|
<% headers = app.headers_for_user(user) %>
|
||||||
|
<% if headers.any? %>
|
||||||
|
<dl class="space-y-2 text-xs font-mono">
|
||||||
|
<% headers.each do |header_name, value| %>
|
||||||
|
<div class="flex">
|
||||||
|
<dt class="text-blue-600 font-semibold w-48"><%= header_name %>:</dt>
|
||||||
|
<dd class="text-gray-800 flex-1"><%= value %></dd>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</dl>
|
||||||
|
<% else %>
|
||||||
|
<p class="text-xs text-gray-500 italic">All headers disabled for this application.</p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-xs text-gray-500">
|
||||||
|
These headers are configured in the application settings and sent by your reverse proxy (Caddy/Traefik) to the upstream application.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if user.groups.any? %>
|
||||||
|
<div>
|
||||||
|
<h4 class="text-sm font-medium text-gray-700 mb-2">User's Groups</h4>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<% user.groups.each do |group| %>
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||||
|
<%= group.name %>
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<% if oidc_apps.empty? && forward_auth_apps.empty? %>
|
||||||
|
<div class="mt-12 border-t pt-8">
|
||||||
|
<div class="text-center py-12 bg-gray-50 rounded-lg">
|
||||||
|
<p class="text-gray-500">No active applications found.</p>
|
||||||
|
<p class="text-sm text-gray-400 mt-1">Create applications in the Admin panel first.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
@@ -1,32 +1,21 @@
|
|||||||
<%= form_with(model: [:admin, user], class: "space-y-6") do |form| %>
|
<%= form_with(model: [:admin, user], class: "space-y-6", data: { controller: "form-errors" }) do |form| %>
|
||||||
<% if user.errors.any? %>
|
<%= render "shared/form_errors", form: form %>
|
||||||
<div class="rounded-md bg-red-50 p-4">
|
|
||||||
<div class="flex">
|
|
||||||
<div class="ml-3">
|
|
||||||
<h3 class="text-sm font-medium text-red-800">
|
|
||||||
<%= pluralize(user.errors.count, "error") %> prohibited this user from being saved:
|
|
||||||
</h3>
|
|
||||||
<div class="mt-2 text-sm text-red-700">
|
|
||||||
<ul class="list-disc pl-5 space-y-1">
|
|
||||||
<% user.errors.full_messages.each do |message| %>
|
|
||||||
<li><%= message %></li>
|
|
||||||
<% end %>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<%= form.label :email_address, class: "block text-sm font-medium text-gray-700" %>
|
<%= form.label :email_address, class: "block text-sm font-medium text-gray-700" %>
|
||||||
<%= form.email_field :email_address, required: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "user@example.com" %>
|
<%= form.email_field :email_address, required: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "user@example.com" %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= form.label :username, "Username (Optional)", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.text_field :username, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "jsmith" %>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">Optional: Short username/handle for login. Can only contain letters, numbers, underscores, and hyphens.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<%= form.label :name, "Display Name (Optional)", class: "block text-sm font-medium text-gray-700" %>
|
<%= form.label :name, "Display Name (Optional)", class: "block text-sm font-medium text-gray-700" %>
|
||||||
<%= form.text_field :name, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "John Smith" %>
|
<%= form.text_field :name, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "John Smith" %>
|
||||||
<p class="mt-1 text-sm text-gray-500">Optional: Name shown in applications. Defaults to email address if not set.</p>
|
<p class="mt-1 text-sm text-gray-500">Optional: Full name shown in applications. Defaults to email address if not set.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -53,9 +42,43 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<%= form.check_box :totp_required, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %>
|
||||||
|
<%= form.label :totp_required, "Require Two-Factor Authentication", class: "ml-2 block text-sm text-gray-900" %>
|
||||||
|
<% if user.totp_required? && !user.totp_enabled? %>
|
||||||
|
<span class="ml-2 text-xs text-amber-600">(User has not set up 2FA yet)</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% if user.totp_required? && !user.totp_enabled? %>
|
||||||
|
<p class="mt-1 text-sm text-amber-600">
|
||||||
|
<svg class="inline h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
Warning: This user will be prompted to set up 2FA on their next login.
|
||||||
|
</p>
|
||||||
|
<% end %>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">When enabled, this user must use two-factor authentication to sign in.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div data-controller="json-validator" data-json-validator-valid-class="border-green-500 focus:border-green-500 focus:ring-green-500" data-json-validator-invalid-class="border-red-500 focus:border-red-500 focus:ring-red-500" data-json-validator-valid-status-class="text-green-600" data-json-validator-invalid-status-class="text-red-600">
|
||||||
<%= form.label :custom_claims, "Custom Claims (JSON)", class: "block text-sm font-medium text-gray-700" %>
|
<%= form.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">
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
<div class="max-w-2xl">
|
<div class="max-w-4xl">
|
||||||
<h1 class="text-2xl font-semibold text-gray-900 mb-6">Edit User</h1>
|
<h1 class="text-2xl font-semibold text-gray-900 mb-6">Edit User</h1>
|
||||||
<p class="text-sm text-gray-600 mb-6">Editing: <%= @user.email_address %></p>
|
<p class="text-sm text-gray-600 mb-6">Editing: <%= @user.email_address %></p>
|
||||||
|
|
||||||
|
<div class="max-w-2xl">
|
||||||
<%= render "form", user: @user %>
|
<%= render "form", user: @user %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if @user.persisted? %>
|
||||||
|
<%= render "application_claims", user: @user, applications: @applications %>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -85,15 +85,20 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</td>
|
</td>
|
||||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
<% if user.totp_enabled? %>
|
<% if user.totp_enabled? %>
|
||||||
<svg class="h-5 w-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="h-5 w-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" title="2FA Enabled">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
<% else %>
|
<% else %>
|
||||||
<svg class="h-5 w-5 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="h-5 w-5 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24" title="2FA Not Enabled">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
<% if user.totp_required? %>
|
||||||
|
<span class="inline-flex items-center rounded-full bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700" title="2FA Required by Admin">Required</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||||
<%= user.groups.count %>
|
<%= user.groups.count %>
|
||||||
|
|||||||
@@ -102,11 +102,22 @@
|
|||||||
<% @applications.each do |app| %>
|
<% @applications.each do |app| %>
|
||||||
<div class="bg-white rounded-lg border border-gray-200 shadow-sm hover:shadow-md transition">
|
<div class="bg-white rounded-lg border border-gray-200 shadow-sm hover:shadow-md transition">
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<div class="flex items-center justify-between mb-3">
|
<div class="flex items-start gap-3 mb-4">
|
||||||
|
<% if app.icon.attached? %>
|
||||||
|
<%= image_tag app.icon, class: "h-12 w-12 rounded-lg object-cover border border-gray-200 shrink-0", alt: "#{app.name} icon" %>
|
||||||
|
<% else %>
|
||||||
|
<div class="h-12 w-12 rounded-lg bg-gray-100 border border-gray-200 flex items-center justify-center shrink-0">
|
||||||
|
<svg class="h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
<h3 class="text-lg font-semibold text-gray-900 truncate">
|
<h3 class="text-lg font-semibold text-gray-900 truncate">
|
||||||
<%= app.name %>
|
<%= app.name %>
|
||||||
</h3>
|
</h3>
|
||||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
|
<span class="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium shrink-0
|
||||||
<% if app.oidc? %>
|
<% if app.oidc? %>
|
||||||
bg-blue-100 text-blue-800
|
bg-blue-100 text-blue-800
|
||||||
<% else %>
|
<% else %>
|
||||||
@@ -115,15 +126,15 @@
|
|||||||
<%= app.app_type.humanize %>
|
<%= app.app_type.humanize %>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<% if app.description.present? %>
|
||||||
<p class="text-sm text-gray-600 mb-4">
|
<p class="text-sm text-gray-600 mt-1 line-clamp-2">
|
||||||
<% if app.oidc? %>
|
<%= app.description %>
|
||||||
OIDC Application
|
|
||||||
<% else %>
|
|
||||||
ForwardAuth Protected Application
|
|
||||||
<% end %>
|
|
||||||
</p>
|
</p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
<% if app.landing_url.present? %>
|
<% if app.landing_url.present? %>
|
||||||
<%= link_to "Open Application", app.landing_url,
|
<%= link_to "Open Application", app.landing_url,
|
||||||
target: "_blank",
|
target: "_blank",
|
||||||
@@ -134,6 +145,13 @@
|
|||||||
No landing URL configured
|
No landing URL configured
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
|
<% if app.user_has_active_session?(@user) %>
|
||||||
|
<%= button_to "Logout", logout_from_app_active_sessions_path(application_id: app.id), method: :delete,
|
||||||
|
class: "w-full flex justify-center items-center px-4 py-2 border border-orange-300 text-sm font-medium rounded-md text-orange-700 bg-white hover:bg-orange-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-orange-500 transition",
|
||||||
|
form: { data: { turbo_confirm: "This will log you out of #{app.name}. You can sign back in without re-authorizing. Continue?" } } %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
<div class="mx-auto max-w-md">
|
<div class="mx-auto max-w-md">
|
||||||
<div class="bg-white py-8 px-6 shadow rounded-lg sm:px-10">
|
<div class="bg-white py-8 px-6 shadow rounded-lg sm:px-10">
|
||||||
<div class="mb-8">
|
<div class="mb-8 text-center">
|
||||||
|
<% if @application.icon.attached? %>
|
||||||
|
<%= image_tag @application.icon, class: "mx-auto h-20 w-20 rounded-xl object-cover border-2 border-gray-200 shadow-sm mb-4", alt: "#{@application.name} icon" %>
|
||||||
|
<% else %>
|
||||||
|
<div class="mx-auto h-20 w-20 rounded-xl bg-gray-100 border-2 border-gray-200 flex items-center justify-center mb-4">
|
||||||
|
<svg class="h-10 w-10 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
<h2 class="text-2xl font-bold text-gray-900">Authorize Application</h2>
|
<h2 class="text-2xl font-bold text-gray-900">Authorize Application</h2>
|
||||||
<p class="mt-2 text-sm text-gray-600">
|
<p class="mt-2 text-sm text-gray-600">
|
||||||
<strong><%= @application.name %></strong> is requesting access to your account.
|
<strong><%= @application.name %></strong> is requesting access to your account.
|
||||||
@@ -57,7 +66,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>
|
||||||
|
|||||||
@@ -98,9 +98,37 @@
|
|||||||
<p class="text-sm font-medium text-green-800">
|
<p class="text-sm font-medium text-green-800">
|
||||||
Two-factor authentication is enabled
|
Two-factor authentication is enabled
|
||||||
</p>
|
</p>
|
||||||
|
<% if @user.totp_required? %>
|
||||||
|
<p class="mt-1 text-sm text-green-700">
|
||||||
|
<svg class="inline h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
Required by administrator
|
||||||
|
</p>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<% if @user.totp_required? %>
|
||||||
|
<div class="mt-4 rounded-md bg-blue-50 p-4">
|
||||||
|
<div class="flex">
|
||||||
|
<svg class="h-5 w-5 text-blue-400 mr-2 flex-shrink-0" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
<p class="text-sm text-blue-800">
|
||||||
|
Your administrator requires two-factor authentication. You cannot disable it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 flex gap-3">
|
||||||
|
<button type="button"
|
||||||
|
data-action="click->modal#show"
|
||||||
|
data-modal-id="view-backup-codes-modal"
|
||||||
|
class="inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
|
||||||
|
View Backup Codes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
<div class="mt-4 flex gap-3">
|
<div class="mt-4 flex gap-3">
|
||||||
<button type="button"
|
<button type="button"
|
||||||
data-action="click->modal#show"
|
data-action="click->modal#show"
|
||||||
@@ -115,6 +143,7 @@
|
|||||||
View Backup Codes
|
View Backup Codes
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<% end %>
|
||||||
<% else %>
|
<% else %>
|
||||||
<%= link_to new_totp_path, class: "inline-flex items-center rounded-md border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" do %>
|
<%= link_to new_totp_path, class: "inline-flex items-center rounded-md border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" do %>
|
||||||
Enable 2FA
|
Enable 2FA
|
||||||
|
|||||||
@@ -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 %>
|
||||||
@@ -45,8 +45,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-8">
|
<div class="mt-8">
|
||||||
|
<% if @auto_signin_pending %>
|
||||||
|
<%= button_to "Continue to Sign In", complete_totp_setup_path, method: :post,
|
||||||
|
class: "inline-flex justify-center rounded-md border border-transparent bg-blue-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" %>
|
||||||
|
<% else %>
|
||||||
<%= link_to "Done", profile_path,
|
<%= link_to "Done", profile_path,
|
||||||
class: "inline-flex justify-center rounded-md border border-transparent bg-blue-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" %>
|
class: "inline-flex justify-center rounded-md border border-transparent bg-blue-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" %>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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" %>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -53,5 +53,5 @@ Rails.application.configure do
|
|||||||
|
|
||||||
# Disable Sentry in test environment to avoid interference with tests
|
# Disable Sentry in test environment to avoid interference with tests
|
||||||
# Sentry can be explicitly enabled for integration testing if needed
|
# Sentry can be explicitly enabled for integration testing if needed
|
||||||
config.sentry.enabled = false
|
ENV["SENTRY_ENABLED_IN_DEVELOPMENT"] = "false"
|
||||||
end
|
end
|
||||||
|
|||||||
14
config/initializers/active_storage.rb
Normal file
14
config/initializers/active_storage.rb
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# Configure ActiveStorage content type resolution
|
||||||
|
Rails.application.config.after_initialize do
|
||||||
|
# Ensure SVG files are served with the correct content type
|
||||||
|
ActiveStorage::Blob.class_eval do
|
||||||
|
def content_type_for_serving
|
||||||
|
# Override content type for SVG files
|
||||||
|
if filename.extension == "svg" && content_type == "application/octet-stream"
|
||||||
|
"image/svg+xml"
|
||||||
|
else
|
||||||
|
content_type
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -39,6 +39,7 @@ Rails.application.configure do
|
|||||||
policy.base_uri :self
|
policy.base_uri :self
|
||||||
|
|
||||||
# Form actions: Allow self for all form submissions
|
# Form actions: Allow self for all form submissions
|
||||||
|
# Note: OAuth redirects will be handled dynamically in the consent page
|
||||||
policy.form_action :self
|
policy.form_action :self
|
||||||
|
|
||||||
# Manifest sources: Allow self for PWA manifest
|
# Manifest sources: Allow self for PWA manifest
|
||||||
@@ -53,9 +54,12 @@ Rails.application.configure do
|
|||||||
# Additional security headers for WebAuthn
|
# Additional security headers for WebAuthn
|
||||||
# Required for WebAuthn to work properly
|
# Required for WebAuthn to work properly
|
||||||
policy.require_trusted_types_for :none
|
policy.require_trusted_types_for :none
|
||||||
|
|
||||||
|
# CSP reporting using report_uri (supported method)
|
||||||
policy.report_uri "/api/csp-violation-report"
|
policy.report_uri "/api/csp-violation-report"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
||||||
# Start with CSP in report-only mode for testing
|
# Start with CSP in report-only mode for testing
|
||||||
# Set to false after verifying everything works in production
|
# Set to false after verifying everything works in production
|
||||||
config.content_security_policy_report_only = Rails.env.development?
|
config.content_security_policy_report_only = Rails.env.development?
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ Rails.application.config.after_initialize do
|
|||||||
def self.emit(event)
|
def self.emit(event)
|
||||||
csp_data = event[:payload] || {}
|
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
|
# Build a structured log message
|
||||||
violated_directive = csp_data[:violated_directive] || "unknown"
|
violated_directive = csp_data[:violated_directive] || "unknown"
|
||||||
blocked_uri = csp_data[:blocked_uri] || "unknown"
|
blocked_uri = csp_data[:blocked_uri] || "unknown"
|
||||||
@@ -59,7 +65,7 @@ Rails.application.config.after_initialize do
|
|||||||
# Determine severity for log level
|
# Determine severity for log level
|
||||||
level = determine_log_level(csp_data[:violated_directive])
|
level = determine_log_level(csp_data[:violated_directive])
|
||||||
|
|
||||||
csp_logger.log(level, log_message)
|
self.csp_logger.log(level, log_message)
|
||||||
|
|
||||||
# Also log to main Rails logger for visibility
|
# Also log to main Rails logger for visibility
|
||||||
Rails.logger.info "CSP violation logged to csp_violations.log: #{violated_directive} - #{blocked_uri}"
|
Rails.logger.info "CSP violation logged to csp_violations.log: #{violated_directive} - #{blocked_uri}"
|
||||||
@@ -70,6 +76,22 @@ Rails.application.config.after_initialize do
|
|||||||
Rails.logger.error e.backtrace.join("\n") if Rails.env.development?
|
Rails.logger.error e.backtrace.join("\n") if Rails.env.development?
|
||||||
end
|
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
|
private
|
||||||
|
|
||||||
def self.determine_log_level(violated_directive)
|
def self.determine_log_level(violated_directive)
|
||||||
|
|||||||
5
config/initializers/version.rb
Normal file
5
config/initializers/version.rb
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Clinch
|
||||||
|
VERSION = "0.6.3"
|
||||||
|
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
|
||||||
|
if ENV["CLINCH_RP_ID"].present?
|
||||||
|
config.rp_id = ENV["CLINCH_RP_ID"]
|
||||||
|
else
|
||||||
|
# Extract registrable domain from CLINCH_HOST using PublicSuffix
|
||||||
origin_uri = URI.parse(origin_host)
|
origin_uri = URI.parse(origin_host)
|
||||||
config.rp_id = ENV.fetch("CLINCH_RP_ID", "localhost")
|
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?
|
||||||
|
|||||||
17
config/recurring.yml
Normal file
17
config/recurring.yml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Solid Queue Recurring Jobs Configuration
|
||||||
|
# This file defines scheduled/cron-like jobs that run periodically
|
||||||
|
|
||||||
|
production:
|
||||||
|
oidc_token_cleanup:
|
||||||
|
class: OidcTokenCleanupJob
|
||||||
|
schedule: "0 3 * * *" # Run daily at 3:00 AM
|
||||||
|
queue: default
|
||||||
|
|
||||||
|
development:
|
||||||
|
oidc_token_cleanup:
|
||||||
|
class: OidcTokenCleanupJob
|
||||||
|
schedule: "0 3 * * *" # Run daily at 3:00 AM
|
||||||
|
queue: default
|
||||||
|
|
||||||
|
test:
|
||||||
|
# No recurring jobs in test environment
|
||||||
@@ -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"
|
||||||
|
|
||||||
@@ -48,6 +49,7 @@ Rails.application.routes.draw do
|
|||||||
end
|
end
|
||||||
resource :active_sessions, only: [:show] do
|
resource :active_sessions, only: [:show] do
|
||||||
member do
|
member do
|
||||||
|
delete :logout_from_app
|
||||||
delete :revoke_consent
|
delete :revoke_consent
|
||||||
delete :revoke_all_consents
|
delete :revoke_all_consents
|
||||||
end
|
end
|
||||||
@@ -66,6 +68,7 @@ Rails.application.routes.draw do
|
|||||||
post '/totp/verify_password', to: 'totp#verify_password', as: :verify_password_totp
|
post '/totp/verify_password', to: 'totp#verify_password', as: :verify_password_totp
|
||||||
get '/totp/regenerate_backup_codes', to: 'totp#regenerate_backup_codes', as: :regenerate_backup_codes_totp
|
get '/totp/regenerate_backup_codes', to: 'totp#regenerate_backup_codes', as: :regenerate_backup_codes_totp
|
||||||
post '/totp/regenerate_backup_codes', to: 'totp#create_new_backup_codes', as: :create_new_backup_codes_totp
|
post '/totp/regenerate_backup_codes', to: 'totp#create_new_backup_codes', as: :create_new_backup_codes_totp
|
||||||
|
post '/totp/complete_setup', to: 'totp#complete_setup', as: :complete_totp_setup
|
||||||
|
|
||||||
# WebAuthn (Passkeys) routes
|
# WebAuthn (Passkeys) routes
|
||||||
get '/webauthn/new', to: 'webauthn#new', as: :new_webauthn
|
get '/webauthn/new', to: 'webauthn#new', as: :new_webauthn
|
||||||
@@ -80,6 +83,8 @@ Rails.application.routes.draw do
|
|||||||
resources :users do
|
resources :users do
|
||||||
member do
|
member do
|
||||||
post :resend_invitation
|
post :resend_invitation
|
||||||
|
post :update_application_claims
|
||||||
|
delete :delete_application_claims
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
resources :applications do
|
resources :applications do
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ test:
|
|||||||
|
|
||||||
local:
|
local:
|
||||||
service: Disk
|
service: Disk
|
||||||
root: <%= Rails.root.join("storage") %>
|
root: <%= Rails.root.join("storage/uploads") %>
|
||||||
|
|
||||||
# Use bin/rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key)
|
# Use bin/rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key)
|
||||||
# amazon:
|
# amazon:
|
||||||
|
|||||||
@@ -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
|
||||||
15
db/migrate/20251122235519_add_sid_to_oidc_user_consent.rb
Normal file
15
db/migrate/20251122235519_add_sid_to_oidc_user_consent.rb
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
class AddSidToOidcUserConsent < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
add_column :oidc_user_consents, :sid, :string
|
||||||
|
add_index :oidc_user_consents, :sid
|
||||||
|
|
||||||
|
# Generate UUIDs for existing consent records
|
||||||
|
reversible do |dir|
|
||||||
|
dir.up do
|
||||||
|
OidcUserConsent.where(sid: nil).find_each do |consent|
|
||||||
|
consent.update_column(:sid, SecureRandom.uuid)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
13
db/migrate/20251123052026_create_application_user_claims.rb
Normal file
13
db/migrate/20251123052026_create_application_user_claims.rb
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
class CreateApplicationUserClaims < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
create_table :application_user_claims do |t|
|
||||||
|
t.references :application, null: false, foreign_key: { on_delete: :cascade }
|
||||||
|
t.references :user, null: false, foreign_key: { on_delete: :cascade }
|
||||||
|
t.json :custom_claims, default: {}, null: false
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
|
||||||
|
add_index :application_user_claims, [:application_id, :user_id], unique: true, name: 'index_app_user_claims_unique'
|
||||||
|
end
|
||||||
|
end
|
||||||
6
db/migrate/20251125012446_add_username_to_users.rb
Normal file
6
db/migrate/20251125012446_add_username_to_users.rb
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
class AddUsernameToUsers < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
add_column :users, :username, :string
|
||||||
|
add_index :users, :username, unique: true
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# This migration comes from active_storage (originally 20170806125915)
|
||||||
|
class CreateActiveStorageTables < ActiveRecord::Migration[7.0]
|
||||||
|
def change
|
||||||
|
# Use Active Record's configured type for primary and foreign keys
|
||||||
|
primary_key_type, foreign_key_type = primary_and_foreign_key_types
|
||||||
|
|
||||||
|
create_table :active_storage_blobs, id: primary_key_type do |t|
|
||||||
|
t.string :key, null: false
|
||||||
|
t.string :filename, null: false
|
||||||
|
t.string :content_type
|
||||||
|
t.text :metadata
|
||||||
|
t.string :service_name, null: false
|
||||||
|
t.bigint :byte_size, null: false
|
||||||
|
t.string :checksum
|
||||||
|
|
||||||
|
if connection.supports_datetime_with_precision?
|
||||||
|
t.datetime :created_at, precision: 6, null: false
|
||||||
|
else
|
||||||
|
t.datetime :created_at, null: false
|
||||||
|
end
|
||||||
|
|
||||||
|
t.index [ :key ], unique: true
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table :active_storage_attachments, id: primary_key_type do |t|
|
||||||
|
t.string :name, null: false
|
||||||
|
t.references :record, null: false, polymorphic: true, index: false, type: foreign_key_type
|
||||||
|
t.references :blob, null: false, type: foreign_key_type
|
||||||
|
|
||||||
|
if connection.supports_datetime_with_precision?
|
||||||
|
t.datetime :created_at, precision: 6, null: false
|
||||||
|
else
|
||||||
|
t.datetime :created_at, null: false
|
||||||
|
end
|
||||||
|
|
||||||
|
t.index [ :record_type, :record_id, :name, :blob_id ], name: :index_active_storage_attachments_uniqueness, unique: true
|
||||||
|
t.foreign_key :active_storage_blobs, column: :blob_id
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table :active_storage_variant_records, id: primary_key_type do |t|
|
||||||
|
t.belongs_to :blob, null: false, index: false, type: foreign_key_type
|
||||||
|
t.string :variation_digest, null: false
|
||||||
|
|
||||||
|
t.index [ :blob_id, :variation_digest ], name: :index_active_storage_variant_records_uniqueness, unique: true
|
||||||
|
t.foreign_key :active_storage_blobs, column: :blob_id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def primary_and_foreign_key_types
|
||||||
|
config = Rails.configuration.generators
|
||||||
|
setting = config.options[config.orm][:primary_key_type]
|
||||||
|
primary_key_type = setting || :primary_key
|
||||||
|
foreign_key_type = setting || :bigint
|
||||||
|
[ primary_key_type, foreign_key_type ]
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
class AddBackchannelLogoutUriToApplications < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
add_column :applications, :backchannel_logout_uri, :string
|
||||||
|
end
|
||||||
|
end
|
||||||
86
db/schema.rb
generated
86
db/schema.rb
generated
@@ -10,7 +10,35 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[8.1].define(version: 2025_11_04_064114) do
|
ActiveRecord::Schema[8.1].define(version: 2025_11_25_081147) do
|
||||||
|
create_table "active_storage_attachments", force: :cascade do |t|
|
||||||
|
t.bigint "blob_id", null: false
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.string "name", null: false
|
||||||
|
t.bigint "record_id", null: false
|
||||||
|
t.string "record_type", null: false
|
||||||
|
t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id"
|
||||||
|
t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table "active_storage_blobs", force: :cascade do |t|
|
||||||
|
t.bigint "byte_size", null: false
|
||||||
|
t.string "checksum"
|
||||||
|
t.string "content_type"
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.string "filename", null: false
|
||||||
|
t.string "key", null: false
|
||||||
|
t.text "metadata"
|
||||||
|
t.string "service_name", null: false
|
||||||
|
t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table "active_storage_variant_records", force: :cascade do |t|
|
||||||
|
t.bigint "blob_id", null: false
|
||||||
|
t.string "variation_digest", null: false
|
||||||
|
t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true
|
||||||
|
end
|
||||||
|
|
||||||
create_table "application_groups", force: :cascade do |t|
|
create_table "application_groups", force: :cascade do |t|
|
||||||
t.integer "application_id", null: false
|
t.integer "application_id", null: false
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
@@ -21,19 +49,34 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_04_064114) do
|
|||||||
t.index ["group_id"], name: "index_application_groups_on_group_id"
|
t.index ["group_id"], name: "index_application_groups_on_group_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "application_user_claims", force: :cascade do |t|
|
||||||
|
t.integer "application_id", null: false
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.json "custom_claims", default: {}, null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.integer "user_id", null: false
|
||||||
|
t.index ["application_id", "user_id"], name: "index_app_user_claims_unique", unique: true
|
||||||
|
t.index ["application_id"], name: "index_application_user_claims_on_application_id"
|
||||||
|
t.index ["user_id"], name: "index_application_user_claims_on_user_id"
|
||||||
|
end
|
||||||
|
|
||||||
create_table "applications", force: :cascade do |t|
|
create_table "applications", force: :cascade do |t|
|
||||||
|
t.integer "access_token_ttl", default: 3600
|
||||||
t.boolean "active", default: true, null: false
|
t.boolean "active", default: true, null: false
|
||||||
t.string "app_type", null: false
|
t.string "app_type", null: false
|
||||||
|
t.string "backchannel_logout_uri"
|
||||||
t.string "client_id"
|
t.string "client_id"
|
||||||
t.string "client_secret_digest"
|
t.string "client_secret_digest"
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
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 +98,26 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_04_064114) 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,19 +129,43 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_04_064114) 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
|
||||||
t.datetime "granted_at", null: false
|
t.datetime "granted_at", null: false
|
||||||
t.text "scopes_granted", null: false
|
t.text "scopes_granted", null: false
|
||||||
|
t.string "sid"
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
t.integer "user_id", null: false
|
t.integer "user_id", null: false
|
||||||
t.index ["application_id"], name: "index_oidc_user_consents_on_application_id"
|
t.index ["application_id"], name: "index_oidc_user_consents_on_application_id"
|
||||||
t.index ["granted_at"], name: "index_oidc_user_consents_on_granted_at"
|
t.index ["granted_at"], name: "index_oidc_user_consents_on_granted_at"
|
||||||
|
t.index ["sid"], name: "index_oidc_user_consents_on_sid"
|
||||||
t.index ["user_id", "application_id"], name: "index_oidc_user_consents_on_user_id_and_application_id", unique: true
|
t.index ["user_id", "application_id"], name: "index_oidc_user_consents_on_user_id_and_application_id", unique: true
|
||||||
t.index ["user_id"], name: "index_oidc_user_consents_on_user_id"
|
t.index ["user_id"], name: "index_oidc_user_consents_on_user_id"
|
||||||
end
|
end
|
||||||
@@ -136,10 +209,12 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_04_064114) do
|
|||||||
t.boolean "totp_required", default: false, null: false
|
t.boolean "totp_required", default: false, null: false
|
||||||
t.string "totp_secret"
|
t.string "totp_secret"
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
|
t.string "username"
|
||||||
t.string "webauthn_id"
|
t.string "webauthn_id"
|
||||||
t.boolean "webauthn_required", default: false, null: false
|
t.boolean "webauthn_required", default: false, null: false
|
||||||
t.index ["email_address"], name: "index_users_on_email_address", unique: true
|
t.index ["email_address"], name: "index_users_on_email_address", unique: true
|
||||||
t.index ["status"], name: "index_users_on_status"
|
t.index ["status"], name: "index_users_on_status"
|
||||||
|
t.index ["username"], name: "index_users_on_username", unique: true
|
||||||
t.index ["webauthn_id"], name: "index_users_on_webauthn_id", unique: true
|
t.index ["webauthn_id"], name: "index_users_on_webauthn_id", unique: true
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -165,12 +240,19 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_04_064114) do
|
|||||||
t.index ["user_id"], name: "index_webauthn_credentials_on_user_id"
|
t.index ["user_id"], name: "index_webauthn_credentials_on_user_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
|
||||||
|
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
|
||||||
add_foreign_key "application_groups", "applications"
|
add_foreign_key "application_groups", "applications"
|
||||||
add_foreign_key "application_groups", "groups"
|
add_foreign_key "application_groups", "groups"
|
||||||
|
add_foreign_key "application_user_claims", "applications", on_delete: :cascade
|
||||||
|
add_foreign_key "application_user_claims", "users", on_delete: :cascade
|
||||||
add_foreign_key "oidc_access_tokens", "applications"
|
add_foreign_key "oidc_access_tokens", "applications"
|
||||||
add_foreign_key "oidc_access_tokens", "users"
|
add_foreign_key "oidc_access_tokens", "users"
|
||||||
add_foreign_key "oidc_authorization_codes", "applications"
|
add_foreign_key "oidc_authorization_codes", "applications"
|
||||||
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"
|
||||||
|
|||||||
441
test/controllers/oidc_authorization_code_security_test.rb
Normal file
441
test/controllers/oidc_authorization_code_security_test.rb
Normal file
@@ -0,0 +1,441 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
||||||
|
def setup
|
||||||
|
@user = User.create!(email_address: "security_test@example.com", password: "password123")
|
||||||
|
@application = Application.create!(
|
||||||
|
name: "Security Test App",
|
||||||
|
slug: "security-test-app",
|
||||||
|
app_type: "oidc",
|
||||||
|
redirect_uris: ["http://localhost:4000/callback"].to_json,
|
||||||
|
active: true
|
||||||
|
)
|
||||||
|
|
||||||
|
# Store the plain text client secret for testing
|
||||||
|
@client_secret = @application.client_secret_digest
|
||||||
|
@application.generate_new_client_secret!
|
||||||
|
@plain_client_secret = @application.client_secret
|
||||||
|
@application.save!
|
||||||
|
end
|
||||||
|
|
||||||
|
def teardown
|
||||||
|
OidcAuthorizationCode.where(application: @application).delete_all
|
||||||
|
# Use delete_all to avoid triggering callbacks that might have issues with the schema
|
||||||
|
OidcAccessToken.where(application: @application).delete_all
|
||||||
|
@user.destroy
|
||||||
|
@application.destroy
|
||||||
|
end
|
||||||
|
|
||||||
|
# ====================
|
||||||
|
# CRITICAL SECURITY TESTS
|
||||||
|
# ====================
|
||||||
|
|
||||||
|
test "prevents authorization code reuse - sequential attempts" do
|
||||||
|
# Create a valid authorization code
|
||||||
|
auth_code = OidcAuthorizationCode.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
code: SecureRandom.urlsafe_base64(32),
|
||||||
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
|
scope: "openid profile",
|
||||||
|
expires_at: 10.minutes.from_now
|
||||||
|
)
|
||||||
|
|
||||||
|
token_params = {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: auth_code.code,
|
||||||
|
redirect_uri: "http://localhost:4000/callback"
|
||||||
|
}
|
||||||
|
|
||||||
|
# First request should succeed
|
||||||
|
post "/oauth/token", params: token_params, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
first_response = JSON.parse(@response.body)
|
||||||
|
assert first_response.key?("access_token")
|
||||||
|
assert first_response.key?("id_token")
|
||||||
|
|
||||||
|
# Second request with same code should fail
|
||||||
|
post "/oauth/token", params: token_params, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :bad_request
|
||||||
|
error = JSON.parse(@response.body)
|
||||||
|
assert_equal "invalid_grant", error["error"]
|
||||||
|
assert_match(/already been used/, error["error_description"])
|
||||||
|
end
|
||||||
|
|
||||||
|
test "revokes existing tokens when authorization code is reused" do
|
||||||
|
# Create a valid authorization code
|
||||||
|
auth_code = OidcAuthorizationCode.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
code: SecureRandom.urlsafe_base64(32),
|
||||||
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
|
scope: "openid profile",
|
||||||
|
expires_at: 10.minutes.from_now
|
||||||
|
)
|
||||||
|
|
||||||
|
token_params = {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: auth_code.code,
|
||||||
|
redirect_uri: "http://localhost:4000/callback"
|
||||||
|
}
|
||||||
|
|
||||||
|
# First request - get access token
|
||||||
|
post "/oauth/token", params: token_params, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
first_response = JSON.parse(@response.body)
|
||||||
|
first_access_token = first_response["access_token"]
|
||||||
|
|
||||||
|
# Verify the token works
|
||||||
|
get "/oauth/userinfo", headers: {
|
||||||
|
"Authorization" => "Bearer #{first_access_token}"
|
||||||
|
}
|
||||||
|
assert_response :success
|
||||||
|
|
||||||
|
# Second request with same code - should fail AND revoke first token
|
||||||
|
post "/oauth/token", params: token_params, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :bad_request
|
||||||
|
|
||||||
|
# Verify the first token is now revoked (expired)
|
||||||
|
get "/oauth/userinfo", headers: {
|
||||||
|
"Authorization" => "Bearer #{first_access_token}"
|
||||||
|
}
|
||||||
|
assert_response :unauthorized, "First access token should be revoked after code reuse"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects already used authorization code" do
|
||||||
|
# Create and mark code as used
|
||||||
|
auth_code = OidcAuthorizationCode.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
code: SecureRandom.urlsafe_base64(32),
|
||||||
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
|
scope: "openid profile",
|
||||||
|
used: true,
|
||||||
|
expires_at: 10.minutes.from_now
|
||||||
|
)
|
||||||
|
|
||||||
|
token_params = {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: auth_code.code,
|
||||||
|
redirect_uri: "http://localhost:4000/callback"
|
||||||
|
}
|
||||||
|
|
||||||
|
post "/oauth/token", params: token_params, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :bad_request
|
||||||
|
error = JSON.parse(@response.body)
|
||||||
|
assert_equal "invalid_grant", error["error"]
|
||||||
|
assert_match(/already been used/, error["error_description"])
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects expired authorization code" do
|
||||||
|
# Create expired code
|
||||||
|
auth_code = OidcAuthorizationCode.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
code: SecureRandom.urlsafe_base64(32),
|
||||||
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
|
scope: "openid profile",
|
||||||
|
expires_at: 5.minutes.ago
|
||||||
|
)
|
||||||
|
|
||||||
|
token_params = {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: auth_code.code,
|
||||||
|
redirect_uri: "http://localhost:4000/callback"
|
||||||
|
}
|
||||||
|
|
||||||
|
post "/oauth/token", params: token_params, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :bad_request
|
||||||
|
error = JSON.parse(@response.body)
|
||||||
|
assert_equal "invalid_grant", error["error"]
|
||||||
|
assert_match(/expired/, error["error_description"])
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects authorization code with mismatched redirect_uri" do
|
||||||
|
auth_code = OidcAuthorizationCode.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
code: SecureRandom.urlsafe_base64(32),
|
||||||
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
|
scope: "openid profile",
|
||||||
|
expires_at: 10.minutes.from_now
|
||||||
|
)
|
||||||
|
|
||||||
|
token_params = {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: auth_code.code,
|
||||||
|
redirect_uri: "http://evil.com/callback" # Wrong redirect URI
|
||||||
|
}
|
||||||
|
|
||||||
|
post "/oauth/token", params: token_params, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :bad_request
|
||||||
|
error = JSON.parse(@response.body)
|
||||||
|
assert_equal "invalid_grant", error["error"]
|
||||||
|
assert_match(/Redirect URI mismatch/, error["error_description"])
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects non-existent authorization code" do
|
||||||
|
token_params = {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: "nonexistent_code_12345",
|
||||||
|
redirect_uri: "http://localhost:4000/callback"
|
||||||
|
}
|
||||||
|
|
||||||
|
post "/oauth/token", params: token_params, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :bad_request
|
||||||
|
error = JSON.parse(@response.body)
|
||||||
|
assert_equal "invalid_grant", error["error"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects authorization code for different application" do
|
||||||
|
# Create another application
|
||||||
|
other_app = Application.create!(
|
||||||
|
name: "Other App",
|
||||||
|
slug: "other-app",
|
||||||
|
app_type: "oidc",
|
||||||
|
redirect_uris: ["http://localhost:5000/callback"].to_json,
|
||||||
|
active: true
|
||||||
|
)
|
||||||
|
other_secret = other_app.client_secret
|
||||||
|
|
||||||
|
# Create auth code for first application
|
||||||
|
auth_code = OidcAuthorizationCode.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
code: SecureRandom.urlsafe_base64(32),
|
||||||
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
|
scope: "openid profile",
|
||||||
|
expires_at: 10.minutes.from_now
|
||||||
|
)
|
||||||
|
|
||||||
|
# Try to use it with different application credentials
|
||||||
|
token_params = {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: auth_code.code,
|
||||||
|
redirect_uri: "http://localhost:4000/callback"
|
||||||
|
}
|
||||||
|
|
||||||
|
post "/oauth/token", params: token_params, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{other_app.client_id}:#{other_secret}")
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :bad_request
|
||||||
|
error = JSON.parse(@response.body)
|
||||||
|
assert_equal "invalid_grant", error["error"]
|
||||||
|
|
||||||
|
other_app.destroy
|
||||||
|
end
|
||||||
|
|
||||||
|
# ====================
|
||||||
|
# CLIENT AUTHENTICATION TESTS
|
||||||
|
# ====================
|
||||||
|
|
||||||
|
test "rejects invalid client_id in Basic auth" do
|
||||||
|
auth_code = OidcAuthorizationCode.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
code: SecureRandom.urlsafe_base64(32),
|
||||||
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
|
scope: "openid profile",
|
||||||
|
expires_at: 10.minutes.from_now
|
||||||
|
)
|
||||||
|
|
||||||
|
token_params = {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: auth_code.code,
|
||||||
|
redirect_uri: "http://localhost:4000/callback"
|
||||||
|
}
|
||||||
|
|
||||||
|
post "/oauth/token", params: token_params, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("invalid_client_id:#{@plain_client_secret}")
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :unauthorized
|
||||||
|
error = JSON.parse(@response.body)
|
||||||
|
assert_equal "invalid_client", error["error"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects invalid client_secret in Basic auth" do
|
||||||
|
auth_code = OidcAuthorizationCode.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
code: SecureRandom.urlsafe_base64(32),
|
||||||
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
|
scope: "openid profile",
|
||||||
|
expires_at: 10.minutes.from_now
|
||||||
|
)
|
||||||
|
|
||||||
|
token_params = {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: auth_code.code,
|
||||||
|
redirect_uri: "http://localhost:4000/callback"
|
||||||
|
}
|
||||||
|
|
||||||
|
post "/oauth/token", params: token_params, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:wrong_secret")
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :unauthorized
|
||||||
|
error = JSON.parse(@response.body)
|
||||||
|
assert_equal "invalid_client", error["error"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "accepts client credentials in POST body" do
|
||||||
|
auth_code = OidcAuthorizationCode.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
code: SecureRandom.urlsafe_base64(32),
|
||||||
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
|
scope: "openid profile",
|
||||||
|
expires_at: 10.minutes.from_now
|
||||||
|
)
|
||||||
|
|
||||||
|
token_params = {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: auth_code.code,
|
||||||
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
|
client_id: @application.client_id,
|
||||||
|
client_secret: @plain_client_secret
|
||||||
|
}
|
||||||
|
|
||||||
|
post "/oauth/token", params: token_params
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
response_body = JSON.parse(@response.body)
|
||||||
|
assert response_body.key?("access_token")
|
||||||
|
assert response_body.key?("id_token")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects request with no client authentication" do
|
||||||
|
auth_code = OidcAuthorizationCode.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
code: SecureRandom.urlsafe_base64(32),
|
||||||
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
|
scope: "openid profile",
|
||||||
|
expires_at: 10.minutes.from_now
|
||||||
|
)
|
||||||
|
|
||||||
|
token_params = {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: auth_code.code,
|
||||||
|
redirect_uri: "http://localhost:4000/callback"
|
||||||
|
}
|
||||||
|
|
||||||
|
post "/oauth/token", params: token_params
|
||||||
|
|
||||||
|
assert_response :unauthorized
|
||||||
|
error = JSON.parse(@response.body)
|
||||||
|
assert_equal "invalid_client", error["error"]
|
||||||
|
end
|
||||||
|
|
||||||
|
# ====================
|
||||||
|
# GRANT TYPE VALIDATION
|
||||||
|
# ====================
|
||||||
|
|
||||||
|
test "rejects unsupported grant_type" do
|
||||||
|
post "/oauth/token", params: {
|
||||||
|
grant_type: "password",
|
||||||
|
username: "user",
|
||||||
|
password: "pass"
|
||||||
|
}, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :bad_request
|
||||||
|
error = JSON.parse(@response.body)
|
||||||
|
assert_equal "unsupported_grant_type", error["error"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects missing grant_type" do
|
||||||
|
post "/oauth/token", params: {
|
||||||
|
code: "some_code",
|
||||||
|
redirect_uri: "http://localhost:4000/callback"
|
||||||
|
}, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :bad_request
|
||||||
|
error = JSON.parse(@response.body)
|
||||||
|
assert_equal "unsupported_grant_type", error["error"]
|
||||||
|
end
|
||||||
|
|
||||||
|
# ====================
|
||||||
|
# TIMING ATTACK PROTECTION
|
||||||
|
# ====================
|
||||||
|
|
||||||
|
test "client authentication uses constant-time comparison" do
|
||||||
|
auth_code = OidcAuthorizationCode.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
code: SecureRandom.urlsafe_base64(32),
|
||||||
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
|
scope: "openid profile",
|
||||||
|
expires_at: 10.minutes.from_now
|
||||||
|
)
|
||||||
|
|
||||||
|
token_params = {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: auth_code.code,
|
||||||
|
redirect_uri: "http://localhost:4000/callback"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test with completely wrong secret
|
||||||
|
times_wrong = []
|
||||||
|
5.times do
|
||||||
|
start_time = Time.now.to_f
|
||||||
|
post "/oauth/token", params: token_params, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:wrong_secret_xxx")
|
||||||
|
}
|
||||||
|
times_wrong << (Time.now.to_f - start_time)
|
||||||
|
assert_response :unauthorized
|
||||||
|
end
|
||||||
|
|
||||||
|
# Test with almost correct secret (differs by one character)
|
||||||
|
correct_secret = @plain_client_secret
|
||||||
|
almost_correct = correct_secret[0..-2] + "X"
|
||||||
|
|
||||||
|
times_almost = []
|
||||||
|
5.times do
|
||||||
|
start_time = Time.now.to_f
|
||||||
|
post "/oauth/token", params: token_params, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{almost_correct}")
|
||||||
|
}
|
||||||
|
times_almost << (Time.now.to_f - start_time)
|
||||||
|
assert_response :unauthorized
|
||||||
|
end
|
||||||
|
|
||||||
|
# The timing difference should be minimal (within 50ms) if using constant-time comparison
|
||||||
|
avg_wrong = times_wrong.sum / times_wrong.size
|
||||||
|
avg_almost = times_almost.sum / times_almost.size
|
||||||
|
timing_difference = (avg_wrong - avg_almost).abs
|
||||||
|
|
||||||
|
# This is a best-effort check - in practice, constant-time comparison is handled by bcrypt
|
||||||
|
assert timing_difference < 0.05,
|
||||||
|
"Timing difference #{timing_difference}s suggests potential timing attack vulnerability"
|
||||||
|
end
|
||||||
|
end
|
||||||
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
|
||||||
@@ -11,7 +11,7 @@ class PasswordsControllerTest < ActionDispatch::IntegrationTest
|
|||||||
test "create" do
|
test "create" do
|
||||||
post passwords_path, params: { email_address: @user.email_address }
|
post passwords_path, params: { email_address: @user.email_address }
|
||||||
assert_enqueued_email_with PasswordsMailer, :reset, args: [ @user ]
|
assert_enqueued_email_with PasswordsMailer, :reset, args: [ @user ]
|
||||||
assert_redirected_to new_session_path
|
assert_redirected_to signin_path
|
||||||
|
|
||||||
follow_redirect!
|
follow_redirect!
|
||||||
assert_notice "reset instructions sent"
|
assert_notice "reset instructions sent"
|
||||||
@@ -20,14 +20,14 @@ class PasswordsControllerTest < ActionDispatch::IntegrationTest
|
|||||||
test "create for an unknown user redirects but sends no mail" do
|
test "create for an unknown user redirects but sends no mail" do
|
||||||
post passwords_path, params: { email_address: "missing-user@example.com" }
|
post passwords_path, params: { email_address: "missing-user@example.com" }
|
||||||
assert_enqueued_emails 0
|
assert_enqueued_emails 0
|
||||||
assert_redirected_to new_session_path
|
assert_redirected_to signin_path
|
||||||
|
|
||||||
follow_redirect!
|
follow_redirect!
|
||||||
assert_notice "reset instructions sent"
|
assert_notice "reset instructions sent"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "edit" do
|
test "edit" do
|
||||||
get edit_password_path(@user.password_reset_token)
|
get edit_password_path(@user.generate_token_for(:password_reset))
|
||||||
assert_response :success
|
assert_response :success
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -41,8 +41,8 @@ class PasswordsControllerTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
test "update" do
|
test "update" do
|
||||||
assert_changes -> { @user.reload.password_digest } do
|
assert_changes -> { @user.reload.password_digest } do
|
||||||
put password_path(@user.password_reset_token), params: { password: "new", password_confirmation: "new" }
|
put password_path(@user.generate_token_for(:password_reset)), params: { password: "newpassword", password_confirmation: "newpassword" }
|
||||||
assert_redirected_to new_session_path
|
assert_redirected_to signin_path
|
||||||
end
|
end
|
||||||
|
|
||||||
follow_redirect!
|
follow_redirect!
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest
|
|||||||
test "create with invalid credentials" do
|
test "create with invalid credentials" do
|
||||||
post session_path, params: { email_address: @user.email_address, password: "wrong" }
|
post session_path, params: { email_address: @user.email_address, password: "wrong" }
|
||||||
|
|
||||||
assert_redirected_to new_session_path
|
assert_redirected_to signin_path
|
||||||
assert_nil cookies[:session_id]
|
assert_nil cookies[:session_id]
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
delete session_path
|
delete session_path
|
||||||
|
|
||||||
assert_redirected_to new_session_path
|
assert_redirected_to signin_path
|
||||||
assert_empty cookies[:session_id]
|
assert_empty cookies[:session_id]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
11
test/fixtures/application_user_claims.yml
vendored
Normal file
11
test/fixtures/application_user_claims.yml
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
||||||
|
|
||||||
|
kavita_alice_claims:
|
||||||
|
application: kavita_app
|
||||||
|
user: alice
|
||||||
|
custom_claims: { "kavita_groups": ["admin"], "library_access": "all" }
|
||||||
|
|
||||||
|
abs_alice_claims:
|
||||||
|
application: audiobookshelf_app
|
||||||
|
user: alice
|
||||||
|
custom_claims: { "abs_groups": ["user"], "abs_permissions": { "canDownload": true, "canUpload": false } }
|
||||||
11
test/fixtures/applications.yml
vendored
11
test/fixtures/applications.yml
vendored
@@ -24,3 +24,14 @@ another_app:
|
|||||||
https://app.example.com/auth/callback
|
https://app.example.com/auth/callback
|
||||||
metadata: "{}"
|
metadata: "{}"
|
||||||
active: true
|
active: true
|
||||||
|
|
||||||
|
audiobookshelf_app:
|
||||||
|
name: Audiobookshelf
|
||||||
|
slug: audiobookshelf
|
||||||
|
app_type: oidc
|
||||||
|
client_id: <%= SecureRandom.urlsafe_base64(32) %>
|
||||||
|
client_secret_digest: <%= BCrypt::Password.create(SecureRandom.urlsafe_base64(48)) %>
|
||||||
|
redirect_uris: |
|
||||||
|
https://abs.example.com/auth/openid/callback
|
||||||
|
metadata: "{}"
|
||||||
|
active: true
|
||||||
|
|||||||
8
test/fixtures/groups.yml
vendored
8
test/fixtures/groups.yml
vendored
@@ -1,5 +1,13 @@
|
|||||||
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
||||||
|
|
||||||
|
one:
|
||||||
|
name: Group One
|
||||||
|
description: First test group
|
||||||
|
|
||||||
|
two:
|
||||||
|
name: Group Two
|
||||||
|
description: Second test group
|
||||||
|
|
||||||
admin_group:
|
admin_group:
|
||||||
name: Administrators
|
name: Administrators
|
||||||
description: System administrators with full access
|
description: System administrators with full access
|
||||||
|
|||||||
12
test/fixtures/users.yml
vendored
12
test/fixtures/users.yml
vendored
@@ -1,5 +1,17 @@
|
|||||||
<% password_digest = BCrypt::Password.create("password") %>
|
<% password_digest = BCrypt::Password.create("password") %>
|
||||||
|
|
||||||
|
one:
|
||||||
|
email_address: one@example.com
|
||||||
|
password_digest: <%= password_digest %>
|
||||||
|
admin: false
|
||||||
|
status: 0 # active
|
||||||
|
|
||||||
|
two:
|
||||||
|
email_address: two@example.com
|
||||||
|
password_digest: <%= password_digest %>
|
||||||
|
admin: true
|
||||||
|
status: 0 # active
|
||||||
|
|
||||||
alice:
|
alice:
|
||||||
email_address: alice@example.com
|
email_address: alice@example.com
|
||||||
password_digest: <%= password_digest %>
|
password_digest: <%= password_digest %>
|
||||||
|
|||||||
@@ -58,8 +58,8 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
|
|||||||
# Domain and Rule Integration Tests
|
# Domain and Rule Integration Tests
|
||||||
test "different domain patterns with same session" do
|
test "different domain patterns with same session" do
|
||||||
# Create test rules
|
# Create test rules
|
||||||
wildcard_rule = ForwardAuthRule.create!(domain_pattern: "*.example.com", active: true)
|
wildcard_rule = Application.create!(domain_pattern: "*.example.com", active: true)
|
||||||
exact_rule = ForwardAuthRule.create!(domain_pattern: "api.example.com", active: true)
|
exact_rule = Application.create!(domain_pattern: "api.example.com", active: true)
|
||||||
|
|
||||||
# Sign in
|
# Sign in
|
||||||
post "/signin", params: { email_address: @user.email_address, password: "password" }
|
post "/signin", params: { email_address: @user.email_address, password: "password" }
|
||||||
@@ -82,7 +82,7 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
test "group-based access control integration" do
|
test "group-based access control integration" do
|
||||||
# Create restricted rule
|
# Create restricted rule
|
||||||
restricted_rule = ForwardAuthRule.create!(domain_pattern: "restricted.example.com", active: true)
|
restricted_rule = Application.create!(domain_pattern: "restricted.example.com", active: true)
|
||||||
restricted_rule.allowed_groups << @group
|
restricted_rule.allowed_groups << @group
|
||||||
|
|
||||||
# Sign in user without group
|
# Sign in user without group
|
||||||
@@ -104,17 +104,19 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
# Header Configuration Integration Tests
|
# Header Configuration Integration Tests
|
||||||
test "different header configurations with same user" do
|
test "different header configurations with same user" do
|
||||||
# Create rules with different header configs
|
# Create applications with different configs
|
||||||
default_rule = ForwardAuthRule.create!(domain_pattern: "default.example.com", active: true)
|
default_rule = Application.create!(name: "Default App", slug: "default-app", app_type: "forward_auth", domain_pattern: "default.example.com", active: true)
|
||||||
custom_rule = ForwardAuthRule.create!(
|
custom_rule = Application.create!(
|
||||||
|
name: "Custom App", slug: "custom-app", app_type: "forward_auth",
|
||||||
domain_pattern: "custom.example.com",
|
domain_pattern: "custom.example.com",
|
||||||
active: true,
|
active: true,
|
||||||
headers_config: { user: "X-WEBAUTH-USER", groups: "X-WEBAUTH-ROLES" }
|
metadata: { headers: { user: "X-WEBAUTH-USER", groups: "X-WEBAUTH-ROLES" } }.to_json
|
||||||
)
|
)
|
||||||
no_headers_rule = ForwardAuthRule.create!(
|
no_headers_rule = Application.create!(
|
||||||
|
name: "No Headers App", slug: "no-headers-app", app_type: "forward_auth",
|
||||||
domain_pattern: "noheaders.example.com",
|
domain_pattern: "noheaders.example.com",
|
||||||
active: true,
|
active: true,
|
||||||
headers_config: { user: "", email: "", name: "", groups: "", admin: "" }
|
metadata: { headers: { user: "", email: "", name: "", groups: "", admin: "" } }.to_json
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add user to groups
|
# Add user to groups
|
||||||
@@ -191,7 +193,7 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
|
|||||||
admin_user = users(:two)
|
admin_user = users(:two)
|
||||||
|
|
||||||
# Create restricted rule
|
# Create restricted rule
|
||||||
admin_rule = ForwardAuthRule.create!(
|
admin_rule = Application.create!(
|
||||||
domain_pattern: "admin.example.com",
|
domain_pattern: "admin.example.com",
|
||||||
active: true,
|
active: true,
|
||||||
headers_config: { user: "X-Admin-User", admin: "X-Admin-Flag" }
|
headers_config: { user: "X-Admin-User", admin: "X-Admin-Flag" }
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ class InvitationsMailerTest < ActionMailer::TestCase
|
|||||||
|
|
||||||
assert_equal "You're invited to join Clinch", email.subject
|
assert_equal "You're invited to join Clinch", email.subject
|
||||||
assert_equal [@user.email_address], email.to
|
assert_equal [@user.email_address], email.to
|
||||||
assert_equal [], email.cc
|
assert_equal [], email.cc || []
|
||||||
assert_equal [], email.bcc
|
assert_equal [], email.bcc || []
|
||||||
# From address is configured in ApplicationMailer
|
# From address is configured in ApplicationMailer
|
||||||
assert_not_nil email.from
|
assert_not_nil email.from
|
||||||
assert email.from.is_a?(Array)
|
assert email.from.is_a?(Array)
|
||||||
|
|||||||
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
|
||||||
@@ -25,8 +25,8 @@ class PasswordsMailerTest < ActionMailer::TestCase
|
|||||||
|
|
||||||
assert_equal "Reset your password", email.subject
|
assert_equal "Reset your password", email.subject
|
||||||
assert_equal [@user.email_address], email.to
|
assert_equal [@user.email_address], email.to
|
||||||
assert_equal [], email.cc
|
assert_equal [], email.cc || []
|
||||||
assert_equal [], email.bcc
|
assert_equal [], email.bcc || []
|
||||||
# From address is configured in ApplicationMailer
|
# From address is configured in ApplicationMailer
|
||||||
assert_not_nil email.from
|
assert_not_nil email.from
|
||||||
assert email.from.is_a?(Array)
|
assert email.from.is_a?(Array)
|
||||||
|
|||||||
78
test/models/application_user_claim_test.rb
Normal file
78
test/models/application_user_claim_test.rb
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class ApplicationUserClaimTest < ActiveSupport::TestCase
|
||||||
|
def setup
|
||||||
|
@user = users(:bob)
|
||||||
|
@application = applications(:another_app)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should create valid application user claim" do
|
||||||
|
claim = ApplicationUserClaim.new(
|
||||||
|
user: @user,
|
||||||
|
application: @application,
|
||||||
|
custom_claims: { "role": "admin" }
|
||||||
|
)
|
||||||
|
assert claim.valid?
|
||||||
|
assert claim.save
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should enforce uniqueness of user per application" do
|
||||||
|
ApplicationUserClaim.create!(
|
||||||
|
user: @user,
|
||||||
|
application: @application,
|
||||||
|
custom_claims: { "role": "admin" }
|
||||||
|
)
|
||||||
|
|
||||||
|
duplicate = ApplicationUserClaim.new(
|
||||||
|
user: @user,
|
||||||
|
application: @application,
|
||||||
|
custom_claims: { "role": "user" }
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_not duplicate.valid?
|
||||||
|
assert_includes duplicate.errors[:user_id], "has already been taken"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "parsed_custom_claims returns hash" do
|
||||||
|
claim = ApplicationUserClaim.new(
|
||||||
|
user: @user,
|
||||||
|
application: @application,
|
||||||
|
custom_claims: { "role": "admin", "level": 5 }
|
||||||
|
)
|
||||||
|
|
||||||
|
parsed = claim.parsed_custom_claims
|
||||||
|
assert_equal "admin", parsed["role"]
|
||||||
|
assert_equal 5, parsed["level"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "parsed_custom_claims returns empty hash when nil" do
|
||||||
|
claim = ApplicationUserClaim.new(
|
||||||
|
user: @user,
|
||||||
|
application: @application,
|
||||||
|
custom_claims: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_equal({}, claim.parsed_custom_claims)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should not allow reserved OIDC claim names" do
|
||||||
|
claim = ApplicationUserClaim.new(
|
||||||
|
user: @user,
|
||||||
|
application: @application,
|
||||||
|
custom_claims: { "groups": ["admin"], "role": "user" }
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_not claim.valid?
|
||||||
|
assert_includes claim.errors[:custom_claims], "cannot override reserved OIDC claims: groups"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should allow non-reserved claim names" do
|
||||||
|
claim = ApplicationUserClaim.new(
|
||||||
|
user: @user,
|
||||||
|
application: @application,
|
||||||
|
custom_claims: { "kavita_groups": ["admin"], "role": "user" }
|
||||||
|
)
|
||||||
|
|
||||||
|
assert claim.valid?
|
||||||
|
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
|
||||||
@@ -14,7 +14,8 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
|
|||||||
assert token.length > 100, "Token should be substantial"
|
assert token.length > 100, "Token should be substantial"
|
||||||
assert token.include?('.')
|
assert token.include?('.')
|
||||||
|
|
||||||
decoded = JWT.decode(token, nil, true)
|
# Decode without verification for testing the payload
|
||||||
|
decoded = JWT.decode(token, nil, false).first
|
||||||
assert_equal @application.client_id, decoded['aud'], "Should have correct audience"
|
assert_equal @application.client_id, decoded['aud'], "Should have correct audience"
|
||||||
assert_equal @user.id.to_s, decoded['sub'], "Should have correct subject"
|
assert_equal @user.id.to_s, decoded['sub'], "Should have correct subject"
|
||||||
assert_equal @user.email_address, decoded['email'], "Should have correct email"
|
assert_equal @user.email_address, decoded['email'], "Should have correct email"
|
||||||
@@ -22,16 +23,16 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
|
|||||||
assert_equal @user.email_address, decoded['preferred_username'], "Should have preferred username"
|
assert_equal @user.email_address, decoded['preferred_username'], "Should have preferred username"
|
||||||
assert_equal @user.email_address, decoded['name'], "Should have name"
|
assert_equal @user.email_address, decoded['name'], "Should have name"
|
||||||
assert_equal "https://localhost:3000", decoded['iss'], "Should have correct issuer"
|
assert_equal "https://localhost:3000", decoded['iss'], "Should have correct issuer"
|
||||||
assert_equal Time.now.to_i + 3600, decoded['exp'], "Should have correct expiration"
|
assert_in_delta Time.current.to_i + 3600, decoded['exp'], 5, "Should have correct expiration"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "should handle nonce in id token" do
|
test "should handle nonce in id token" do
|
||||||
nonce = "test-nonce-12345"
|
nonce = "test-nonce-12345"
|
||||||
token = @service.generate_id_token(@user, @application, nonce: nonce)
|
token = @service.generate_id_token(@user, @application, nonce: nonce)
|
||||||
|
|
||||||
decoded = JWT.decode(token, nil, true)
|
decoded = JWT.decode(token, nil, false).first
|
||||||
assert_equal nonce, decoded['nonce'], "Should preserve nonce in token"
|
assert_equal nonce, decoded['nonce'], "Should preserve nonce in token"
|
||||||
assert_equal Time.now.to_i + 3600, decoded['exp'], "Should have correct expiration with nonce"
|
assert_in_delta Time.current.to_i + 3600, decoded['exp'], 5, "Should have correct expiration with nonce"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "should include groups in token when user has groups" do
|
test "should include groups in token when user has groups" do
|
||||||
@@ -39,17 +40,17 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
|
|||||||
|
|
||||||
token = @service.generate_id_token(@user, @application)
|
token = @service.generate_id_token(@user, @application)
|
||||||
|
|
||||||
decoded = JWT.decode(token, nil, true)
|
decoded = JWT.decode(token, nil, false).first
|
||||||
assert_includes decoded['groups'], "admin", "Should include user's groups"
|
assert_includes decoded['groups'], "admin", "Should include user's groups"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "should include admin claim for admin users" do
|
test "admin claim should not be included in token" do
|
||||||
@user.update!(admin: true)
|
@user.update!(admin: true)
|
||||||
|
|
||||||
token = @service.generate_id_token(@user, @application)
|
token = @service.generate_id_token(@user, @application)
|
||||||
|
|
||||||
decoded = JWT.decode(token, nil, true)
|
decoded = JWT.decode(token, nil, false).first
|
||||||
assert_equal true, decoded['admin'], "Admin users should have admin claim"
|
refute decoded.key?('admin'), "Admin claim should not be included in ID tokens (use groups instead)"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "should handle role-based claims when enabled" do
|
test "should handle role-based claims when enabled" do
|
||||||
@@ -63,7 +64,7 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
|
|||||||
|
|
||||||
token = @service.generate_id_token(@user, @application)
|
token = @service.generate_id_token(@user, @application)
|
||||||
|
|
||||||
decoded = JWT.decode(token, nil, true)
|
decoded = JWT.decode(token, nil, false).first
|
||||||
assert_includes decoded['roles'], "editor", "Should include user's role"
|
assert_includes decoded['roles'], "editor", "Should include user's role"
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -96,7 +97,7 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
|
|||||||
|
|
||||||
token = @service.generate_id_token(@user, @application)
|
token = @service.generate_id_token(@user, @application)
|
||||||
|
|
||||||
decoded = JWT.decode(token, nil, true)
|
decoded = JWT.decode(token, nil, false).first
|
||||||
assert_equal "Content Editor", decoded['role_display_name'], "Should include role display name"
|
assert_equal "Content Editor", decoded['role_display_name'], "Should include role display name"
|
||||||
assert_includes decoded['role_permissions'], "read", "Should include read permission"
|
assert_includes decoded['role_permissions'], "read", "Should include read permission"
|
||||||
assert_includes decoded['role_permissions'], "write", "Should include write permission"
|
assert_includes decoded['role_permissions'], "write", "Should include write permission"
|
||||||
@@ -107,15 +108,99 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
|
|||||||
test "should handle missing roles gracefully" do
|
test "should handle missing roles gracefully" do
|
||||||
token = @service.generate_id_token(@user, @application)
|
token = @service.generate_id_token(@user, @application)
|
||||||
|
|
||||||
decoded = JWT.decode(token, nil, true)
|
decoded = JWT.decode(token, nil, false).first
|
||||||
refute_includes decoded, 'roles', "Should not have roles when not configured"
|
refute_includes decoded, 'roles', "Should not have roles when not configured"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "should use RSA private key from environment" do
|
test "should load RSA private key from environment with escaped newlines" do
|
||||||
ENV.stub(:fetch, "OIDC_PRIVATE_KEY") { "test-private-key" }
|
# Simulate how direnv exports multi-line strings with \n escape sequences
|
||||||
|
key_with_escaped_newlines = "-----BEGIN PRIVATE KEY-----\\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDg3SfOR4UW6wV2\\nyKnE/pN5/tvUC7Fpol5/NjJQHm24F8+r6iipdLWJrJ3T2oEzaKw/RTGYPBQvjj6c\\nz3+tc7QkJLOESJCA0WqgawE1WdKSx5ug3sP0Y7woTPipt+afGaV58YvV/sqFD1ft\\nU+2w8olBHqWphUCd/LakfvqHbwrmF58IASk4IbGceqQ7f98d/8C8TrR6k3SKQAto\\n0OWo+xuyJg0RoSS8S220/qyIukXxtHS89NQj3dgJI06fGCSATCu8uVdsKwBDNw3F\\nBSQEX3xhk8E/JXXZfwRFR1K3zUIVQu8haQ3YA52b0jkzE2xI6TaHVbuGdifmGAmX\\nb5jsJ/eNAgMBAAECggEAAWJb3PwlOUANWTe630Pp1OegV5M1Tn2vi+oQPosPl1iX\\nFlbymrj80EfaRPWo84oKnq0t1/RnogrbDa3txgdpSVCsEWk9N2SyoJXy8+MZu6Er\\nQHka8qfBVfe4PbHyRj3FSeQKvZOEvvOgNJkYpIFeb5zkHa1ISyloEWvAxr0njJbQ\\n0F2jML4sUeduYulCWI9dSJdB+yp8BsmOPu8VzUFthW/GPPuw4a4ngzoGtPV6f/kp\\ncjPa2YT8L8z6zXE0IiDU8bc5abC++QBNLJrMy55tM+zfgGyShandITbcpuWptIqT\\n2yhMulifOMw0hdV0cYRqetkWkevz07nrwnh/1FGjYQKBgQD9C/Ls720tULS7SIdh\\nuDWnrtMG4sidSbxWJTOqPUNZ9a0vaHnx/FwlmvURyCojn5leLByY8ZNN08DxKBVq\\nwH6ZJe7KGOik5wMtFV1zrhyHNpa/H/RrLaYAZqCVlGYyOVqNa7mA7oOIeqtbv9x+\\nOaEz3BnoXHOJOwM10h20Nos6bQKBgQDjfQCSQXcrkV8hKf+F65N7Kcf7JMlZQAA3\\n9dvJxxek683bhYTLZhubY/tegfhxlZGkgP3eHKI1XyUYBCNBnztn3t1zD0ovcqRX\\no21m5TaJ0fGW4X3iyi1IWioMBPXffR8tXk5+LnWVZ26RgmaBG1rgOJEQ5bHYMtHj\\n+jo9JLV9oQKBgQDt1nNHm2qEcxzMAsmsYVWc+8bA7BsfKxTn6yN6WQaa4T0cGBi2\\nBzoc5l59jiN9RB8E0nU2k6ieN+9bOw+WPMNA8tRUA8F2bOMhVrl1ZyrNM9PQZBp5\\nOniSW+OHc+nyPtILpjq/Im9isdmp7NUzlrsbYT7AlVTKoTrNNWZR4gpOqQKBgQC3\\nIWwSUS00H4TrV7nh/zDsl0fr/0Mv2/vRENTsbJ+2HjXMIII0k3Bp+WTkQdDU70kd\\nmtHDul1CheOAn+QZ8auLBLhU5dwcsjdmbaOmj6MF88J+aexDY+psMlli76NXVIyC\\no0ahAZmaunciIE2QZYsUsbTmW2J93vtkgY3cpu6LwQKBgDigl7dCQl38Vt7FhxjJ\\naC6wmmM8YX6y5f5t3caVVBizVhx8xOXQla96zB0nW6ibTpaIKCSdORxMGAoajTZ9\\n8Ww2gOfZpZeojU2YHTV/KFd7wHGYE8QaBKqP6DuibLnP5farjuwPeGvbjZW6e9cy\\nntHkSPI0VmhqsUQEMgPnYuCg\\n-----END PRIVATE KEY-----"
|
||||||
|
|
||||||
private_key = @service.private_key
|
# Clear any cached keys
|
||||||
assert_equal "test-private-key", private_key.to_s, "Should use private key from environment"
|
OidcJwtService.instance_variable_set(:@private_key, nil)
|
||||||
|
|
||||||
|
# Stub ENV to return the test key
|
||||||
|
original_value = ENV["OIDC_PRIVATE_KEY"]
|
||||||
|
ENV["OIDC_PRIVATE_KEY"] = key_with_escaped_newlines
|
||||||
|
|
||||||
|
# The service should convert \n to actual newlines and load successfully
|
||||||
|
private_key = OidcJwtService.send(:private_key)
|
||||||
|
|
||||||
|
assert_not_nil private_key
|
||||||
|
assert_kind_of OpenSSL::PKey::RSA, private_key
|
||||||
|
assert_equal 2048, private_key.n.num_bits
|
||||||
|
ensure
|
||||||
|
# Restore original value and clear cached key
|
||||||
|
ENV["OIDC_PRIVATE_KEY"] = original_value
|
||||||
|
OidcJwtService.instance_variable_set(:@private_key, nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should handle key with actual newlines" do
|
||||||
|
# Generate a real test key
|
||||||
|
test_key = OpenSSL::PKey::RSA.new(2048)
|
||||||
|
key_pem = test_key.to_pem
|
||||||
|
|
||||||
|
# Clear any cached keys
|
||||||
|
OidcJwtService.instance_variable_set(:@private_key, nil)
|
||||||
|
|
||||||
|
# Stub ENV to return the test key
|
||||||
|
original_value = ENV["OIDC_PRIVATE_KEY"]
|
||||||
|
ENV["OIDC_PRIVATE_KEY"] = key_pem
|
||||||
|
|
||||||
|
private_key = OidcJwtService.send(:private_key)
|
||||||
|
|
||||||
|
assert_not_nil private_key
|
||||||
|
assert_kind_of OpenSSL::PKey::RSA, private_key
|
||||||
|
assert_equal 2048, private_key.n.num_bits
|
||||||
|
ensure
|
||||||
|
# Restore original value and clear cached key
|
||||||
|
ENV["OIDC_PRIVATE_KEY"] = original_value
|
||||||
|
OidcJwtService.instance_variable_set(:@private_key, nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should raise error for invalid key format" do
|
||||||
|
# Clear any cached keys
|
||||||
|
OidcJwtService.instance_variable_set(:@private_key, nil)
|
||||||
|
|
||||||
|
# Stub ENV to return invalid key
|
||||||
|
original_value = ENV["OIDC_PRIVATE_KEY"]
|
||||||
|
ENV["OIDC_PRIVATE_KEY"] = "invalid-key-data"
|
||||||
|
|
||||||
|
error = assert_raises RuntimeError do
|
||||||
|
OidcJwtService.send(:private_key)
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_match /Invalid OIDC private key format/, error.message
|
||||||
|
ensure
|
||||||
|
# Restore original value and clear cached key
|
||||||
|
ENV["OIDC_PRIVATE_KEY"] = original_value
|
||||||
|
OidcJwtService.instance_variable_set(:@private_key, nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should raise error in production when no key configured" do
|
||||||
|
# Skip this test if we can't properly stub Rails.env
|
||||||
|
skip "Skipping production env test" unless Rails.env.development? || Rails.env.test?
|
||||||
|
|
||||||
|
# Clear any cached keys
|
||||||
|
OidcJwtService.instance_variable_set(:@private_key, nil)
|
||||||
|
|
||||||
|
# Temporarily remove the key
|
||||||
|
original_value = ENV["OIDC_PRIVATE_KEY"]
|
||||||
|
ENV.delete("OIDC_PRIVATE_KEY")
|
||||||
|
|
||||||
|
# Stub Rails.env to be production
|
||||||
|
Rails.env = ActiveSupport::StringInquirer.new("production")
|
||||||
|
|
||||||
|
error = assert_raises RuntimeError do
|
||||||
|
OidcJwtService.send(:private_key)
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_match /OIDC private key not configured/, error.message
|
||||||
|
ensure
|
||||||
|
# Restore original environment and clear cached key
|
||||||
|
ENV["OIDC_PRIVATE_KEY"] = original_value if original_value
|
||||||
|
Rails.env = ActiveSupport::StringInquirer.new(ENV.fetch("RAILS_ENV", "test"))
|
||||||
|
OidcJwtService.instance_variable_set(:@private_key, nil)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "should generate RSA private key when missing" do
|
test "should generate RSA private key when missing" do
|
||||||
@@ -176,7 +261,7 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
|
|||||||
test "should handle access token generation" do
|
test "should handle access token generation" do
|
||||||
token = @service.generate_id_token(@user, @application)
|
token = @service.generate_id_token(@user, @application)
|
||||||
|
|
||||||
decoded = JWT.decode(token, nil, true)
|
decoded = JWT.decode(token, nil, false).first
|
||||||
refute_includes decoded.keys, 'email_verified'
|
refute_includes decoded.keys, 'email_verified'
|
||||||
assert_equal @user.id.to_s, decoded['sub'], "Should decode subject correctly"
|
assert_equal @user.id.to_s, decoded['sub'], "Should decode subject correctly"
|
||||||
assert_equal @application.client_id, decoded['aud'], "Should decode audience correctly"
|
assert_equal @application.client_id, decoded['aud'], "Should decode audience correctly"
|
||||||
@@ -207,4 +292,215 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
|
|||||||
end
|
end
|
||||||
assert_match /no key found/, error.message, "Should warn about missing private key"
|
assert_match /no key found/, error.message, "Should warn about missing private key"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "should include app-specific custom claims in token" do
|
||||||
|
# Use bob and another_app to avoid fixture conflicts
|
||||||
|
user = users(:bob)
|
||||||
|
app = applications(:another_app)
|
||||||
|
|
||||||
|
# Create app-specific claim
|
||||||
|
ApplicationUserClaim.create!(
|
||||||
|
user: user,
|
||||||
|
application: app,
|
||||||
|
custom_claims: { "app_groups": ["admin"], "library_access": "all" }
|
||||||
|
)
|
||||||
|
|
||||||
|
token = @service.generate_id_token(user, app)
|
||||||
|
decoded = JWT.decode(token, nil, false).first
|
||||||
|
|
||||||
|
assert_equal ["admin"], decoded["app_groups"]
|
||||||
|
assert_equal "all", decoded["library_access"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "app-specific claims should override user and group claims" do
|
||||||
|
# Use bob and another_app to avoid fixture conflicts
|
||||||
|
user = users(:bob)
|
||||||
|
app = applications(:another_app)
|
||||||
|
|
||||||
|
# Add user to group with claims
|
||||||
|
group = groups(:admin_group)
|
||||||
|
group.update!(custom_claims: { "role": "viewer", "max_items": 10 })
|
||||||
|
user.groups << group
|
||||||
|
|
||||||
|
# Add user custom claims
|
||||||
|
user.update!(custom_claims: { "role": "editor", "theme": "dark" })
|
||||||
|
|
||||||
|
# Add app-specific claims (should override both)
|
||||||
|
ApplicationUserClaim.create!(
|
||||||
|
user: user,
|
||||||
|
application: app,
|
||||||
|
custom_claims: { "role": "admin", "app_specific": true }
|
||||||
|
)
|
||||||
|
|
||||||
|
token = @service.generate_id_token(user, app)
|
||||||
|
decoded = JWT.decode(token, nil, false).first
|
||||||
|
|
||||||
|
# App-specific claim should win
|
||||||
|
assert_equal "admin", decoded["role"]
|
||||||
|
# App-specific claim should be present
|
||||||
|
assert_equal true, decoded["app_specific"]
|
||||||
|
# User claim not overridden should still be present
|
||||||
|
assert_equal "dark", decoded["theme"]
|
||||||
|
# Group claim not overridden should still be present
|
||||||
|
assert_equal 10, decoded["max_items"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should deep merge array claims from group and user" do
|
||||||
|
user = users(:bob)
|
||||||
|
app = applications(:another_app)
|
||||||
|
|
||||||
|
# Group has roles: ["user"]
|
||||||
|
group = groups(:admin_group)
|
||||||
|
group.update!(custom_claims: { "roles" => ["user"], "permissions" => ["read"] })
|
||||||
|
user.groups << group
|
||||||
|
|
||||||
|
# User adds roles: ["admin"]
|
||||||
|
user.update!(custom_claims: { "roles" => ["admin"], "permissions" => ["write"] })
|
||||||
|
|
||||||
|
token = @service.generate_id_token(user, app)
|
||||||
|
decoded = JWT.decode(token, nil, false).first
|
||||||
|
|
||||||
|
# Roles should be combined (not overwritten)
|
||||||
|
assert_equal 2, decoded["roles"].length
|
||||||
|
assert_includes decoded["roles"], "user"
|
||||||
|
assert_includes decoded["roles"], "admin"
|
||||||
|
# Permissions should also be combined
|
||||||
|
assert_equal 2, decoded["permissions"].length
|
||||||
|
assert_includes decoded["permissions"], "read"
|
||||||
|
assert_includes decoded["permissions"], "write"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should deep merge array claims from multiple groups" do
|
||||||
|
user = users(:bob)
|
||||||
|
app = applications(:another_app)
|
||||||
|
|
||||||
|
# First group has roles: ["user"]
|
||||||
|
group1 = groups(:admin_group)
|
||||||
|
group1.update!(custom_claims: { "roles" => ["user"] })
|
||||||
|
user.groups << group1
|
||||||
|
|
||||||
|
# Second group has roles: ["moderator"]
|
||||||
|
group2 = Group.create!(name: "moderators", description: "Moderators group")
|
||||||
|
group2.update!(custom_claims: { "roles" => ["moderator"] })
|
||||||
|
user.groups << group2
|
||||||
|
|
||||||
|
# User adds roles: ["admin"]
|
||||||
|
user.update!(custom_claims: { "roles" => ["admin"] })
|
||||||
|
|
||||||
|
token = @service.generate_id_token(user, app)
|
||||||
|
decoded = JWT.decode(token, nil, false).first
|
||||||
|
|
||||||
|
# All roles should be combined
|
||||||
|
assert_equal 3, decoded["roles"].length
|
||||||
|
assert_includes decoded["roles"], "user"
|
||||||
|
assert_includes decoded["roles"], "moderator"
|
||||||
|
assert_includes decoded["roles"], "admin"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should remove duplicate values when merging arrays" do
|
||||||
|
user = users(:bob)
|
||||||
|
app = applications(:another_app)
|
||||||
|
|
||||||
|
# Group has roles: ["user", "reader"]
|
||||||
|
group = groups(:admin_group)
|
||||||
|
group.update!(custom_claims: { "roles" => ["user", "reader"] })
|
||||||
|
user.groups << group
|
||||||
|
|
||||||
|
# User also has "user" role (duplicate)
|
||||||
|
user.update!(custom_claims: { "roles" => ["user", "admin"] })
|
||||||
|
|
||||||
|
token = @service.generate_id_token(user, app)
|
||||||
|
decoded = JWT.decode(token, nil, false).first
|
||||||
|
|
||||||
|
# "user" should only appear once
|
||||||
|
assert_equal 3, decoded["roles"].length
|
||||||
|
assert_includes decoded["roles"], "user"
|
||||||
|
assert_includes decoded["roles"], "reader"
|
||||||
|
assert_includes decoded["roles"], "admin"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should override non-array values while merging arrays" do
|
||||||
|
user = users(:bob)
|
||||||
|
app = applications(:another_app)
|
||||||
|
|
||||||
|
# Group has roles array and max_items scalar
|
||||||
|
group = groups(:admin_group)
|
||||||
|
group.update!(custom_claims: { "roles" => ["user"], "max_items" => 10, "theme" => "light" })
|
||||||
|
user.groups << group
|
||||||
|
|
||||||
|
# User overrides max_items and theme, adds to roles
|
||||||
|
user.update!(custom_claims: { "roles" => ["admin"], "max_items" => 100, "theme" => "dark" })
|
||||||
|
|
||||||
|
token = @service.generate_id_token(user, app)
|
||||||
|
decoded = JWT.decode(token, nil, false).first
|
||||||
|
|
||||||
|
# Arrays should be combined
|
||||||
|
assert_equal 2, decoded["roles"].length
|
||||||
|
assert_includes decoded["roles"], "user"
|
||||||
|
assert_includes decoded["roles"], "admin"
|
||||||
|
# Scalar values should be overridden (user wins)
|
||||||
|
assert_equal 100, decoded["max_items"]
|
||||||
|
assert_equal "dark", decoded["theme"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should deep merge nested hashes in claims" do
|
||||||
|
user = users(:bob)
|
||||||
|
app = applications(:another_app)
|
||||||
|
|
||||||
|
# Group has nested config
|
||||||
|
group = groups(:admin_group)
|
||||||
|
group.update!(custom_claims: {
|
||||||
|
"config" => {
|
||||||
|
"theme" => "light",
|
||||||
|
"notifications" => { "email" => true }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
user.groups << group
|
||||||
|
|
||||||
|
# User adds to nested config
|
||||||
|
user.update!(custom_claims: {
|
||||||
|
"config" => {
|
||||||
|
"language" => "en",
|
||||||
|
"notifications" => { "sms" => true }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
token = @service.generate_id_token(user, app)
|
||||||
|
decoded = JWT.decode(token, nil, false).first
|
||||||
|
|
||||||
|
# Nested hashes should be deep merged
|
||||||
|
assert_equal "light", decoded["config"]["theme"]
|
||||||
|
assert_equal "en", decoded["config"]["language"]
|
||||||
|
assert_equal true, decoded["config"]["notifications"]["email"]
|
||||||
|
assert_equal true, decoded["config"]["notifications"]["sms"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "app-specific claims should combine arrays with group and user claims" do
|
||||||
|
user = users(:bob)
|
||||||
|
app = applications(:another_app)
|
||||||
|
|
||||||
|
# Group has roles: ["user"]
|
||||||
|
group = groups(:admin_group)
|
||||||
|
group.update!(custom_claims: { "roles" => ["user"] })
|
||||||
|
user.groups << group
|
||||||
|
|
||||||
|
# User has roles: ["moderator"]
|
||||||
|
user.update!(custom_claims: { "roles" => ["moderator"] })
|
||||||
|
|
||||||
|
# App-specific has roles: ["app_admin"]
|
||||||
|
ApplicationUserClaim.create!(
|
||||||
|
user: user,
|
||||||
|
application: app,
|
||||||
|
custom_claims: { "roles" => ["app_admin"] }
|
||||||
|
)
|
||||||
|
|
||||||
|
token = @service.generate_id_token(user, app)
|
||||||
|
decoded = JWT.decode(token, nil, false).first
|
||||||
|
|
||||||
|
# All three sources should be combined
|
||||||
|
assert_equal 3, decoded["roles"].length
|
||||||
|
assert_includes decoded["roles"], "user"
|
||||||
|
assert_includes decoded["roles"], "moderator"
|
||||||
|
assert_includes decoded["roles"], "app_admin"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
Reference in New Issue
Block a user