Compare commits
8 Commits
7796c38c08
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab362aabac | ||
|
|
283feea175 | ||
|
|
7af8624bf8 | ||
|
|
f8543f98cc | ||
|
|
6be23c2c37 | ||
|
|
eb2d7379bf | ||
|
|
67d86e5835 | ||
|
|
d6029556d3 |
@@ -11,6 +11,8 @@
|
||||
ARG RUBY_VERSION=3.4.6
|
||||
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
|
||||
|
||||
LABEL org.opencontainers.image.source=https://github.com/dkam/clinch
|
||||
|
||||
# Rails app lives here
|
||||
WORKDIR /rails
|
||||
|
||||
|
||||
6
Gemfile
6
Gemfile
@@ -35,11 +35,11 @@ gem "jwt", "~> 3.1"
|
||||
gem "webauthn", "~> 3.0"
|
||||
|
||||
# Public Suffix List for domain parsing
|
||||
gem "public_suffix", "~> 6.0"
|
||||
gem "public_suffix", "~> 7.0"
|
||||
|
||||
# Error tracking and performance monitoring (optional, configured via SENTRY_DSN)
|
||||
gem "sentry-ruby", "~> 5.18"
|
||||
gem "sentry-rails", "~> 5.18"
|
||||
gem "sentry-ruby", "~> 6.2"
|
||||
gem "sentry-rails", "~> 6.2"
|
||||
|
||||
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
|
||||
gem "tzinfo-data", platforms: %i[ windows jruby ]
|
||||
|
||||
92
Gemfile.lock
92
Gemfile.lock
@@ -75,8 +75,8 @@ GEM
|
||||
securerandom (>= 0.3)
|
||||
tzinfo (~> 2.0, >= 2.0.5)
|
||||
uri (>= 0.13.1)
|
||||
addressable (2.8.7)
|
||||
public_suffix (>= 2.0.2, < 7.0)
|
||||
addressable (2.8.8)
|
||||
public_suffix (>= 2.0.2, < 8.0)
|
||||
android_key_attestation (0.3.0)
|
||||
ast (2.4.3)
|
||||
base64 (0.3.0)
|
||||
@@ -85,13 +85,13 @@ GEM
|
||||
bigdecimal (3.3.1)
|
||||
bindata (2.5.1)
|
||||
bindex (0.8.1)
|
||||
bootsnap (1.18.6)
|
||||
bootsnap (1.19.0)
|
||||
msgpack (~> 1.2)
|
||||
brakeman (7.1.0)
|
||||
brakeman (7.1.1)
|
||||
racc
|
||||
builder (3.3.0)
|
||||
bundler-audit (0.9.2)
|
||||
bundler (>= 1.2.0, < 3)
|
||||
bundler-audit (0.9.3)
|
||||
bundler (>= 1.2.0)
|
||||
thor (~> 1.0)
|
||||
capybara (3.40.0)
|
||||
addressable
|
||||
@@ -107,7 +107,7 @@ GEM
|
||||
logger (~> 1.5)
|
||||
chunky_png (1.4.0)
|
||||
concurrent-ruby (1.3.5)
|
||||
connection_pool (2.5.4)
|
||||
connection_pool (2.5.5)
|
||||
cose (1.3.1)
|
||||
cbor (~> 0.5.9)
|
||||
openssl-signature_algorithm (~> 1.0)
|
||||
@@ -119,7 +119,7 @@ GEM
|
||||
dotenv (3.1.8)
|
||||
drb (2.2.3)
|
||||
ed25519 (1.4.0)
|
||||
erb (5.1.3)
|
||||
erb (6.0.0)
|
||||
erubi (1.13.1)
|
||||
ffi (1.17.2-aarch64-linux-gnu)
|
||||
ffi (1.17.2-aarch64-linux-musl)
|
||||
@@ -147,10 +147,10 @@ GEM
|
||||
jbuilder (2.14.1)
|
||||
actionview (>= 7.0.0)
|
||||
activesupport (>= 7.0.0)
|
||||
json (2.15.2)
|
||||
json (2.16.0)
|
||||
jwt (3.1.2)
|
||||
base64
|
||||
kamal (2.8.1)
|
||||
kamal (2.9.0)
|
||||
activesupport (>= 7.0)
|
||||
base64 (~> 0.2)
|
||||
bcrypt_pbkdf (~> 1.0)
|
||||
@@ -184,7 +184,7 @@ GEM
|
||||
mini_magick (5.3.1)
|
||||
logger
|
||||
mini_mime (1.1.5)
|
||||
minitest (5.26.0)
|
||||
minitest (5.26.2)
|
||||
msgpack (1.8.0)
|
||||
net-imap (0.5.12)
|
||||
date
|
||||
@@ -220,7 +220,7 @@ GEM
|
||||
openssl (> 2.0)
|
||||
ostruct (0.6.3)
|
||||
parallel (1.27.0)
|
||||
parser (3.3.9.0)
|
||||
parser (3.3.10.0)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
pp (0.6.3)
|
||||
@@ -234,7 +234,7 @@ GEM
|
||||
psych (5.2.6)
|
||||
date
|
||||
stringio
|
||||
public_suffix (6.0.2)
|
||||
public_suffix (7.0.0)
|
||||
puma (7.1.0)
|
||||
nio4r (~> 2.0)
|
||||
racc (1.8.1)
|
||||
@@ -278,20 +278,20 @@ GEM
|
||||
zeitwerk (~> 2.6)
|
||||
rainbow (3.1.1)
|
||||
rake (13.3.1)
|
||||
rdoc (6.15.1)
|
||||
rdoc (6.16.1)
|
||||
erb
|
||||
psych (>= 4.0.0)
|
||||
tsort
|
||||
regexp_parser (2.11.3)
|
||||
reline (0.6.2)
|
||||
reline (0.6.3)
|
||||
io-console (~> 0.5)
|
||||
rexml (3.4.4)
|
||||
rotp (6.3.0)
|
||||
rqrcode (3.1.0)
|
||||
rqrcode (3.1.1)
|
||||
chunky_png (~> 1.0)
|
||||
rqrcode_core (~> 2.0)
|
||||
rqrcode_core (2.0.0)
|
||||
rubocop (1.81.6)
|
||||
rqrcode_core (2.0.1)
|
||||
rubocop (1.81.7)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (~> 3.17.0.2)
|
||||
lint_roller (~> 1.1.0)
|
||||
@@ -302,14 +302,14 @@ GEM
|
||||
rubocop-ast (>= 1.47.1, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 2.4.0, < 4.0)
|
||||
rubocop-ast (1.47.1)
|
||||
rubocop-ast (1.48.0)
|
||||
parser (>= 3.3.7.2)
|
||||
prism (~> 1.4)
|
||||
rubocop-performance (1.26.1)
|
||||
lint_roller (~> 1.1)
|
||||
rubocop (>= 1.75.0, < 2.0)
|
||||
rubocop-ast (>= 1.47.1, < 2.0)
|
||||
rubocop-rails (2.33.4)
|
||||
rubocop-rails (2.34.2)
|
||||
activesupport (>= 4.2.0)
|
||||
lint_roller (~> 1.1)
|
||||
rack (>= 1.1)
|
||||
@@ -323,7 +323,7 @@ GEM
|
||||
ruby-vips (2.2.5)
|
||||
ffi (~> 1.12)
|
||||
logger
|
||||
rubyzip (3.2.1)
|
||||
rubyzip (3.2.2)
|
||||
safety_net_attestation (0.5.0)
|
||||
jwt (>= 2.0, < 4.0)
|
||||
securerandom (0.4.1)
|
||||
@@ -333,10 +333,10 @@ GEM
|
||||
rexml (~> 3.2, >= 3.2.5)
|
||||
rubyzip (>= 1.2.2, < 4.0)
|
||||
websocket (~> 1.0)
|
||||
sentry-rails (5.28.0)
|
||||
railties (>= 5.0)
|
||||
sentry-ruby (~> 5.28.0)
|
||||
sentry-ruby (5.28.0)
|
||||
sentry-rails (6.2.0)
|
||||
railties (>= 5.2.0)
|
||||
sentry-ruby (~> 6.2.0)
|
||||
sentry-ruby (6.2.0)
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
solid_cable (3.0.12)
|
||||
@@ -344,17 +344,17 @@ GEM
|
||||
activejob (>= 7.2)
|
||||
activerecord (>= 7.2)
|
||||
railties (>= 7.2)
|
||||
solid_cache (1.0.8)
|
||||
solid_cache (1.0.10)
|
||||
activejob (>= 7.2)
|
||||
activerecord (>= 7.2)
|
||||
railties (>= 7.2)
|
||||
sqlite3 (2.7.4-aarch64-linux-gnu)
|
||||
sqlite3 (2.7.4-aarch64-linux-musl)
|
||||
sqlite3 (2.7.4-arm-linux-gnu)
|
||||
sqlite3 (2.7.4-arm-linux-musl)
|
||||
sqlite3 (2.7.4-arm64-darwin)
|
||||
sqlite3 (2.7.4-x86_64-linux-gnu)
|
||||
sqlite3 (2.7.4-x86_64-linux-musl)
|
||||
sqlite3 (2.8.1-aarch64-linux-gnu)
|
||||
sqlite3 (2.8.1-aarch64-linux-musl)
|
||||
sqlite3 (2.8.1-arm-linux-gnu)
|
||||
sqlite3 (2.8.1-arm-linux-musl)
|
||||
sqlite3 (2.8.1-arm64-darwin)
|
||||
sqlite3 (2.8.1-x86_64-linux-gnu)
|
||||
sqlite3 (2.8.1-x86_64-linux-musl)
|
||||
sshkit (1.24.0)
|
||||
base64
|
||||
logger
|
||||
@@ -364,16 +364,16 @@ GEM
|
||||
ostruct
|
||||
stimulus-rails (1.3.4)
|
||||
railties (>= 6.0.0)
|
||||
stringio (3.1.7)
|
||||
tailwindcss-rails (4.3.0)
|
||||
stringio (3.1.8)
|
||||
tailwindcss-rails (4.4.0)
|
||||
railties (>= 7.0.0)
|
||||
tailwindcss-ruby (~> 4.0)
|
||||
tailwindcss-ruby (4.1.13)
|
||||
tailwindcss-ruby (4.1.13-aarch64-linux-gnu)
|
||||
tailwindcss-ruby (4.1.13-aarch64-linux-musl)
|
||||
tailwindcss-ruby (4.1.13-arm64-darwin)
|
||||
tailwindcss-ruby (4.1.13-x86_64-linux-gnu)
|
||||
tailwindcss-ruby (4.1.13-x86_64-linux-musl)
|
||||
tailwindcss-ruby (4.1.16)
|
||||
tailwindcss-ruby (4.1.16-aarch64-linux-gnu)
|
||||
tailwindcss-ruby (4.1.16-aarch64-linux-musl)
|
||||
tailwindcss-ruby (4.1.16-arm64-darwin)
|
||||
tailwindcss-ruby (4.1.16-x86_64-linux-gnu)
|
||||
tailwindcss-ruby (4.1.16-x86_64-linux-musl)
|
||||
thor (1.4.0)
|
||||
thruster (0.1.16)
|
||||
thruster (0.1.16-aarch64-linux)
|
||||
@@ -385,7 +385,7 @@ GEM
|
||||
openssl (> 2.0)
|
||||
openssl-signature_algorithm (~> 1.0)
|
||||
tsort (0.2.0)
|
||||
turbo-rails (2.0.17)
|
||||
turbo-rails (2.0.20)
|
||||
actionpack (>= 7.1.0)
|
||||
railties (>= 7.1.0)
|
||||
tzinfo (2.0.6)
|
||||
@@ -393,7 +393,7 @@ GEM
|
||||
unicode-display_width (3.2.0)
|
||||
unicode-emoji (~> 4.1)
|
||||
unicode-emoji (4.1.0)
|
||||
uri (1.1.0)
|
||||
uri (1.1.1)
|
||||
useragent (0.16.11)
|
||||
web-console (4.2.1)
|
||||
actionview (>= 6.0.0)
|
||||
@@ -442,15 +442,15 @@ DEPENDENCIES
|
||||
kamal
|
||||
letter_opener
|
||||
propshaft
|
||||
public_suffix (~> 6.0)
|
||||
public_suffix (~> 7.0)
|
||||
puma (>= 5.0)
|
||||
rails (~> 8.1.1)
|
||||
rotp (~> 6.3)
|
||||
rqrcode (~> 3.1)
|
||||
rubocop-rails-omakase
|
||||
selenium-webdriver
|
||||
sentry-rails (~> 5.18)
|
||||
sentry-ruby (~> 5.18)
|
||||
sentry-rails (~> 6.2)
|
||||
sentry-ruby (~> 6.2)
|
||||
solid_cable
|
||||
solid_cache
|
||||
sqlite3 (>= 2.1)
|
||||
|
||||
57
README.md
57
README.md
@@ -15,10 +15,12 @@ I've completed all planned features:
|
||||
* Forward Auth configured and working
|
||||
* OIDC provider with auto discovery, refresh tokens, and token revocation
|
||||
* Configurable token expiry per application (access, refresh, ID tokens)
|
||||
* Backchannel Logout
|
||||
* Per-application logout / revoke
|
||||
* Invite users by email, assign to groups
|
||||
* Self managed password reset by email
|
||||
* Use Groups to assign Applications ( Family group can access Kavita, Developers can access Gitea )
|
||||
* Configurable Group 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 logged in sessions and OIDC logged in sessions
|
||||
|
||||
@@ -94,6 +96,7 @@ Standard OAuth2/OIDC provider with endpoints:
|
||||
|
||||
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
|
||||
@@ -122,10 +125,54 @@ Send emails for:
|
||||
- **Session revocation** - Users and admins can revoke individual sessions
|
||||
|
||||
### Access Control
|
||||
- **Group-based allowlists** - Restrict applications to specific user groups
|
||||
- **Per-application access** - Each app defines which groups can access it
|
||||
- **Automatic enforcement** - Access checks during OIDC authorization and ForwardAuth
|
||||
- **Custom claims** - Add arbitrary claims to OIDC tokens via groups and users (perfect for app-specific roles)
|
||||
|
||||
#### Group-Based Application Access
|
||||
Clinch uses groups to control which users can access which applications:
|
||||
|
||||
- **Create groups** - Organize users into logical groups (readers, editors, family, developers, etc.)
|
||||
- **Assign groups to applications** - Each app defines which groups are allowed to access it
|
||||
- Example: Kavita app allows the "readers" group → only users in the "readers" group can sign in
|
||||
- If no groups are assigned to an app → all active users can access it
|
||||
- **Automatic enforcement** - Access checks happen automatically:
|
||||
- During OIDC authorization flow (before consent)
|
||||
- During ForwardAuth verification (before proxying requests)
|
||||
- Users not in allowed groups receive a "You do not have permission" error
|
||||
|
||||
#### Group Claims in Tokens
|
||||
- **OIDC tokens include group membership** - ID tokens contain a `groups` claim with all user's groups
|
||||
- **Custom claims** - Add arbitrary key-value pairs to tokens via groups and users
|
||||
- Group claims apply to all members (e.g., `{"role": "viewer"}`)
|
||||
- User claims override group claims for fine-grained control
|
||||
- Perfect for app-specific authorization (e.g., admin vs. read-only roles)
|
||||
|
||||
#### Custom Claims Merging
|
||||
Custom claims from groups and users are merged into OIDC ID tokens with the following precedence:
|
||||
|
||||
1. **Default OIDC claims** - Standard claims (`iss`, `sub`, `aud`, `exp`, `email`, etc.)
|
||||
2. **Standard Clinch claims** - `groups` array (list of user's group names)
|
||||
3. **Group custom claims** - Merged in order; later groups override earlier ones
|
||||
4. **User custom claims** - Override all group claims
|
||||
5. **Application-specific claims** - Highest priority; override all other claims
|
||||
|
||||
**Example:**
|
||||
- Group "readers" has `{"role": "viewer", "max_items": 10}`
|
||||
- Group "premium" has `{"role": "subscriber", "max_items": 100}`
|
||||
- User (in both groups) has `{"max_items": 500}`
|
||||
- **Result:** `{"role": "subscriber", "max_items": 500}` (user overrides max_items, premium overrides role)
|
||||
|
||||
#### Application-Specific Claims
|
||||
Configure different claims for different applications on a per-user basis:
|
||||
|
||||
- **Per-app customization** - Each application can have unique claims for each user
|
||||
- **Highest precedence** - App-specific claims override group and user global claims
|
||||
- **Use case** - Different roles in different apps (e.g., admin in Kavita, user in Audiobookshelf)
|
||||
- **Admin UI** - Configure via Admin → Users → Edit User → App-Specific Claim Overrides
|
||||
|
||||
**Example:**
|
||||
- User Alice, global claims: `{"theme": "dark"}`
|
||||
- Kavita app-specific: `{"kavita_groups": ["admin"]}`
|
||||
- Audiobookshelf app-specific: `{"abs_groups": ["user"]}`
|
||||
- **Result:** Kavita receives `{"theme": "dark", "kavita_groups": ["admin"]}`, Audiobookshelf receives `{"theme": "dark", "abs_groups": ["user"]}`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -16,16 +16,82 @@ class ActiveSessionsController < ApplicationController
|
||||
return
|
||||
end
|
||||
|
||||
# Send backchannel logout notification before revoking consent
|
||||
if application.supports_backchannel_logout?
|
||||
BackchannelLogoutJob.perform_later(
|
||||
user_id: @user.id,
|
||||
application_id: application.id,
|
||||
consent_sid: consent.sid
|
||||
)
|
||||
Rails.logger.info "ActiveSessionsController: Enqueued backchannel logout for #{application.name}"
|
||||
end
|
||||
|
||||
# Revoke all tokens for this user-application pair
|
||||
now = Time.current
|
||||
revoked_access_tokens = OidcAccessToken.where(application: application, user: @user, revoked_at: nil)
|
||||
.update_all(revoked_at: now)
|
||||
revoked_refresh_tokens = OidcRefreshToken.where(application: application, user: @user, revoked_at: nil)
|
||||
.update_all(revoked_at: now)
|
||||
|
||||
Rails.logger.info "ActiveSessionsController: Revoked #{revoked_access_tokens} access tokens and #{revoked_refresh_tokens} refresh tokens for #{application.name}"
|
||||
|
||||
# Revoke the consent
|
||||
consent.destroy
|
||||
redirect_to active_sessions_path, notice: "Successfully revoked access to #{application.name}."
|
||||
end
|
||||
|
||||
def logout_from_app
|
||||
@user = Current.session.user
|
||||
application = Application.find(params[:application_id])
|
||||
|
||||
# Check if user has consent for this application
|
||||
consent = @user.oidc_user_consents.find_by(application: application)
|
||||
unless consent
|
||||
redirect_to root_path, alert: "No active session found for this application."
|
||||
return
|
||||
end
|
||||
|
||||
# Send backchannel logout notification
|
||||
if application.supports_backchannel_logout?
|
||||
BackchannelLogoutJob.perform_later(
|
||||
user_id: @user.id,
|
||||
application_id: application.id,
|
||||
consent_sid: consent.sid
|
||||
)
|
||||
Rails.logger.info "ActiveSessionsController: Enqueued backchannel logout for #{application.name}"
|
||||
end
|
||||
|
||||
# Revoke all tokens for this user-application pair
|
||||
now = Time.current
|
||||
revoked_access_tokens = OidcAccessToken.where(application: application, user: @user, revoked_at: nil)
|
||||
.update_all(revoked_at: now)
|
||||
revoked_refresh_tokens = OidcRefreshToken.where(application: application, user: @user, revoked_at: nil)
|
||||
.update_all(revoked_at: now)
|
||||
|
||||
Rails.logger.info "ActiveSessionsController: Logged out from #{application.name} - revoked #{revoked_access_tokens} access tokens and #{revoked_refresh_tokens} refresh tokens"
|
||||
|
||||
# Keep the consent intact - this is the key difference from revoke_consent
|
||||
redirect_to root_path, notice: "Successfully logged out of #{application.name}."
|
||||
end
|
||||
|
||||
def revoke_all_consents
|
||||
@user = Current.session.user
|
||||
count = @user.oidc_user_consents.count
|
||||
consents = @user.oidc_user_consents.includes(:application)
|
||||
count = consents.count
|
||||
|
||||
if count > 0
|
||||
# Send backchannel logout notifications before revoking consents
|
||||
consents.each do |consent|
|
||||
next unless consent.application.supports_backchannel_logout?
|
||||
|
||||
BackchannelLogoutJob.perform_later(
|
||||
user_id: @user.id,
|
||||
application_id: consent.application.id,
|
||||
consent_sid: consent.sid
|
||||
)
|
||||
end
|
||||
Rails.logger.info "ActiveSessionsController: Enqueued #{count} backchannel logout notifications"
|
||||
|
||||
@user.oidc_user_consents.destroy_all
|
||||
redirect_to active_sessions_path, notice: "Successfully revoked access to #{count} applications."
|
||||
else
|
||||
|
||||
@@ -100,6 +100,7 @@ module Admin
|
||||
params.require(:application).permit(
|
||||
:name, :slug, :app_type, :active, :redirect_uris, :description, :metadata,
|
||||
:domain_pattern, :landing_url, :access_token_ttl, :refresh_token_ttl, :id_token_ttl,
|
||||
:icon, :backchannel_logout_uri,
|
||||
headers_config: {}
|
||||
).tap do |whitelisted|
|
||||
# Remove client_secret from params if present (shouldn't be updated via form)
|
||||
|
||||
@@ -18,7 +18,25 @@ module Admin
|
||||
end
|
||||
|
||||
def create
|
||||
@group = Group.new(group_params)
|
||||
create_params = group_params
|
||||
|
||||
# Parse custom_claims JSON if provided
|
||||
if create_params[:custom_claims].present?
|
||||
begin
|
||||
create_params[:custom_claims] = JSON.parse(create_params[:custom_claims])
|
||||
rescue JSON::ParserError
|
||||
@group = Group.new
|
||||
@group.errors.add(:custom_claims, "must be valid JSON")
|
||||
@available_users = User.order(:email_address)
|
||||
render :new, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
else
|
||||
# If empty or blank, set to empty hash (NOT NULL constraint)
|
||||
create_params[:custom_claims] = {}
|
||||
end
|
||||
|
||||
@group = Group.new(create_params)
|
||||
|
||||
if @group.save
|
||||
# Handle user assignments
|
||||
@@ -39,7 +57,24 @@ module Admin
|
||||
end
|
||||
|
||||
def update
|
||||
if @group.update(group_params)
|
||||
update_params = group_params
|
||||
|
||||
# Parse custom_claims JSON if provided
|
||||
if update_params[:custom_claims].present?
|
||||
begin
|
||||
update_params[:custom_claims] = JSON.parse(update_params[:custom_claims])
|
||||
rescue JSON::ParserError
|
||||
@group.errors.add(:custom_claims, "must be valid JSON")
|
||||
@available_users = User.order(:email_address)
|
||||
render :edit, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
else
|
||||
# If empty or blank, set to empty hash (NOT NULL constraint)
|
||||
update_params[:custom_claims] = {}
|
||||
end
|
||||
|
||||
if @group.update(update_params)
|
||||
# Handle user assignments
|
||||
if params[:group][:user_ids].present?
|
||||
user_ids = params[:group][:user_ids].reject(&:blank?)
|
||||
@@ -67,7 +102,7 @@ module Admin
|
||||
end
|
||||
|
||||
def group_params
|
||||
params.require(:group).permit(:name, :description, custom_claims: {})
|
||||
params.require(:group).permit(:name, :description, :custom_claims)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module Admin
|
||||
class UsersController < BaseController
|
||||
before_action :set_user, only: [:show, :edit, :update, :destroy, :resend_invitation]
|
||||
before_action :set_user, only: [:show, :edit, :update, :destroy, :resend_invitation, :update_application_claims, :delete_application_claims]
|
||||
|
||||
def index
|
||||
@users = User.order(created_at: :desc)
|
||||
@@ -27,6 +27,7 @@ module Admin
|
||||
end
|
||||
|
||||
def edit
|
||||
@applications = Application.active.order(:name)
|
||||
end
|
||||
|
||||
def update
|
||||
@@ -35,9 +36,25 @@ module Admin
|
||||
# Only update password if provided
|
||||
update_params.delete(:password) if update_params[:password].blank?
|
||||
|
||||
# Parse custom_claims JSON if provided
|
||||
if update_params[:custom_claims].present?
|
||||
begin
|
||||
update_params[:custom_claims] = JSON.parse(update_params[:custom_claims])
|
||||
rescue JSON::ParserError
|
||||
@user.errors.add(:custom_claims, "must be valid JSON")
|
||||
@applications = Application.active.order(:name)
|
||||
render :edit, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
else
|
||||
# If empty or blank, set to empty hash (NOT NULL constraint)
|
||||
update_params[:custom_claims] = {}
|
||||
end
|
||||
|
||||
if @user.update(update_params)
|
||||
redirect_to admin_users_path, notice: "User updated successfully."
|
||||
else
|
||||
@applications = Application.active.order(:name)
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
@@ -63,6 +80,41 @@ module Admin
|
||||
redirect_to admin_users_path, notice: "User deleted successfully."
|
||||
end
|
||||
|
||||
# POST /admin/users/:id/update_application_claims
|
||||
def update_application_claims
|
||||
application = Application.find(params[:application_id])
|
||||
|
||||
claims_json = params[:custom_claims].presence || "{}"
|
||||
begin
|
||||
claims = JSON.parse(claims_json)
|
||||
rescue JSON::ParserError
|
||||
redirect_to edit_admin_user_path(@user), alert: "Invalid JSON format for claims."
|
||||
return
|
||||
end
|
||||
|
||||
app_claim = @user.application_user_claims.find_or_initialize_by(application: application)
|
||||
app_claim.custom_claims = claims
|
||||
|
||||
if app_claim.save
|
||||
redirect_to edit_admin_user_path(@user), notice: "App-specific claims updated for #{application.name}."
|
||||
else
|
||||
error_message = app_claim.errors.full_messages.join(", ")
|
||||
redirect_to edit_admin_user_path(@user), alert: "Failed to update claims: #{error_message}"
|
||||
end
|
||||
end
|
||||
|
||||
# DELETE /admin/users/:id/delete_application_claims
|
||||
def delete_application_claims
|
||||
application = Application.find(params[:application_id])
|
||||
app_claim = @user.application_user_claims.find_by(application: application)
|
||||
|
||||
if app_claim&.destroy
|
||||
redirect_to edit_admin_user_path(@user), notice: "App-specific claims removed for #{application.name}."
|
||||
else
|
||||
redirect_to edit_admin_user_path(@user), alert: "No claims found to remove."
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_user
|
||||
@@ -71,7 +123,7 @@ module Admin
|
||||
|
||||
def user_params
|
||||
# Base attributes that all admins can modify
|
||||
base_params = params.require(:user).permit(:email_address, :name, :password, :status, :totp_required, custom_claims: {})
|
||||
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
|
||||
|
||||
@@ -3,7 +3,7 @@ module Api
|
||||
# ForwardAuth endpoints need session storage for return URL
|
||||
allow_unauthenticated_access
|
||||
skip_before_action :verify_authenticity_token
|
||||
rate_limit to: 100, within: 1.minute, only: :verify, with: -> { head :too_many_requests }
|
||||
# No rate limiting on forward_auth endpoint - proxy middleware hits this frequently
|
||||
|
||||
# GET /api/verify
|
||||
# This endpoint is called by reverse proxies (Traefik, Caddy, nginx)
|
||||
|
||||
@@ -20,10 +20,12 @@ class OidcController < ApplicationController
|
||||
grant_types_supported: ["authorization_code", "refresh_token"],
|
||||
subject_types_supported: ["public"],
|
||||
id_token_signing_alg_values_supported: ["RS256"],
|
||||
scopes_supported: ["openid", "profile", "email", "groups"],
|
||||
scopes_supported: ["openid", "profile", "email", "groups", "offline_access"],
|
||||
token_endpoint_auth_methods_supported: ["client_secret_post", "client_secret_basic"],
|
||||
claims_supported: ["sub", "email", "email_verified", "name", "preferred_username", "groups", "admin"],
|
||||
code_challenge_methods_supported: ["plain", "S256"]
|
||||
code_challenge_methods_supported: ["plain", "S256"],
|
||||
backchannel_logout_supported: true,
|
||||
backchannel_logout_session_supported: true
|
||||
}
|
||||
|
||||
render json: config
|
||||
@@ -534,9 +536,6 @@ class OidcController < ApplicationController
|
||||
claims[:groups] = user.groups.pluck(:name)
|
||||
end
|
||||
|
||||
# Add admin claim if user is admin
|
||||
claims[:admin] = true if user.admin?
|
||||
|
||||
# Merge custom claims from groups
|
||||
user.groups.each do |group|
|
||||
claims.merge!(group.parsed_custom_claims)
|
||||
@@ -545,6 +544,10 @@ class OidcController < ApplicationController
|
||||
# Merge custom claims from user (overrides group claims)
|
||||
claims.merge!(user.parsed_custom_claims)
|
||||
|
||||
# Merge app-specific custom claims (highest priority)
|
||||
application = access_token.application
|
||||
claims.merge!(application.custom_claims_for_user(user))
|
||||
|
||||
render json: claims
|
||||
end
|
||||
|
||||
@@ -626,6 +629,11 @@ class OidcController < ApplicationController
|
||||
|
||||
# If user is authenticated, log them out
|
||||
if authenticated?
|
||||
user = Current.session.user
|
||||
|
||||
# Send backchannel logout notifications to all connected applications
|
||||
send_backchannel_logout_notifications(user)
|
||||
|
||||
# Invalidate the current session
|
||||
Current.session&.destroy
|
||||
reset_session
|
||||
@@ -765,4 +773,26 @@ class OidcController < ApplicationController
|
||||
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
|
||||
|
||||
@@ -11,7 +11,7 @@ class PasswordsController < ApplicationController
|
||||
PasswordsMailer.reset(user).deliver_later
|
||||
end
|
||||
|
||||
redirect_to new_session_path, notice: "Password reset instructions sent (if user with that email address exists)."
|
||||
redirect_to signin_path, notice: "Password reset instructions sent (if user with that email address exists)."
|
||||
end
|
||||
|
||||
def edit
|
||||
@@ -20,7 +20,7 @@ class PasswordsController < ApplicationController
|
||||
def update
|
||||
if @user.update(params.permit(:password, :password_confirmation))
|
||||
@user.sessions.destroy_all
|
||||
redirect_to new_session_path, notice: "Password has been reset."
|
||||
redirect_to signin_path, notice: "Password has been reset."
|
||||
else
|
||||
redirect_to edit_password_path(params[:token]), alert: "Passwords did not match."
|
||||
end
|
||||
@@ -29,6 +29,7 @@ class PasswordsController < ApplicationController
|
||||
private
|
||||
def set_user_by_token
|
||||
@user = User.find_by_token_for(:password_reset, params[:token])
|
||||
redirect_to new_password_path, alert: "Password reset link is invalid or has expired." if @user.nil?
|
||||
rescue ActiveSupport::MessageVerifier::InvalidSignature
|
||||
redirect_to new_password_path, alert: "Password reset link is invalid or has expired."
|
||||
end
|
||||
|
||||
@@ -134,6 +134,12 @@ class SessionsController < ApplicationController
|
||||
end
|
||||
|
||||
def destroy
|
||||
# Send backchannel logout notifications before terminating session
|
||||
if authenticated?
|
||||
user = Current.session.user
|
||||
send_backchannel_logout_notifications(user)
|
||||
end
|
||||
|
||||
terminate_session
|
||||
redirect_to signin_path, status: :see_other, notice: "Signed out successfully."
|
||||
end
|
||||
@@ -311,4 +317,26 @@ class SessionsController < ApplicationController
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def send_backchannel_logout_notifications(user)
|
||||
# Find all active OIDC consents for this user
|
||||
consents = OidcUserConsent.where(user: user).includes(:application)
|
||||
|
||||
consents.each do |consent|
|
||||
# Skip if application doesn't support backchannel logout
|
||||
next unless consent.application.supports_backchannel_logout?
|
||||
|
||||
# Enqueue background job to send logout notification
|
||||
BackchannelLogoutJob.perform_later(
|
||||
user_id: user.id,
|
||||
application_id: consent.application.id,
|
||||
consent_sid: consent.sid
|
||||
)
|
||||
end
|
||||
|
||||
Rails.logger.info "SessionsController: Enqueued #{consents.count} backchannel logout notifications for user #{user.id}"
|
||||
rescue => e
|
||||
# Log error but don't block logout
|
||||
Rails.logger.error "SessionsController: Failed to enqueue backchannel logout: #{e.class} - #{e.message}"
|
||||
end
|
||||
end
|
||||
|
||||
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]
|
||||
}
|
||||
}
|
||||
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
|
||||
@@ -1,8 +1,14 @@
|
||||
class Application < ApplicationRecord
|
||||
has_secure_password :client_secret, validations: false
|
||||
|
||||
has_one_attached :icon
|
||||
|
||||
# Fix SVG content type after attachment
|
||||
after_save :fix_icon_content_type, if: -> { icon.attached? && saved_change_to_attribute?(:id) == false }
|
||||
|
||||
has_many :application_groups, dependent: :destroy
|
||||
has_many :allowed_groups, through: :application_groups, source: :group
|
||||
has_many :application_user_claims, dependent: :destroy
|
||||
has_many :oidc_authorization_codes, dependent: :destroy
|
||||
has_many :oidc_access_tokens, dependent: :destroy
|
||||
has_many :oidc_refresh_tokens, dependent: :destroy
|
||||
@@ -17,6 +23,15 @@ class Application < ApplicationRecord
|
||||
validates :client_secret, presence: true, on: :create, if: -> { oidc? }
|
||||
validates :domain_pattern, presence: true, uniqueness: { case_sensitive: false }, if: :forward_auth?
|
||||
validates :landing_url, format: { with: URI::regexp(%w[http https]), allow_nil: true, message: "must be a valid URL" }
|
||||
validates :backchannel_logout_uri, format: {
|
||||
with: URI::regexp(%w[http https]),
|
||||
allow_nil: true,
|
||||
message: "must be a valid HTTP or HTTPS URL"
|
||||
}
|
||||
validate :backchannel_logout_uri_must_be_https_in_production, if: -> { backchannel_logout_uri.present? }
|
||||
|
||||
# Icon validation using ActiveStorage validators
|
||||
validate :icon_validation, if: -> { icon.attached? }
|
||||
|
||||
# Token TTL validations (for OIDC apps)
|
||||
validates :access_token_ttl, numericality: { greater_than_or_equal_to: 300, less_than_or_equal_to: 86400 }, if: :oidc? # 5 min - 24 hours
|
||||
@@ -28,6 +43,10 @@ class Application < ApplicationRecord
|
||||
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?
|
||||
|
||||
@@ -186,8 +205,50 @@ class Application < ApplicationRecord
|
||||
duration_to_human(id_token_ttl || 3600)
|
||||
end
|
||||
|
||||
# Get app-specific custom claims for a user
|
||||
def custom_claims_for_user(user)
|
||||
app_claim = application_user_claims.find_by(user: user)
|
||||
app_claim&.parsed_custom_claims || {}
|
||||
end
|
||||
|
||||
# Check if this application supports backchannel logout
|
||||
def supports_backchannel_logout?
|
||||
backchannel_logout_uri.present?
|
||||
end
|
||||
|
||||
# Check if a user has an active session with this application
|
||||
# (i.e., has valid, non-revoked tokens)
|
||||
def user_has_active_session?(user)
|
||||
oidc_access_tokens.where(user: user).valid.exists? ||
|
||||
oidc_refresh_tokens.where(user: user).valid.exists?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fix_icon_content_type
|
||||
return unless icon.attached?
|
||||
|
||||
# Fix SVG content type if it was detected incorrectly
|
||||
if icon.filename.extension == "svg" && icon.content_type == "application/octet-stream"
|
||||
icon.blob.update(content_type: "image/svg+xml")
|
||||
end
|
||||
end
|
||||
|
||||
def icon_validation
|
||||
return unless icon.attached?
|
||||
|
||||
# Check content type
|
||||
allowed_types = ['image/png', 'image/jpg', 'image/jpeg', 'image/gif', 'image/svg+xml']
|
||||
unless allowed_types.include?(icon.content_type)
|
||||
errors.add(:icon, 'must be a PNG, JPG, GIF, or SVG image')
|
||||
end
|
||||
|
||||
# Check file size (2MB limit)
|
||||
if icon.blob.byte_size > 2.megabytes
|
||||
errors.add(:icon, 'must be less than 2MB')
|
||||
end
|
||||
end
|
||||
|
||||
def duration_to_human(seconds)
|
||||
if seconds < 3600
|
||||
"#{seconds / 60} minutes"
|
||||
@@ -206,4 +267,18 @@ class Application < ApplicationRecord
|
||||
self.client_secret = secret
|
||||
end
|
||||
end
|
||||
|
||||
def backchannel_logout_uri_must_be_https_in_production
|
||||
return unless Rails.env.production?
|
||||
return unless backchannel_logout_uri.present?
|
||||
|
||||
begin
|
||||
uri = URI.parse(backchannel_logout_uri)
|
||||
unless uri.scheme == 'https'
|
||||
errors.add(:backchannel_logout_uri, 'must use HTTPS in production')
|
||||
end
|
||||
rescue URI::InvalidURIError
|
||||
# Let the format validator handle invalid URIs
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
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 :applications, through: :application_groups
|
||||
|
||||
# Reserved OIDC claim names that should not be overridden
|
||||
RESERVED_CLAIMS = %w[
|
||||
iss sub aud exp iat nbf jti nonce azp
|
||||
email email_verified preferred_username name
|
||||
groups
|
||||
].freeze
|
||||
|
||||
validates :name, presence: true, uniqueness: { case_sensitive: false }
|
||||
normalizes :name, with: ->(name) { name.strip.downcase }
|
||||
validate :no_reserved_claim_names
|
||||
|
||||
# Parse custom_claims JSON field
|
||||
def parsed_custom_claims
|
||||
custom_claims || {}
|
||||
return {} if custom_claims.blank?
|
||||
custom_claims.is_a?(Hash) ? custom_claims : {}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def no_reserved_claim_names
|
||||
return if custom_claims.blank?
|
||||
|
||||
reserved_used = parsed_custom_claims.keys.map(&:to_s) & RESERVED_CLAIMS
|
||||
if reserved_used.any?
|
||||
errors.add(:custom_claims, "cannot override reserved OIDC claims: #{reserved_used.join(', ')}")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,6 +3,7 @@ class User < ApplicationRecord
|
||||
has_many :sessions, dependent: :destroy
|
||||
has_many :user_groups, dependent: :destroy
|
||||
has_many :groups, through: :user_groups
|
||||
has_many :application_user_claims, dependent: :destroy
|
||||
has_many :oidc_user_consents, dependent: :destroy
|
||||
has_many :webauthn_credentials, dependent: :destroy
|
||||
|
||||
@@ -20,10 +21,22 @@ class User < ApplicationRecord
|
||||
end
|
||||
|
||||
normalizes :email_address, with: ->(e) { e.strip.downcase }
|
||||
normalizes :username, with: ->(u) { u.strip.downcase if u.present? }
|
||||
|
||||
# Reserved OIDC claim names that should not be overridden
|
||||
RESERVED_CLAIMS = %w[
|
||||
iss sub aud exp iat nbf jti nonce azp
|
||||
email email_verified preferred_username name
|
||||
groups
|
||||
].freeze
|
||||
|
||||
validates :email_address, presence: true, uniqueness: { case_sensitive: false },
|
||||
format: { with: URI::MailTo::EMAIL_REGEXP }
|
||||
validates :username, uniqueness: { case_sensitive: false }, allow_nil: true,
|
||||
format: { with: /\A[a-zA-Z0-9_-]+\z/, message: "can only contain letters, numbers, underscores, and hyphens" },
|
||||
length: { minimum: 2, maximum: 30 }
|
||||
validates :password, length: { minimum: 8 }, allow_nil: true
|
||||
validate :no_reserved_claim_names
|
||||
|
||||
# Enum - automatically creates scopes (User.active, User.disabled, etc.)
|
||||
enum :status, { active: 0, disabled: 1, pending_invitation: 2 }
|
||||
@@ -182,11 +195,39 @@ class User < ApplicationRecord
|
||||
|
||||
# Parse custom_claims JSON field
|
||||
def parsed_custom_claims
|
||||
custom_claims || {}
|
||||
return {} if custom_claims.blank?
|
||||
custom_claims.is_a?(Hash) ? custom_claims : {}
|
||||
end
|
||||
|
||||
# Get fully merged claims for a specific application
|
||||
def merged_claims_for_application(application)
|
||||
merged = {}
|
||||
|
||||
# Start with group claims (in order)
|
||||
groups.each do |group|
|
||||
merged.merge!(group.parsed_custom_claims)
|
||||
end
|
||||
|
||||
# Merge user global claims
|
||||
merged.merge!(parsed_custom_claims)
|
||||
|
||||
# Merge app-specific claims (highest priority)
|
||||
merged.merge!(application.custom_claims_for_user(self))
|
||||
|
||||
merged
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def no_reserved_claim_names
|
||||
return if custom_claims.blank?
|
||||
|
||||
reserved_used = parsed_custom_claims.keys.map(&:to_s) & RESERVED_CLAIMS
|
||||
if reserved_used.any?
|
||||
errors.add(:custom_claims, "cannot override reserved OIDC claims: #{reserved_used.join(', ')}")
|
||||
end
|
||||
end
|
||||
|
||||
def generate_backup_codes
|
||||
# Generate plain codes for user to see/save
|
||||
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,4 +1,6 @@
|
||||
class OidcJwtService
|
||||
extend ClaimsMerger
|
||||
|
||||
class << self
|
||||
# Generate an ID token (JWT) for the user
|
||||
def generate_id_token(user, application, consent: nil, nonce: nil)
|
||||
@@ -17,7 +19,7 @@ class OidcJwtService
|
||||
iat: now,
|
||||
email: user.email_address,
|
||||
email_verified: true,
|
||||
preferred_username: user.email_address,
|
||||
preferred_username: user.username.presence || user.email_address,
|
||||
name: user.name.presence || user.email_address
|
||||
}
|
||||
|
||||
@@ -29,17 +31,41 @@ class OidcJwtService
|
||||
payload[:groups] = user.groups.pluck(:name)
|
||||
end
|
||||
|
||||
# Add admin claim if user is admin
|
||||
payload[:admin] = true if user.admin?
|
||||
|
||||
# Merge custom claims from groups
|
||||
# Merge custom claims from groups (arrays are combined, not overwritten)
|
||||
user.groups.each do |group|
|
||||
payload.merge!(group.parsed_custom_claims)
|
||||
payload = deep_merge_claims(payload, group.parsed_custom_claims)
|
||||
end
|
||||
|
||||
# Merge custom claims from user (overrides group claims)
|
||||
payload.merge!(user.parsed_custom_claims)
|
||||
# Merge custom claims from user (arrays are combined, other values override)
|
||||
payload = deep_merge_claims(payload, user.parsed_custom_claims)
|
||||
|
||||
# Merge app-specific custom claims (highest priority, arrays are combined)
|
||||
payload = deep_merge_claims(payload, application.custom_claims_for_user(user))
|
||||
|
||||
JWT.encode(payload, private_key, "RS256", { kid: key_id, typ: "JWT" })
|
||||
end
|
||||
|
||||
# Generate a backchannel logout token (JWT)
|
||||
# Per OIDC Back-Channel Logout spec, this token:
|
||||
# - MUST include iss, aud, iat, jti, events claims
|
||||
# - MUST include sub or sid (or both) - we always include both
|
||||
# - MUST NOT include nonce claim
|
||||
def generate_logout_token(user, application, consent)
|
||||
now = Time.current.to_i
|
||||
|
||||
payload = {
|
||||
iss: issuer_url,
|
||||
sub: consent.sid, # Pairwise subject identifier
|
||||
aud: application.client_id,
|
||||
iat: now,
|
||||
jti: SecureRandom.uuid, # Unique identifier for this logout token
|
||||
sid: consent.sid, # Session ID - always included for granular logout
|
||||
events: {
|
||||
"http://schemas.openid.net/event/backchannel-logout" => {}
|
||||
}
|
||||
}
|
||||
|
||||
# Important: Do NOT include nonce in logout tokens (spec requirement)
|
||||
JWT.encode(payload, private_key, "RS256", { kid: key_id, typ: "JWT" })
|
||||
end
|
||||
|
||||
|
||||
@@ -17,6 +17,87 @@
|
||||
<%= form.text_area :description, rows: 3, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Optional description of this application" %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<%= form.label :icon, "Application Icon", class: "block text-sm font-medium text-gray-700" %>
|
||||
<a href="https://dashboardicons.com" target="_blank" rel="noopener noreferrer" class="text-xs text-blue-600 hover:text-blue-800 flex items-center gap-1">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path>
|
||||
</svg>
|
||||
Browse icons at dashboardicons.com
|
||||
</a>
|
||||
</div>
|
||||
<% if application.icon.attached? && application.persisted? %>
|
||||
<% begin %>
|
||||
<%# Only show icon if we can successfully get its URL (blob is persisted) %>
|
||||
<% if application.icon.blob&.persisted? && application.icon.blob.key.present? %>
|
||||
<div class="mt-2 mb-3 flex items-center gap-4">
|
||||
<%= image_tag application.icon, class: "h-16 w-16 rounded-lg object-cover border border-gray-200", alt: "Current icon" %>
|
||||
<div class="text-sm text-gray-600">
|
||||
<p class="font-medium">Current icon</p>
|
||||
<p class="text-xs"><%= number_to_human_size(application.icon.blob.byte_size) %></p>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% rescue ArgumentError => e %>
|
||||
<%# Handle case where icon attachment exists but can't generate signed_id %>
|
||||
<% if e.message.include?("Cannot get a signed_id for a new record") %>
|
||||
<div class="mt-2 mb-3 text-sm text-gray-600">
|
||||
<p class="font-medium">Icon uploaded</p>
|
||||
<p class="text-xs">File will be processed shortly</p>
|
||||
</div>
|
||||
<% else %>
|
||||
<%# Re-raise if it's a different error %>
|
||||
<% raise e %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<div class="mt-2" data-controller="file-drop image-paste">
|
||||
<div class="flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 border-dashed rounded-md hover:border-blue-400 transition-colors"
|
||||
data-file-drop-target="dropzone"
|
||||
data-image-paste-target="dropzone"
|
||||
data-action="dragover->file-drop#dragover dragleave->file-drop#dragleave drop->file-drop#drop paste->image-paste#handlePaste"
|
||||
tabindex="0">
|
||||
<div class="space-y-1 text-center">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400" stroke="currentColor" fill="none" viewBox="0 0 48 48">
|
||||
<path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
<div class="flex text-sm text-gray-600">
|
||||
<label for="<%= form.field_id(:icon) %>" class="relative cursor-pointer bg-white rounded-md font-medium text-blue-600 hover:text-blue-500 focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2 focus-within:ring-blue-500">
|
||||
<span>Upload a file</span>
|
||||
<%= form.file_field :icon,
|
||||
accept: "image/png,image/jpg,image/jpeg,image/gif,image/svg+xml",
|
||||
class: "sr-only",
|
||||
data: {
|
||||
file_drop_target: "input",
|
||||
image_paste_target: "input",
|
||||
action: "change->file-drop#handleFiles"
|
||||
} %>
|
||||
</label>
|
||||
<p class="pl-1">or drag and drop</p>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500">PNG, JPG, GIF, or SVG up to 2MB</p>
|
||||
<p class="text-xs text-blue-600 font-medium mt-2">💡 Tip: Click here and press Ctrl+V (or Cmd+V) to paste an image from your clipboard</p>
|
||||
</div>
|
||||
</div>
|
||||
<div data-file-drop-target="preview" class="mt-3 hidden">
|
||||
<div class="flex items-center gap-3 p-3 bg-blue-50 rounded-md border border-blue-200">
|
||||
<img data-file-drop-target="previewImage" class="h-12 w-12 rounded object-cover" alt="Preview">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900" data-file-drop-target="filename"></p>
|
||||
<p class="text-xs text-gray-500" data-file-drop-target="filesize"></p>
|
||||
</div>
|
||||
<button type="button" data-action="click->file-drop#clear" class="text-gray-400 hover:text-gray-600">
|
||||
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form.label :landing_url, "Landing URL", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.url_field :landing_url, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "https://app.example.com" %>
|
||||
@@ -45,6 +126,16 @@
|
||||
<p class="mt-1 text-sm text-gray-500">One URI per line. These are the allowed callback URLs for your application.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form.label :backchannel_logout_uri, "Backchannel Logout URI (Optional)", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.url_field :backchannel_logout_uri, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono", placeholder: "https://app.example.com/oidc/backchannel-logout" %>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
If the application supports OpenID Connect Backchannel Logout, enter the logout endpoint URL.
|
||||
When users log out, Clinch will send logout notifications to this endpoint for immediate session termination.
|
||||
Leave blank if the application doesn't support backchannel logout.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-200 pt-4 mt-4">
|
||||
<h4 class="text-sm font-semibold text-gray-900 mb-3">Token Expiration Settings</h4>
|
||||
<p class="text-sm text-gray-500 mb-4">Configure how long tokens remain valid. Shorter times are more secure but require more frequent refreshes.</p>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<table class="min-w-full divide-y divide-gray-300">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-0">Name</th>
|
||||
<th scope="col" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-0">Application</th>
|
||||
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Slug</th>
|
||||
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Type</th>
|
||||
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Status</th>
|
||||
@@ -28,7 +28,18 @@
|
||||
<% @applications.each do |application| %>
|
||||
<tr>
|
||||
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-0">
|
||||
<div class="flex items-center gap-3">
|
||||
<% if application.icon.attached? %>
|
||||
<%= image_tag application.icon, class: "h-10 w-10 rounded-lg object-cover border border-gray-200 flex-shrink-0", alt: "#{application.name} icon" %>
|
||||
<% else %>
|
||||
<div class="h-10 w-10 rounded-lg bg-gray-100 border border-gray-200 flex items-center justify-center flex-shrink-0">
|
||||
<svg class="h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= link_to application.name, admin_application_path(application), class: "text-blue-600 hover:text-blue-900" %>
|
||||
</div>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||
<code class="text-xs bg-gray-100 px-2 py-1 rounded"><%= application.slug %></code>
|
||||
|
||||
@@ -16,11 +16,22 @@
|
||||
</div>
|
||||
<% 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>
|
||||
<h1 class="text-2xl font-semibold text-gray-900"><%= @application.name %></h1>
|
||||
<p class="mt-1 text-sm text-gray-500"><%= @application.description %></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 sm:mt-0 flex gap-3">
|
||||
<%= link_to "Edit", edit_admin_application_path(@application), class: "rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50" %>
|
||||
<%= 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="px-4 py-5 sm:p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-base font-semibold leading-6 text-gray-900">OIDC Credentials</h3>
|
||||
<h3 class="text-base font-semibold leading-6 text-gray-900">OIDC Configuration</h3>
|
||||
<%= button_to "Regenerate Credentials", regenerate_credentials_admin_application_path(@application), method: :post, data: { turbo_confirm: "This will invalidate the current credentials. Continue?" }, class: "text-sm text-red-600 hover:text-red-900" %>
|
||||
</div>
|
||||
<dl class="space-y-4">
|
||||
<% unless flash[:client_id] && flash[:client_secret] %>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Client ID</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">
|
||||
@@ -99,6 +111,7 @@
|
||||
</p>
|
||||
</dd>
|
||||
</div>
|
||||
<% end %>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Redirect URIs</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">
|
||||
@@ -111,6 +124,27 @@
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">
|
||||
Backchannel Logout URI
|
||||
<% if @application.supports_backchannel_logout? %>
|
||||
<span class="ml-2 inline-flex items-center rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700">Enabled</span>
|
||||
<% end %>
|
||||
</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">
|
||||
<% if @application.backchannel_logout_uri.present? %>
|
||||
<code class="block bg-gray-100 px-3 py-2 rounded font-mono text-xs break-all"><%= @application.backchannel_logout_uri %></code>
|
||||
<p class="mt-2 text-xs text-gray-500">
|
||||
When users log out, Clinch will send logout notifications to this endpoint for immediate session termination.
|
||||
</p>
|
||||
<% else %>
|
||||
<span class="text-gray-400 italic">Not configured</span>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
Backchannel logout is optional. Configure it if the application supports OpenID Connect Backchannel Logout.
|
||||
</p>
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
185
app/views/admin/users/_application_claims.html.erb
Normal file
185
app/views/admin/users/_application_claims.html.erb
Normal file
@@ -0,0 +1,185 @@
|
||||
<% oidc_apps = applications.select(&:oidc?) %>
|
||||
<% forward_auth_apps = applications.select(&:forward_auth?) %>
|
||||
|
||||
<!-- OIDC Apps: Custom Claims -->
|
||||
<% if oidc_apps.any? %>
|
||||
<div class="mt-12 border-t pt-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-4">OIDC App-Specific Claims</h2>
|
||||
<p class="text-sm text-gray-600 mb-6">
|
||||
Configure custom claims that apply only to specific OIDC applications. These override both group and user global claims and are included in ID tokens.
|
||||
</p>
|
||||
|
||||
<div class="space-y-6">
|
||||
<% oidc_apps.each do |app| %>
|
||||
<% app_claim = user.application_user_claims.find_by(application: app) %>
|
||||
<details class="border rounded-lg" <%= "open" if app_claim&.custom_claims&.any? %>>
|
||||
<summary class="cursor-pointer bg-gray-50 px-4 py-3 hover:bg-gray-100 rounded-t-lg flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="font-medium text-gray-900"><%= app.name %></span>
|
||||
<span class="text-xs px-2 py-1 rounded-full bg-blue-100 text-blue-700">
|
||||
OIDC
|
||||
</span>
|
||||
<% if app_claim&.custom_claims&.any? %>
|
||||
<span class="text-xs px-2 py-1 rounded-full bg-amber-100 text-amber-700">
|
||||
<%= app_claim.custom_claims.keys.count %> claim(s)
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<svg class="h-5 w-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</summary>
|
||||
|
||||
<div class="p-4 space-y-4">
|
||||
<%= form_with url: update_application_claims_admin_user_path(user), method: :post, class: "space-y-4", data: { controller: "json-validator" } do |form| %>
|
||||
<%= hidden_field_tag :application_id, app.id %>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Custom Claims (JSON)</label>
|
||||
<%= text_area_tag :custom_claims,
|
||||
(app_claim&.custom_claims.present? ? JSON.pretty_generate(app_claim.custom_claims) : ""),
|
||||
rows: 8,
|
||||
class: "w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono",
|
||||
placeholder: '{"kavita_groups": ["admin"], "library_access": "all"}',
|
||||
data: {
|
||||
action: "input->json-validator#validate blur->json-validator#format",
|
||||
json_validator_target: "textarea"
|
||||
} %>
|
||||
<div class="mt-2 space-y-1">
|
||||
<p class="text-xs text-gray-600">
|
||||
Example for <%= app.name %>: Add claims that this app specifically needs to read.
|
||||
</p>
|
||||
<p class="text-xs text-amber-600">
|
||||
<strong>Note:</strong> Do not use reserved claim names (<code class="bg-amber-50 px-1 rounded">groups</code>, <code class="bg-amber-50 px-1 rounded">email</code>, <code class="bg-amber-50 px-1 rounded">name</code>, etc.). Use app-specific names like <code class="bg-amber-50 px-1 rounded">kavita_groups</code> instead.
|
||||
</p>
|
||||
<div data-json-validator-target="status" class="text-xs font-medium"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<%= button_tag type: :submit, class: "rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500" do %>
|
||||
<%= app_claim ? "Update" : "Add" %> Claims
|
||||
<% end %>
|
||||
|
||||
<% if app_claim %>
|
||||
<%= button_to "Remove Override",
|
||||
delete_application_claims_admin_user_path(user, application_id: app.id),
|
||||
method: :delete,
|
||||
data: { turbo_confirm: "Remove app-specific claims for #{app.name}?" },
|
||||
class: "rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50" %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Preview merged claims -->
|
||||
<div class="mt-4 border-t pt-4">
|
||||
<h4 class="text-sm font-medium text-gray-700 mb-2">Preview: Final ID Token Claims for <%= app.name %></h4>
|
||||
<div class="bg-gray-50 rounded-lg p-3">
|
||||
<pre class="text-xs font-mono text-gray-800 overflow-x-auto"><%= JSON.pretty_generate(preview_user_claims(user, app)) %></pre>
|
||||
</div>
|
||||
|
||||
<details class="mt-2">
|
||||
<summary class="cursor-pointer text-xs text-gray-600 hover:text-gray-900">Show claim sources</summary>
|
||||
<div class="mt-2 space-y-1">
|
||||
<% claim_sources(user, app).each do |source| %>
|
||||
<div class="flex gap-2 items-start text-xs">
|
||||
<span class="px-2 py-1 rounded <%= source[:type] == :group ? 'bg-blue-100 text-blue-700' : (source[:type] == :user ? 'bg-green-100 text-green-700' : 'bg-amber-100 text-amber-700') %>">
|
||||
<%= source[:name] %>
|
||||
</span>
|
||||
<code class="text-gray-700"><%= source[:claims].to_json %></code>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- ForwardAuth Apps: Headers Preview -->
|
||||
<% if forward_auth_apps.any? %>
|
||||
<div class="mt-12 border-t pt-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-4">ForwardAuth Headers Preview</h2>
|
||||
<p class="text-sm text-gray-600 mb-6">
|
||||
ForwardAuth applications receive HTTP headers (not OIDC tokens). Headers are based on user's email, name, groups, and admin status.
|
||||
</p>
|
||||
|
||||
<div class="space-y-6">
|
||||
<% forward_auth_apps.each do |app| %>
|
||||
<details class="border rounded-lg">
|
||||
<summary class="cursor-pointer bg-gray-50 px-4 py-3 hover:bg-gray-100 rounded-t-lg flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="font-medium text-gray-900"><%= app.name %></span>
|
||||
<span class="text-xs px-2 py-1 rounded-full bg-green-100 text-green-700">
|
||||
FORWARD AUTH
|
||||
</span>
|
||||
<span class="text-xs text-gray-500">
|
||||
<%= app.domain_pattern %>
|
||||
</span>
|
||||
</div>
|
||||
<svg class="h-5 w-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</summary>
|
||||
|
||||
<div class="p-4 space-y-4">
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||
<div class="flex items-start">
|
||||
<svg class="h-5 w-5 text-blue-400 mr-2 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-gray-700 mb-2">Headers Sent to <%= app.name %></h4>
|
||||
<div class="bg-gray-50 rounded-lg p-3 border">
|
||||
<% headers = app.headers_for_user(user) %>
|
||||
<% if headers.any? %>
|
||||
<dl class="space-y-2 text-xs font-mono">
|
||||
<% headers.each do |header_name, value| %>
|
||||
<div class="flex">
|
||||
<dt class="text-blue-600 font-semibold w-48"><%= header_name %>:</dt>
|
||||
<dd class="text-gray-800 flex-1"><%= value %></dd>
|
||||
</div>
|
||||
<% end %>
|
||||
</dl>
|
||||
<% else %>
|
||||
<p class="text-xs text-gray-500 italic">All headers disabled for this application.</p>
|
||||
<% end %>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500">
|
||||
These headers are configured in the application settings and sent by your reverse proxy (Caddy/Traefik) to the upstream application.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<% if user.groups.any? %>
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-gray-700 mb-2">User's Groups</h4>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<% user.groups.each do |group| %>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
<%= group.name %>
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</details>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if oidc_apps.empty? && forward_auth_apps.empty? %>
|
||||
<div class="mt-12 border-t pt-8">
|
||||
<div class="text-center py-12 bg-gray-50 rounded-lg">
|
||||
<p class="text-gray-500">No active applications found.</p>
|
||||
<p class="text-sm text-gray-400 mt-1">Create applications in the Admin panel first.</p>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -6,10 +6,16 @@
|
||||
<%= form.email_field :email_address, required: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "user@example.com" %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form.label :username, "Username (Optional)", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.text_field :username, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "jsmith" %>
|
||||
<p class="mt-1 text-sm text-gray-500">Optional: Short username/handle for login. Can only contain letters, numbers, underscores, and hyphens.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form.label :name, "Display Name (Optional)", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.text_field :name, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "John Smith" %>
|
||||
<p class="mt-1 text-sm text-gray-500">Optional: Name shown in applications. Defaults to email address if not set.</p>
|
||||
<p class="mt-1 text-sm text-gray-500">Optional: Full name shown in applications. Defaults to email address if not set.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
<div class="max-w-2xl">
|
||||
<div class="max-w-4xl">
|
||||
<h1 class="text-2xl font-semibold text-gray-900 mb-6">Edit User</h1>
|
||||
<p class="text-sm text-gray-600 mb-6">Editing: <%= @user.email_address %></p>
|
||||
|
||||
<div class="max-w-2xl">
|
||||
<%= render "form", user: @user %>
|
||||
</div>
|
||||
|
||||
<% if @user.persisted? %>
|
||||
<%= render "application_claims", user: @user, applications: @applications %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
@@ -102,11 +102,22 @@
|
||||
<% @applications.each do |app| %>
|
||||
<div class="bg-white rounded-lg border border-gray-200 shadow-sm hover:shadow-md transition">
|
||||
<div class="p-6">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-start gap-3 mb-4">
|
||||
<% if app.icon.attached? %>
|
||||
<%= image_tag app.icon, class: "h-12 w-12 rounded-lg object-cover border border-gray-200 shrink-0", alt: "#{app.name} icon" %>
|
||||
<% else %>
|
||||
<div class="h-12 w-12 rounded-lg bg-gray-100 border border-gray-200 flex items-center justify-center shrink-0">
|
||||
<svg class="h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<% end %>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-start justify-between">
|
||||
<h3 class="text-lg font-semibold text-gray-900 truncate">
|
||||
<%= app.name %>
|
||||
</h3>
|
||||
<span class="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? %>
|
||||
bg-blue-100 text-blue-800
|
||||
<% else %>
|
||||
@@ -115,15 +126,15 @@
|
||||
<%= app.app_type.humanize %>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-gray-600 mb-4">
|
||||
<% if app.oidc? %>
|
||||
OIDC Application
|
||||
<% else %>
|
||||
ForwardAuth Protected Application
|
||||
<% end %>
|
||||
<% if app.description.present? %>
|
||||
<p class="text-sm text-gray-600 mt-1 line-clamp-2">
|
||||
<%= app.description %>
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<% if app.landing_url.present? %>
|
||||
<%= link_to "Open Application", app.landing_url,
|
||||
target: "_blank",
|
||||
@@ -134,6 +145,13 @@
|
||||
No landing URL configured
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if app.user_has_active_session?(@user) %>
|
||||
<%= button_to "Logout", logout_from_app_active_sessions_path(application_id: app.id), method: :delete,
|
||||
class: "w-full flex justify-center items-center px-4 py-2 border border-orange-300 text-sm font-medium rounded-md text-orange-700 bg-white hover:bg-orange-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-orange-500 transition",
|
||||
form: { data: { turbo_confirm: "This will log you out of #{app.name}. You can sign back in without re-authorizing. Continue?" } } %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
<div class="mx-auto max-w-md">
|
||||
<div class="bg-white py-8 px-6 shadow rounded-lg sm:px-10">
|
||||
<div class="mb-8">
|
||||
<div class="mb-8 text-center">
|
||||
<% if @application.icon.attached? %>
|
||||
<%= image_tag @application.icon, class: "mx-auto h-20 w-20 rounded-xl object-cover border-2 border-gray-200 shadow-sm mb-4", alt: "#{@application.name} icon" %>
|
||||
<% else %>
|
||||
<div class="mx-auto h-20 w-20 rounded-xl bg-gray-100 border-2 border-gray-200 flex items-center justify-center mb-4">
|
||||
<svg class="h-10 w-10 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<% end %>
|
||||
<h2 class="text-2xl font-bold text-gray-900">Authorize Application</h2>
|
||||
<p class="mt-2 text-sm text-gray-600">
|
||||
<strong><%= @application.name %></strong> is requesting access to your account.
|
||||
|
||||
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
|
||||
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
|
||||
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
|
||||
@@ -49,6 +49,7 @@ Rails.application.routes.draw do
|
||||
end
|
||||
resource :active_sessions, only: [:show] do
|
||||
member do
|
||||
delete :logout_from_app
|
||||
delete :revoke_consent
|
||||
delete :revoke_all_consents
|
||||
end
|
||||
@@ -82,6 +83,8 @@ Rails.application.routes.draw do
|
||||
resources :users do
|
||||
member do
|
||||
post :resend_invitation
|
||||
post :update_application_claims
|
||||
delete :delete_application_claims
|
||||
end
|
||||
end
|
||||
resources :applications do
|
||||
|
||||
@@ -4,7 +4,7 @@ test:
|
||||
|
||||
local:
|
||||
service: Disk
|
||||
root: <%= Rails.root.join("storage") %>
|
||||
root: <%= Rails.root.join("storage/uploads") %>
|
||||
|
||||
# Use bin/rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key)
|
||||
# amazon:
|
||||
|
||||
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
|
||||
48
db/schema.rb
generated
48
db/schema.rb
generated
@@ -10,7 +10,35 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.1].define(version: 2025_11_22_235519) do
|
||||
ActiveRecord::Schema[8.1].define(version: 2025_11_25_081147) do
|
||||
create_table "active_storage_attachments", force: :cascade do |t|
|
||||
t.bigint "blob_id", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.string "name", null: false
|
||||
t.bigint "record_id", null: false
|
||||
t.string "record_type", null: false
|
||||
t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id"
|
||||
t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true
|
||||
end
|
||||
|
||||
create_table "active_storage_blobs", force: :cascade do |t|
|
||||
t.bigint "byte_size", null: false
|
||||
t.string "checksum"
|
||||
t.string "content_type"
|
||||
t.datetime "created_at", null: false
|
||||
t.string "filename", null: false
|
||||
t.string "key", null: false
|
||||
t.text "metadata"
|
||||
t.string "service_name", null: false
|
||||
t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true
|
||||
end
|
||||
|
||||
create_table "active_storage_variant_records", force: :cascade do |t|
|
||||
t.bigint "blob_id", null: false
|
||||
t.string "variation_digest", null: false
|
||||
t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true
|
||||
end
|
||||
|
||||
create_table "application_groups", force: :cascade do |t|
|
||||
t.integer "application_id", null: false
|
||||
t.datetime "created_at", null: false
|
||||
@@ -21,10 +49,22 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_22_235519) do
|
||||
t.index ["group_id"], name: "index_application_groups_on_group_id"
|
||||
end
|
||||
|
||||
create_table "application_user_claims", force: :cascade do |t|
|
||||
t.integer "application_id", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.json "custom_claims", default: {}, null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.integer "user_id", null: false
|
||||
t.index ["application_id", "user_id"], name: "index_app_user_claims_unique", unique: true
|
||||
t.index ["application_id"], name: "index_application_user_claims_on_application_id"
|
||||
t.index ["user_id"], name: "index_application_user_claims_on_user_id"
|
||||
end
|
||||
|
||||
create_table "applications", force: :cascade do |t|
|
||||
t.integer "access_token_ttl", default: 3600
|
||||
t.boolean "active", default: true, null: false
|
||||
t.string "app_type", null: false
|
||||
t.string "backchannel_logout_uri"
|
||||
t.string "client_id"
|
||||
t.string "client_secret_digest"
|
||||
t.datetime "created_at", null: false
|
||||
@@ -169,10 +209,12 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_22_235519) do
|
||||
t.boolean "totp_required", default: false, null: false
|
||||
t.string "totp_secret"
|
||||
t.datetime "updated_at", null: false
|
||||
t.string "username"
|
||||
t.string "webauthn_id"
|
||||
t.boolean "webauthn_required", default: false, null: false
|
||||
t.index ["email_address"], name: "index_users_on_email_address", unique: true
|
||||
t.index ["status"], name: "index_users_on_status"
|
||||
t.index ["username"], name: "index_users_on_username", unique: true
|
||||
t.index ["webauthn_id"], name: "index_users_on_webauthn_id", unique: true
|
||||
end
|
||||
|
||||
@@ -198,8 +240,12 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_22_235519) do
|
||||
t.index ["user_id"], name: "index_webauthn_credentials_on_user_id"
|
||||
end
|
||||
|
||||
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
|
||||
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
|
||||
add_foreign_key "application_groups", "applications"
|
||||
add_foreign_key "application_groups", "groups"
|
||||
add_foreign_key "application_user_claims", "applications", on_delete: :cascade
|
||||
add_foreign_key "application_user_claims", "users", on_delete: :cascade
|
||||
add_foreign_key "oidc_access_tokens", "applications"
|
||||
add_foreign_key "oidc_access_tokens", "users"
|
||||
add_foreign_key "oidc_authorization_codes", "applications"
|
||||
|
||||
@@ -19,8 +19,9 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
||||
end
|
||||
|
||||
def teardown
|
||||
OidcAuthorizationCode.where(application: @application).destroy_all
|
||||
OidcAccessToken.where(application: @application).destroy_all
|
||||
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
|
||||
|
||||
@@ -11,7 +11,7 @@ class PasswordsControllerTest < ActionDispatch::IntegrationTest
|
||||
test "create" do
|
||||
post passwords_path, params: { email_address: @user.email_address }
|
||||
assert_enqueued_email_with PasswordsMailer, :reset, args: [ @user ]
|
||||
assert_redirected_to new_session_path
|
||||
assert_redirected_to signin_path
|
||||
|
||||
follow_redirect!
|
||||
assert_notice "reset instructions sent"
|
||||
@@ -20,14 +20,14 @@ class PasswordsControllerTest < ActionDispatch::IntegrationTest
|
||||
test "create for an unknown user redirects but sends no mail" do
|
||||
post passwords_path, params: { email_address: "missing-user@example.com" }
|
||||
assert_enqueued_emails 0
|
||||
assert_redirected_to new_session_path
|
||||
assert_redirected_to signin_path
|
||||
|
||||
follow_redirect!
|
||||
assert_notice "reset instructions sent"
|
||||
end
|
||||
|
||||
test "edit" do
|
||||
get edit_password_path(@user.password_reset_token)
|
||||
get edit_password_path(@user.generate_token_for(:password_reset))
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
@@ -41,8 +41,8 @@ class PasswordsControllerTest < ActionDispatch::IntegrationTest
|
||||
|
||||
test "update" do
|
||||
assert_changes -> { @user.reload.password_digest } do
|
||||
put password_path(@user.password_reset_token), params: { password: "new", password_confirmation: "new" }
|
||||
assert_redirected_to new_session_path
|
||||
put password_path(@user.generate_token_for(:password_reset)), params: { password: "newpassword", password_confirmation: "newpassword" }
|
||||
assert_redirected_to signin_path
|
||||
end
|
||||
|
||||
follow_redirect!
|
||||
|
||||
@@ -18,7 +18,7 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest
|
||||
test "create with invalid credentials" do
|
||||
post session_path, params: { email_address: @user.email_address, password: "wrong" }
|
||||
|
||||
assert_redirected_to new_session_path
|
||||
assert_redirected_to signin_path
|
||||
assert_nil cookies[:session_id]
|
||||
end
|
||||
|
||||
@@ -27,7 +27,7 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest
|
||||
|
||||
delete session_path
|
||||
|
||||
assert_redirected_to new_session_path
|
||||
assert_redirected_to signin_path
|
||||
assert_empty cookies[:session_id]
|
||||
end
|
||||
end
|
||||
|
||||
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
|
||||
metadata: "{}"
|
||||
active: true
|
||||
|
||||
audiobookshelf_app:
|
||||
name: Audiobookshelf
|
||||
slug: audiobookshelf
|
||||
app_type: oidc
|
||||
client_id: <%= SecureRandom.urlsafe_base64(32) %>
|
||||
client_secret_digest: <%= BCrypt::Password.create(SecureRandom.urlsafe_base64(48)) %>
|
||||
redirect_uris: |
|
||||
https://abs.example.com/auth/openid/callback
|
||||
metadata: "{}"
|
||||
active: true
|
||||
|
||||
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
|
||||
|
||||
one:
|
||||
name: Group One
|
||||
description: First test group
|
||||
|
||||
two:
|
||||
name: Group Two
|
||||
description: Second test group
|
||||
|
||||
admin_group:
|
||||
name: Administrators
|
||||
description: System administrators with full access
|
||||
|
||||
12
test/fixtures/users.yml
vendored
12
test/fixtures/users.yml
vendored
@@ -1,5 +1,17 @@
|
||||
<% password_digest = BCrypt::Password.create("password") %>
|
||||
|
||||
one:
|
||||
email_address: one@example.com
|
||||
password_digest: <%= password_digest %>
|
||||
admin: false
|
||||
status: 0 # active
|
||||
|
||||
two:
|
||||
email_address: two@example.com
|
||||
password_digest: <%= password_digest %>
|
||||
admin: true
|
||||
status: 0 # active
|
||||
|
||||
alice:
|
||||
email_address: alice@example.com
|
||||
password_digest: <%= password_digest %>
|
||||
|
||||
@@ -58,8 +58,8 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
|
||||
# Domain and Rule Integration Tests
|
||||
test "different domain patterns with same session" do
|
||||
# Create test rules
|
||||
wildcard_rule = ForwardAuthRule.create!(domain_pattern: "*.example.com", active: true)
|
||||
exact_rule = ForwardAuthRule.create!(domain_pattern: "api.example.com", active: true)
|
||||
wildcard_rule = Application.create!(domain_pattern: "*.example.com", active: true)
|
||||
exact_rule = Application.create!(domain_pattern: "api.example.com", active: true)
|
||||
|
||||
# Sign in
|
||||
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
|
||||
# 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
|
||||
|
||||
# Sign in user without group
|
||||
@@ -104,17 +104,19 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
|
||||
|
||||
# Header Configuration Integration Tests
|
||||
test "different header configurations with same user" do
|
||||
# Create rules with different header configs
|
||||
default_rule = ForwardAuthRule.create!(domain_pattern: "default.example.com", active: true)
|
||||
custom_rule = ForwardAuthRule.create!(
|
||||
# Create applications with different configs
|
||||
default_rule = Application.create!(name: "Default App", slug: "default-app", app_type: "forward_auth", domain_pattern: "default.example.com", active: true)
|
||||
custom_rule = Application.create!(
|
||||
name: "Custom App", slug: "custom-app", app_type: "forward_auth",
|
||||
domain_pattern: "custom.example.com",
|
||||
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",
|
||||
active: true,
|
||||
headers_config: { user: "", email: "", name: "", groups: "", admin: "" }
|
||||
metadata: { headers: { user: "", email: "", name: "", groups: "", admin: "" } }.to_json
|
||||
)
|
||||
|
||||
# Add user to groups
|
||||
@@ -191,7 +193,7 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
|
||||
admin_user = users(:two)
|
||||
|
||||
# Create restricted rule
|
||||
admin_rule = ForwardAuthRule.create!(
|
||||
admin_rule = Application.create!(
|
||||
domain_pattern: "admin.example.com",
|
||||
active: true,
|
||||
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 [@user.email_address], email.to
|
||||
assert_equal [], email.cc
|
||||
assert_equal [], email.bcc
|
||||
assert_equal [], email.cc || []
|
||||
assert_equal [], email.bcc || []
|
||||
# From address is configured in ApplicationMailer
|
||||
assert_not_nil email.from
|
||||
assert email.from.is_a?(Array)
|
||||
|
||||
@@ -25,8 +25,8 @@ class PasswordsMailerTest < ActionMailer::TestCase
|
||||
|
||||
assert_equal "Reset your password", email.subject
|
||||
assert_equal [@user.email_address], email.to
|
||||
assert_equal [], email.cc
|
||||
assert_equal [], email.bcc
|
||||
assert_equal [], email.cc || []
|
||||
assert_equal [], email.bcc || []
|
||||
# From address is configured in ApplicationMailer
|
||||
assert_not_nil email.from
|
||||
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
|
||||
@@ -14,7 +14,8 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
|
||||
assert token.length > 100, "Token should be substantial"
|
||||
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 @user.id.to_s, decoded['sub'], "Should have correct subject"
|
||||
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['name'], "Should have name"
|
||||
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
|
||||
|
||||
test "should handle nonce in id token" do
|
||||
nonce = "test-nonce-12345"
|
||||
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 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
|
||||
|
||||
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)
|
||||
|
||||
decoded = JWT.decode(token, nil, true)
|
||||
decoded = JWT.decode(token, nil, false).first
|
||||
assert_includes decoded['groups'], "admin", "Should include user's groups"
|
||||
end
|
||||
|
||||
test "should include admin claim for admin users" do
|
||||
test "admin claim should not be included in token" do
|
||||
@user.update!(admin: true)
|
||||
|
||||
token = @service.generate_id_token(@user, @application)
|
||||
|
||||
decoded = JWT.decode(token, nil, true)
|
||||
assert_equal true, decoded['admin'], "Admin users should have admin claim"
|
||||
decoded = JWT.decode(token, nil, false).first
|
||||
refute decoded.key?('admin'), "Admin claim should not be included in ID tokens (use groups instead)"
|
||||
end
|
||||
|
||||
test "should handle role-based claims when enabled" do
|
||||
@@ -63,7 +64,7 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
|
||||
|
||||
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"
|
||||
end
|
||||
|
||||
@@ -96,7 +97,7 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
|
||||
|
||||
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_includes decoded['role_permissions'], "read", "Should include read permission"
|
||||
assert_includes decoded['role_permissions'], "write", "Should include write permission"
|
||||
@@ -107,7 +108,7 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
|
||||
test "should handle missing roles gracefully" do
|
||||
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"
|
||||
end
|
||||
|
||||
@@ -260,7 +261,7 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
|
||||
test "should handle access token generation" do
|
||||
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'
|
||||
assert_equal @user.id.to_s, decoded['sub'], "Should decode subject correctly"
|
||||
assert_equal @application.client_id, decoded['aud'], "Should decode audience correctly"
|
||||
@@ -291,4 +292,215 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
|
||||
end
|
||||
assert_match /no key found/, error.message, "Should warn about missing private key"
|
||||
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
|
||||
Reference in New Issue
Block a user