Compare commits

..

4 Commits

Author SHA1 Message Date
Dan Milne
6be23c2c37 Add backchannel logout, per application logout.
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2025-11-27 16:38:27 +11:00
Dan Milne
eb2d7379bf Backchannel complete - improve oidc credential display 2025-11-27 11:52:25 +11:00
Dan Milne
67d86e5835 Add Icons for apps 2025-11-25 19:11:22 +11:00
Dan Milne
d6029556d3 Add OIDC fixes, add prefered_username, add application-user claims 2025-11-25 16:29:40 +11:00
48 changed files with 1662 additions and 118 deletions

View File

@@ -122,10 +122,54 @@ Send emails for:
- **Session revocation** - Users and admins can revoke individual sessions - **Session revocation** - Users and admins can revoke individual sessions
### Access Control ### Access Control
- **Group-based allowlists** - Restrict applications to specific user groups
- **Per-application access** - Each app defines which groups can access it #### Group-Based Application Access
- **Automatic enforcement** - Access checks during OIDC authorization and ForwardAuth Clinch uses groups to control which users can access which applications:
- **Custom claims** - Add arbitrary claims to OIDC tokens via groups and users (perfect for app-specific roles)
- **Create groups** - Organize users into logical groups (readers, editors, family, developers, etc.)
- **Assign groups to applications** - Each app defines which groups are allowed to access it
- Example: Kavita app allows the "readers" group → only users in the "readers" group can sign in
- If no groups are assigned to an app → all active users can access it
- **Automatic enforcement** - Access checks happen automatically:
- During OIDC authorization flow (before consent)
- During ForwardAuth verification (before proxying requests)
- Users not in allowed groups receive a "You do not have permission" error
#### Group Claims in Tokens
- **OIDC tokens include group membership** - ID tokens contain a `groups` claim with all user's groups
- **Custom claims** - Add arbitrary key-value pairs to tokens via groups and users
- Group claims apply to all members (e.g., `{"role": "viewer"}`)
- User claims override group claims for fine-grained control
- Perfect for app-specific authorization (e.g., admin vs. read-only roles)
#### Custom Claims Merging
Custom claims from groups and users are merged into OIDC ID tokens with the following precedence:
1. **Default OIDC claims** - Standard claims (`iss`, `sub`, `aud`, `exp`, `email`, etc.)
2. **Standard Clinch claims** - `groups` array (list of user's group names)
3. **Group custom claims** - Merged in order; later groups override earlier ones
4. **User custom claims** - Override all group claims
5. **Application-specific claims** - Highest priority; override all other claims
**Example:**
- Group "readers" has `{"role": "viewer", "max_items": 10}`
- Group "premium" has `{"role": "subscriber", "max_items": 100}`
- User (in both groups) has `{"max_items": 500}`
- **Result:** `{"role": "subscriber", "max_items": 500}` (user overrides max_items, premium overrides role)
#### Application-Specific Claims
Configure different claims for different applications on a per-user basis:
- **Per-app customization** - Each application can have unique claims for each user
- **Highest precedence** - App-specific claims override group and user global claims
- **Use case** - Different roles in different apps (e.g., admin in Kavita, user in Audiobookshelf)
- **Admin UI** - Configure via Admin → Users → Edit User → App-Specific Claim Overrides
**Example:**
- User Alice, global claims: `{"theme": "dark"}`
- Kavita app-specific: `{"kavita_groups": ["admin"]}`
- Audiobookshelf app-specific: `{"abs_groups": ["user"]}`
- **Result:** Kavita receives `{"theme": "dark", "kavita_groups": ["admin"]}`, Audiobookshelf receives `{"theme": "dark", "abs_groups": ["user"]}`
--- ---

View File

@@ -1 +1 @@
2025.02 2025.03

View File

@@ -16,16 +16,82 @@ class ActiveSessionsController < ApplicationController
return return
end end
# Send backchannel logout notification before revoking consent
if application.supports_backchannel_logout?
BackchannelLogoutJob.perform_later(
user_id: @user.id,
application_id: application.id,
consent_sid: consent.sid
)
Rails.logger.info "ActiveSessionsController: Enqueued backchannel logout for #{application.name}"
end
# Revoke all tokens for this user-application pair
now = Time.current
revoked_access_tokens = OidcAccessToken.where(application: application, user: @user, revoked_at: nil)
.update_all(revoked_at: now)
revoked_refresh_tokens = OidcRefreshToken.where(application: application, user: @user, revoked_at: nil)
.update_all(revoked_at: now)
Rails.logger.info "ActiveSessionsController: Revoked #{revoked_access_tokens} access tokens and #{revoked_refresh_tokens} refresh tokens for #{application.name}"
# Revoke the consent # Revoke the consent
consent.destroy consent.destroy
redirect_to active_sessions_path, notice: "Successfully revoked access to #{application.name}." redirect_to active_sessions_path, notice: "Successfully revoked access to #{application.name}."
end end
def logout_from_app
@user = Current.session.user
application = Application.find(params[:application_id])
# Check if user has consent for this application
consent = @user.oidc_user_consents.find_by(application: application)
unless consent
redirect_to root_path, alert: "No active session found for this application."
return
end
# Send backchannel logout notification
if application.supports_backchannel_logout?
BackchannelLogoutJob.perform_later(
user_id: @user.id,
application_id: application.id,
consent_sid: consent.sid
)
Rails.logger.info "ActiveSessionsController: Enqueued backchannel logout for #{application.name}"
end
# Revoke all tokens for this user-application pair
now = Time.current
revoked_access_tokens = OidcAccessToken.where(application: application, user: @user, revoked_at: nil)
.update_all(revoked_at: now)
revoked_refresh_tokens = OidcRefreshToken.where(application: application, user: @user, revoked_at: nil)
.update_all(revoked_at: now)
Rails.logger.info "ActiveSessionsController: Logged out from #{application.name} - revoked #{revoked_access_tokens} access tokens and #{revoked_refresh_tokens} refresh tokens"
# Keep the consent intact - this is the key difference from revoke_consent
redirect_to root_path, notice: "Successfully logged out of #{application.name}."
end
def revoke_all_consents def revoke_all_consents
@user = Current.session.user @user = Current.session.user
count = @user.oidc_user_consents.count consents = @user.oidc_user_consents.includes(:application)
count = consents.count
if count > 0 if count > 0
# Send backchannel logout notifications before revoking consents
consents.each do |consent|
next unless consent.application.supports_backchannel_logout?
BackchannelLogoutJob.perform_later(
user_id: @user.id,
application_id: consent.application.id,
consent_sid: consent.sid
)
end
Rails.logger.info "ActiveSessionsController: Enqueued #{count} backchannel logout notifications"
@user.oidc_user_consents.destroy_all @user.oidc_user_consents.destroy_all
redirect_to active_sessions_path, notice: "Successfully revoked access to #{count} applications." redirect_to active_sessions_path, notice: "Successfully revoked access to #{count} applications."
else else

View File

@@ -100,6 +100,7 @@ module Admin
params.require(:application).permit( params.require(:application).permit(
:name, :slug, :app_type, :active, :redirect_uris, :description, :metadata, :name, :slug, :app_type, :active, :redirect_uris, :description, :metadata,
:domain_pattern, :landing_url, :access_token_ttl, :refresh_token_ttl, :id_token_ttl, :domain_pattern, :landing_url, :access_token_ttl, :refresh_token_ttl, :id_token_ttl,
:icon, :backchannel_logout_uri,
headers_config: {} headers_config: {}
).tap do |whitelisted| ).tap do |whitelisted|
# Remove client_secret from params if present (shouldn't be updated via form) # Remove client_secret from params if present (shouldn't be updated via form)

View File

@@ -18,7 +18,25 @@ module Admin
end end
def create def create
@group = Group.new(group_params) create_params = group_params
# Parse custom_claims JSON if provided
if create_params[:custom_claims].present?
begin
create_params[:custom_claims] = JSON.parse(create_params[:custom_claims])
rescue JSON::ParserError
@group = Group.new
@group.errors.add(:custom_claims, "must be valid JSON")
@available_users = User.order(:email_address)
render :new, status: :unprocessable_entity
return
end
else
# If empty or blank, set to empty hash (NOT NULL constraint)
create_params[:custom_claims] = {}
end
@group = Group.new(create_params)
if @group.save if @group.save
# Handle user assignments # Handle user assignments
@@ -39,7 +57,24 @@ module Admin
end end
def update def update
if @group.update(group_params) update_params = group_params
# Parse custom_claims JSON if provided
if update_params[:custom_claims].present?
begin
update_params[:custom_claims] = JSON.parse(update_params[:custom_claims])
rescue JSON::ParserError
@group.errors.add(:custom_claims, "must be valid JSON")
@available_users = User.order(:email_address)
render :edit, status: :unprocessable_entity
return
end
else
# If empty or blank, set to empty hash (NOT NULL constraint)
update_params[:custom_claims] = {}
end
if @group.update(update_params)
# Handle user assignments # Handle user assignments
if params[:group][:user_ids].present? if params[:group][:user_ids].present?
user_ids = params[:group][:user_ids].reject(&:blank?) user_ids = params[:group][:user_ids].reject(&:blank?)
@@ -67,7 +102,7 @@ module Admin
end end
def group_params def group_params
params.require(:group).permit(:name, :description, custom_claims: {}) params.require(:group).permit(:name, :description, :custom_claims)
end end
end end
end end

View File

@@ -1,6 +1,6 @@
module Admin module Admin
class UsersController < BaseController class UsersController < BaseController
before_action :set_user, only: [:show, :edit, :update, :destroy, :resend_invitation] before_action :set_user, only: [:show, :edit, :update, :destroy, :resend_invitation, :update_application_claims, :delete_application_claims]
def index def index
@users = User.order(created_at: :desc) @users = User.order(created_at: :desc)
@@ -27,6 +27,7 @@ module Admin
end end
def edit def edit
@applications = Application.active.order(:name)
end end
def update def update
@@ -35,9 +36,25 @@ module Admin
# Only update password if provided # Only update password if provided
update_params.delete(:password) if update_params[:password].blank? update_params.delete(:password) if update_params[:password].blank?
# Parse custom_claims JSON if provided
if update_params[:custom_claims].present?
begin
update_params[:custom_claims] = JSON.parse(update_params[:custom_claims])
rescue JSON::ParserError
@user.errors.add(:custom_claims, "must be valid JSON")
@applications = Application.active.order(:name)
render :edit, status: :unprocessable_entity
return
end
else
# If empty or blank, set to empty hash (NOT NULL constraint)
update_params[:custom_claims] = {}
end
if @user.update(update_params) if @user.update(update_params)
redirect_to admin_users_path, notice: "User updated successfully." redirect_to admin_users_path, notice: "User updated successfully."
else else
@applications = Application.active.order(:name)
render :edit, status: :unprocessable_entity render :edit, status: :unprocessable_entity
end end
end end
@@ -63,6 +80,41 @@ module Admin
redirect_to admin_users_path, notice: "User deleted successfully." redirect_to admin_users_path, notice: "User deleted successfully."
end end
# POST /admin/users/:id/update_application_claims
def update_application_claims
application = Application.find(params[:application_id])
claims_json = params[:custom_claims].presence || "{}"
begin
claims = JSON.parse(claims_json)
rescue JSON::ParserError
redirect_to edit_admin_user_path(@user), alert: "Invalid JSON format for claims."
return
end
app_claim = @user.application_user_claims.find_or_initialize_by(application: application)
app_claim.custom_claims = claims
if app_claim.save
redirect_to edit_admin_user_path(@user), notice: "App-specific claims updated for #{application.name}."
else
error_message = app_claim.errors.full_messages.join(", ")
redirect_to edit_admin_user_path(@user), alert: "Failed to update claims: #{error_message}"
end
end
# DELETE /admin/users/:id/delete_application_claims
def delete_application_claims
application = Application.find(params[:application_id])
app_claim = @user.application_user_claims.find_by(application: application)
if app_claim&.destroy
redirect_to edit_admin_user_path(@user), notice: "App-specific claims removed for #{application.name}."
else
redirect_to edit_admin_user_path(@user), alert: "No claims found to remove."
end
end
private private
def set_user def set_user
@@ -71,7 +123,7 @@ module Admin
def user_params def user_params
# Base attributes that all admins can modify # 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) # Only allow modifying admin status when editing other users (prevent self-demotion)
if params[:id] != Current.session.user.id.to_s if params[:id] != Current.session.user.id.to_s

View File

@@ -23,7 +23,9 @@ class OidcController < ApplicationController
scopes_supported: ["openid", "profile", "email", "groups"], scopes_supported: ["openid", "profile", "email", "groups"],
token_endpoint_auth_methods_supported: ["client_secret_post", "client_secret_basic"], token_endpoint_auth_methods_supported: ["client_secret_post", "client_secret_basic"],
claims_supported: ["sub", "email", "email_verified", "name", "preferred_username", "groups", "admin"], claims_supported: ["sub", "email", "email_verified", "name", "preferred_username", "groups", "admin"],
code_challenge_methods_supported: ["plain", "S256"] code_challenge_methods_supported: ["plain", "S256"],
backchannel_logout_supported: true,
backchannel_logout_session_supported: true
} }
render json: config render json: config
@@ -534,9 +536,6 @@ class OidcController < ApplicationController
claims[:groups] = user.groups.pluck(:name) claims[:groups] = user.groups.pluck(:name)
end end
# Add admin claim if user is admin
claims[:admin] = true if user.admin?
# Merge custom claims from groups # Merge custom claims from groups
user.groups.each do |group| user.groups.each do |group|
claims.merge!(group.parsed_custom_claims) claims.merge!(group.parsed_custom_claims)
@@ -545,6 +544,10 @@ class OidcController < ApplicationController
# Merge custom claims from user (overrides group claims) # Merge custom claims from user (overrides group claims)
claims.merge!(user.parsed_custom_claims) claims.merge!(user.parsed_custom_claims)
# Merge app-specific custom claims (highest priority)
application = access_token.application
claims.merge!(application.custom_claims_for_user(user))
render json: claims render json: claims
end end
@@ -626,6 +629,11 @@ class OidcController < ApplicationController
# If user is authenticated, log them out # If user is authenticated, log them out
if authenticated? if authenticated?
user = Current.session.user
# Send backchannel logout notifications to all connected applications
send_backchannel_logout_notifications(user)
# Invalidate the current session # Invalidate the current session
Current.session&.destroy Current.session&.destroy
reset_session reset_session
@@ -765,4 +773,26 @@ class OidcController < ApplicationController
false false
end end
end end
def send_backchannel_logout_notifications(user)
# Find all active OIDC consents for this user
consents = OidcUserConsent.where(user: user).includes(:application)
consents.each do |consent|
# Skip if application doesn't support backchannel logout
next unless consent.application.supports_backchannel_logout?
# Enqueue background job to send logout notification
BackchannelLogoutJob.perform_later(
user_id: user.id,
application_id: consent.application.id,
consent_sid: consent.sid
)
end
Rails.logger.info "OidcController: Enqueued #{consents.count} backchannel logout notifications for user #{user.id}"
rescue => e
# Log error but don't block logout
Rails.logger.error "OidcController: Failed to enqueue backchannel logout: #{e.class} - #{e.message}"
end
end end

View File

@@ -11,7 +11,7 @@ class PasswordsController < ApplicationController
PasswordsMailer.reset(user).deliver_later PasswordsMailer.reset(user).deliver_later
end end
redirect_to new_session_path, notice: "Password reset instructions sent (if user with that email address exists)." redirect_to signin_path, notice: "Password reset instructions sent (if user with that email address exists)."
end end
def edit def edit
@@ -20,7 +20,7 @@ class PasswordsController < ApplicationController
def update def update
if @user.update(params.permit(:password, :password_confirmation)) if @user.update(params.permit(:password, :password_confirmation))
@user.sessions.destroy_all @user.sessions.destroy_all
redirect_to new_session_path, notice: "Password has been reset." redirect_to signin_path, notice: "Password has been reset."
else else
redirect_to edit_password_path(params[:token]), alert: "Passwords did not match." redirect_to edit_password_path(params[:token]), alert: "Passwords did not match."
end end
@@ -29,6 +29,7 @@ class PasswordsController < ApplicationController
private private
def set_user_by_token def set_user_by_token
@user = User.find_by_token_for(:password_reset, params[:token]) @user = User.find_by_token_for(:password_reset, params[:token])
redirect_to new_password_path, alert: "Password reset link is invalid or has expired." if @user.nil?
rescue ActiveSupport::MessageVerifier::InvalidSignature rescue ActiveSupport::MessageVerifier::InvalidSignature
redirect_to new_password_path, alert: "Password reset link is invalid or has expired." redirect_to new_password_path, alert: "Password reset link is invalid or has expired."
end end

View File

@@ -134,6 +134,12 @@ class SessionsController < ApplicationController
end end
def destroy def destroy
# Send backchannel logout notifications before terminating session
if authenticated?
user = Current.session.user
send_backchannel_logout_notifications(user)
end
terminate_session terminate_session
redirect_to signin_path, status: :see_other, notice: "Signed out successfully." redirect_to signin_path, status: :see_other, notice: "Signed out successfully."
end end
@@ -311,4 +317,26 @@ class SessionsController < ApplicationController
nil nil
end end
end end
def send_backchannel_logout_notifications(user)
# Find all active OIDC consents for this user
consents = OidcUserConsent.where(user: user).includes(:application)
consents.each do |consent|
# Skip if application doesn't support backchannel logout
next unless consent.application.supports_backchannel_logout?
# Enqueue background job to send logout notification
BackchannelLogoutJob.perform_later(
user_id: user.id,
application_id: consent.application.id,
consent_sid: consent.sid
)
end
Rails.logger.info "SessionsController: Enqueued #{consents.count} backchannel logout notifications for user #{user.id}"
rescue => e
# Log error but don't block logout
Rails.logger.error "SessionsController: Failed to enqueue backchannel logout: #{e.class} - #{e.message}"
end
end end

View File

@@ -0,0 +1,69 @@
module ClaimsHelper
include ClaimsMerger
# Preview final merged claims for a user accessing an application
def preview_user_claims(user, application)
claims = {
# Standard OIDC claims
email: user.email_address,
email_verified: true,
preferred_username: user.username.presence || user.email_address,
name: user.name.presence || user.email_address
}
# Add groups
if user.groups.any?
claims[:groups] = user.groups.pluck(:name)
end
# Merge group custom claims (arrays are combined, not overwritten)
user.groups.each do |group|
claims = deep_merge_claims(claims, group.parsed_custom_claims)
end
# Merge user custom claims (arrays are combined, other values override)
claims = deep_merge_claims(claims, user.parsed_custom_claims)
# Merge app-specific claims (arrays are combined)
claims = deep_merge_claims(claims, application.custom_claims_for_user(user))
claims
end
# Get claim sources breakdown for display
def claim_sources(user, application)
sources = []
# Group claims
user.groups.each do |group|
if group.parsed_custom_claims.any?
sources << {
type: :group,
name: group.name,
claims: group.parsed_custom_claims
}
end
end
# User claims
if user.parsed_custom_claims.any?
sources << {
type: :user,
name: "User Override",
claims: user.parsed_custom_claims
}
end
# App-specific claims
app_claims = application.custom_claims_for_user(user)
if app_claims.any?
sources << {
type: :application,
name: "App-Specific (#{application.name})",
claims: app_claims
}
end
sources
end
end

View File

@@ -0,0 +1,96 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["input", "dropzone", "preview", "previewImage", "filename", "filesize"]
connect() {
// Prevent default drag behaviors on the whole document
["dragenter", "dragover", "dragleave", "drop"].forEach(eventName => {
document.body.addEventListener(eventName, this.preventDefaults, false)
})
}
disconnect() {
["dragenter", "dragover", "dragleave", "drop"].forEach(eventName => {
document.body.removeEventListener(eventName, this.preventDefaults, false)
})
}
preventDefaults(e) {
e.preventDefault()
e.stopPropagation()
}
dragover(e) {
e.preventDefault()
e.stopPropagation()
this.dropzoneTarget.classList.add("border-blue-500", "bg-blue-50")
}
dragleave(e) {
e.preventDefault()
e.stopPropagation()
this.dropzoneTarget.classList.remove("border-blue-500", "bg-blue-50")
}
drop(e) {
e.preventDefault()
e.stopPropagation()
this.dropzoneTarget.classList.remove("border-blue-500", "bg-blue-50")
const files = e.dataTransfer.files
if (files.length > 0) {
// Set the file to the input element
this.inputTarget.files = files
this.handleFiles()
}
}
handleFiles() {
const file = this.inputTarget.files[0]
if (!file) return
// Validate file type
const validTypes = ["image/png", "image/jpg", "image/jpeg", "image/gif", "image/svg+xml"]
if (!validTypes.includes(file.type)) {
alert("Please upload a PNG, JPG, GIF, or SVG image")
this.clear()
return
}
// Validate file size (2MB)
if (file.size > 2 * 1024 * 1024) {
alert("File size must be less than 2MB")
this.clear()
return
}
// Show preview
this.filenameTarget.textContent = file.name
this.filesizeTarget.textContent = this.formatFileSize(file.size)
// Create preview image
const reader = new FileReader()
reader.onload = (e) => {
this.previewImageTarget.src = e.target.result
this.previewTarget.classList.remove("hidden")
}
reader.readAsDataURL(file)
}
clear(e) {
if (e) {
e.preventDefault()
}
this.inputTarget.value = ""
this.previewTarget.classList.add("hidden")
}
formatFileSize(bytes) {
if (bytes === 0) return "0 Bytes"
const k = 1024
const sizes = ["Bytes", "KB", "MB"]
const i = Math.floor(Math.log(bytes) / Math.log(k))
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + " " + sizes[i]
}
}

View File

@@ -0,0 +1,52 @@
class BackchannelLogoutJob < ApplicationJob
queue_as :default
# Retry with exponential backoff: 1s, 5s, 25s
retry_on StandardError, wait: :exponentially_longer, attempts: 3
def perform(user_id:, application_id:, consent_sid:)
# Find the records
user = User.find_by(id: user_id)
application = Application.find_by(id: application_id)
consent = OidcUserConsent.find_by(sid: consent_sid)
# Validate we have all required data
unless user && application && consent
Rails.logger.warn "BackchannelLogout: Missing data - user: #{user.present?}, app: #{application.present?}, consent: #{consent.present?}"
return
end
# Skip if application doesn't support backchannel logout
unless application.supports_backchannel_logout?
Rails.logger.debug "BackchannelLogout: Application #{application.name} doesn't support backchannel logout"
return
end
# Generate the logout token
logout_token = OidcJwtService.generate_logout_token(user, application, consent)
# Send HTTP POST to the application's backchannel logout URI
uri = URI.parse(application.backchannel_logout_uri)
begin
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https', open_timeout: 5, read_timeout: 5) do |http|
request = Net::HTTP::Post.new(uri.path.presence || '/')
request['Content-Type'] = 'application/x-www-form-urlencoded'
request.set_form_data({ logout_token: logout_token })
http.request(request)
end
if response.code.to_i == 200
Rails.logger.info "BackchannelLogout: Successfully sent logout notification to #{application.name} (#{application.backchannel_logout_uri})"
else
Rails.logger.warn "BackchannelLogout: Application #{application.name} returned HTTP #{response.code} from #{application.backchannel_logout_uri}"
end
rescue Net::OpenTimeout, Net::ReadTimeout => e
Rails.logger.warn "BackchannelLogout: Timeout sending logout to #{application.name} (#{application.backchannel_logout_uri}): #{e.message}"
raise # Retry on timeout
rescue StandardError => e
Rails.logger.error "BackchannelLogout: Failed to send logout to #{application.name} (#{application.backchannel_logout_uri}): #{e.class} - #{e.message}"
raise # Retry on error
end
end
end

View File

@@ -1,8 +1,14 @@
class Application < ApplicationRecord class Application < ApplicationRecord
has_secure_password :client_secret, validations: false has_secure_password :client_secret, validations: false
has_one_attached :icon
# Fix SVG content type after attachment
after_save :fix_icon_content_type, if: -> { icon.attached? && saved_change_to_attribute?(:id) == false }
has_many :application_groups, dependent: :destroy has_many :application_groups, dependent: :destroy
has_many :allowed_groups, through: :application_groups, source: :group has_many :allowed_groups, through: :application_groups, source: :group
has_many :application_user_claims, dependent: :destroy
has_many :oidc_authorization_codes, dependent: :destroy has_many :oidc_authorization_codes, dependent: :destroy
has_many :oidc_access_tokens, dependent: :destroy has_many :oidc_access_tokens, dependent: :destroy
has_many :oidc_refresh_tokens, dependent: :destroy has_many :oidc_refresh_tokens, dependent: :destroy
@@ -17,6 +23,15 @@ class Application < ApplicationRecord
validates :client_secret, presence: true, on: :create, if: -> { oidc? } validates :client_secret, presence: true, on: :create, if: -> { oidc? }
validates :domain_pattern, presence: true, uniqueness: { case_sensitive: false }, if: :forward_auth? validates :domain_pattern, presence: true, uniqueness: { case_sensitive: false }, if: :forward_auth?
validates :landing_url, format: { with: URI::regexp(%w[http https]), allow_nil: true, message: "must be a valid URL" } validates :landing_url, format: { with: URI::regexp(%w[http https]), allow_nil: true, message: "must be a valid URL" }
validates :backchannel_logout_uri, format: {
with: URI::regexp(%w[http https]),
allow_nil: true,
message: "must be a valid HTTP or HTTPS URL"
}
validate :backchannel_logout_uri_must_be_https_in_production, if: -> { backchannel_logout_uri.present? }
# Icon validation using ActiveStorage validators
validate :icon_validation, if: -> { icon.attached? }
# Token TTL validations (for OIDC apps) # Token TTL validations (for OIDC apps)
validates :access_token_ttl, numericality: { greater_than_or_equal_to: 300, less_than_or_equal_to: 86400 }, if: :oidc? # 5 min - 24 hours validates :access_token_ttl, numericality: { greater_than_or_equal_to: 300, less_than_or_equal_to: 86400 }, if: :oidc? # 5 min - 24 hours
@@ -186,8 +201,50 @@ class Application < ApplicationRecord
duration_to_human(id_token_ttl || 3600) duration_to_human(id_token_ttl || 3600)
end end
# Get app-specific custom claims for a user
def custom_claims_for_user(user)
app_claim = application_user_claims.find_by(user: user)
app_claim&.parsed_custom_claims || {}
end
# Check if this application supports backchannel logout
def supports_backchannel_logout?
backchannel_logout_uri.present?
end
# Check if a user has an active session with this application
# (i.e., has valid, non-revoked tokens)
def user_has_active_session?(user)
oidc_access_tokens.where(user: user).valid.exists? ||
oidc_refresh_tokens.where(user: user).valid.exists?
end
private private
def fix_icon_content_type
return unless icon.attached?
# Fix SVG content type if it was detected incorrectly
if icon.filename.extension == "svg" && icon.content_type == "application/octet-stream"
icon.blob.update(content_type: "image/svg+xml")
end
end
def icon_validation
return unless icon.attached?
# Check content type
allowed_types = ['image/png', 'image/jpg', 'image/jpeg', 'image/gif', 'image/svg+xml']
unless allowed_types.include?(icon.content_type)
errors.add(:icon, 'must be a PNG, JPG, GIF, or SVG image')
end
# Check file size (2MB limit)
if icon.blob.byte_size > 2.megabytes
errors.add(:icon, 'must be less than 2MB')
end
end
def duration_to_human(seconds) def duration_to_human(seconds)
if seconds < 3600 if seconds < 3600
"#{seconds / 60} minutes" "#{seconds / 60} minutes"
@@ -206,4 +263,18 @@ class Application < ApplicationRecord
self.client_secret = secret self.client_secret = secret
end end
end end
def backchannel_logout_uri_must_be_https_in_production
return unless Rails.env.production?
return unless backchannel_logout_uri.present?
begin
uri = URI.parse(backchannel_logout_uri)
unless uri.scheme == 'https'
errors.add(:backchannel_logout_uri, 'must use HTTPS in production')
end
rescue URI::InvalidURIError
# Let the format validator handle invalid URIs
end
end
end end

View File

@@ -0,0 +1,31 @@
class ApplicationUserClaim < ApplicationRecord
belongs_to :application
belongs_to :user
# Reserved OIDC claim names that should not be overridden
RESERVED_CLAIMS = %w[
iss sub aud exp iat nbf jti nonce azp
email email_verified preferred_username name
groups
].freeze
validates :user_id, uniqueness: { scope: :application_id }
validate :no_reserved_claim_names
# Parse custom_claims JSON field
def parsed_custom_claims
return {} if custom_claims.blank?
custom_claims.is_a?(Hash) ? custom_claims : {}
end
private
def no_reserved_claim_names
return if custom_claims.blank?
reserved_used = parsed_custom_claims.keys.map(&:to_s) & RESERVED_CLAIMS
if reserved_used.any?
errors.add(:custom_claims, "cannot override reserved OIDC claims: #{reserved_used.join(', ')}")
end
end
end

View File

@@ -4,11 +4,31 @@ class Group < ApplicationRecord
has_many :application_groups, dependent: :destroy has_many :application_groups, dependent: :destroy
has_many :applications, through: :application_groups has_many :applications, through: :application_groups
# Reserved OIDC claim names that should not be overridden
RESERVED_CLAIMS = %w[
iss sub aud exp iat nbf jti nonce azp
email email_verified preferred_username name
groups
].freeze
validates :name, presence: true, uniqueness: { case_sensitive: false } validates :name, presence: true, uniqueness: { case_sensitive: false }
normalizes :name, with: ->(name) { name.strip.downcase } normalizes :name, with: ->(name) { name.strip.downcase }
validate :no_reserved_claim_names
# Parse custom_claims JSON field # Parse custom_claims JSON field
def parsed_custom_claims def parsed_custom_claims
custom_claims || {} return {} if custom_claims.blank?
custom_claims.is_a?(Hash) ? custom_claims : {}
end
private
def no_reserved_claim_names
return if custom_claims.blank?
reserved_used = parsed_custom_claims.keys.map(&:to_s) & RESERVED_CLAIMS
if reserved_used.any?
errors.add(:custom_claims, "cannot override reserved OIDC claims: #{reserved_used.join(', ')}")
end
end end
end end

View File

@@ -3,6 +3,7 @@ class User < ApplicationRecord
has_many :sessions, dependent: :destroy has_many :sessions, dependent: :destroy
has_many :user_groups, dependent: :destroy has_many :user_groups, dependent: :destroy
has_many :groups, through: :user_groups has_many :groups, through: :user_groups
has_many :application_user_claims, dependent: :destroy
has_many :oidc_user_consents, dependent: :destroy has_many :oidc_user_consents, dependent: :destroy
has_many :webauthn_credentials, dependent: :destroy has_many :webauthn_credentials, dependent: :destroy
@@ -20,10 +21,22 @@ class User < ApplicationRecord
end end
normalizes :email_address, with: ->(e) { e.strip.downcase } normalizes :email_address, with: ->(e) { e.strip.downcase }
normalizes :username, with: ->(u) { u.strip.downcase if u.present? }
# Reserved OIDC claim names that should not be overridden
RESERVED_CLAIMS = %w[
iss sub aud exp iat nbf jti nonce azp
email email_verified preferred_username name
groups
].freeze
validates :email_address, presence: true, uniqueness: { case_sensitive: false }, validates :email_address, presence: true, uniqueness: { case_sensitive: false },
format: { with: URI::MailTo::EMAIL_REGEXP } format: { with: URI::MailTo::EMAIL_REGEXP }
validates :username, uniqueness: { case_sensitive: false }, allow_nil: true,
format: { with: /\A[a-zA-Z0-9_-]+\z/, message: "can only contain letters, numbers, underscores, and hyphens" },
length: { minimum: 2, maximum: 30 }
validates :password, length: { minimum: 8 }, allow_nil: true validates :password, length: { minimum: 8 }, allow_nil: true
validate :no_reserved_claim_names
# Enum - automatically creates scopes (User.active, User.disabled, etc.) # Enum - automatically creates scopes (User.active, User.disabled, etc.)
enum :status, { active: 0, disabled: 1, pending_invitation: 2 } enum :status, { active: 0, disabled: 1, pending_invitation: 2 }
@@ -182,11 +195,39 @@ class User < ApplicationRecord
# Parse custom_claims JSON field # Parse custom_claims JSON field
def parsed_custom_claims def parsed_custom_claims
custom_claims || {} return {} if custom_claims.blank?
custom_claims.is_a?(Hash) ? custom_claims : {}
end
# Get fully merged claims for a specific application
def merged_claims_for_application(application)
merged = {}
# Start with group claims (in order)
groups.each do |group|
merged.merge!(group.parsed_custom_claims)
end
# Merge user global claims
merged.merge!(parsed_custom_claims)
# Merge app-specific claims (highest priority)
merged.merge!(application.custom_claims_for_user(self))
merged
end end
private private
def no_reserved_claim_names
return if custom_claims.blank?
reserved_used = parsed_custom_claims.keys.map(&:to_s) & RESERVED_CLAIMS
if reserved_used.any?
errors.add(:custom_claims, "cannot override reserved OIDC claims: #{reserved_used.join(', ')}")
end
end
def generate_backup_codes def generate_backup_codes
# Generate plain codes for user to see/save # Generate plain codes for user to see/save
plain_codes = Array.new(10) { SecureRandom.alphanumeric(8).upcase } plain_codes = Array.new(10) { SecureRandom.alphanumeric(8).upcase }

View File

@@ -0,0 +1,35 @@
module ClaimsMerger
extend ActiveSupport::Concern
# Deep merge claims, combining arrays instead of overwriting them
# This ensures that array values (like roles) are combined across group/user/app claims
#
# Example:
# base = { "roles" => ["user"], "level" => 1 }
# incoming = { "roles" => ["admin"], "department" => "IT" }
# deep_merge_claims(base, incoming)
# # => { "roles" => ["user", "admin"], "level" => 1, "department" => "IT" }
def deep_merge_claims(base, incoming)
result = base.dup
incoming.each do |key, value|
if result.key?(key)
# If both values are arrays, combine them (union to avoid duplicates)
if result[key].is_a?(Array) && value.is_a?(Array)
result[key] = (result[key] + value).uniq
# If both values are hashes, recursively merge them
elsif result[key].is_a?(Hash) && value.is_a?(Hash)
result[key] = deep_merge_claims(result[key], value)
else
# Otherwise, incoming value wins (override)
result[key] = value
end
else
# New key, just add it
result[key] = value
end
end
result
end
end

View File

@@ -1,4 +1,6 @@
class OidcJwtService class OidcJwtService
extend ClaimsMerger
class << self class << self
# Generate an ID token (JWT) for the user # Generate an ID token (JWT) for the user
def generate_id_token(user, application, consent: nil, nonce: nil) def generate_id_token(user, application, consent: nil, nonce: nil)
@@ -17,7 +19,7 @@ class OidcJwtService
iat: now, iat: now,
email: user.email_address, email: user.email_address,
email_verified: true, email_verified: true,
preferred_username: user.email_address, preferred_username: user.username.presence || user.email_address,
name: user.name.presence || user.email_address name: user.name.presence || user.email_address
} }
@@ -29,17 +31,41 @@ class OidcJwtService
payload[:groups] = user.groups.pluck(:name) payload[:groups] = user.groups.pluck(:name)
end end
# Add admin claim if user is admin # Merge custom claims from groups (arrays are combined, not overwritten)
payload[:admin] = true if user.admin?
# Merge custom claims from groups
user.groups.each do |group| user.groups.each do |group|
payload.merge!(group.parsed_custom_claims) payload = deep_merge_claims(payload, group.parsed_custom_claims)
end end
# Merge custom claims from user (overrides group claims) # Merge custom claims from user (arrays are combined, other values override)
payload.merge!(user.parsed_custom_claims) payload = deep_merge_claims(payload, user.parsed_custom_claims)
# Merge app-specific custom claims (highest priority, arrays are combined)
payload = deep_merge_claims(payload, application.custom_claims_for_user(user))
JWT.encode(payload, private_key, "RS256", { kid: key_id, typ: "JWT" })
end
# Generate a backchannel logout token (JWT)
# Per OIDC Back-Channel Logout spec, this token:
# - MUST include iss, aud, iat, jti, events claims
# - MUST include sub or sid (or both) - we always include both
# - MUST NOT include nonce claim
def generate_logout_token(user, application, consent)
now = Time.current.to_i
payload = {
iss: issuer_url,
sub: consent.sid, # Pairwise subject identifier
aud: application.client_id,
iat: now,
jti: SecureRandom.uuid, # Unique identifier for this logout token
sid: consent.sid, # Session ID - always included for granular logout
events: {
"http://schemas.openid.net/event/backchannel-logout" => {}
}
}
# Important: Do NOT include nonce in logout tokens (spec requirement)
JWT.encode(payload, private_key, "RS256", { kid: key_id, typ: "JWT" }) JWT.encode(payload, private_key, "RS256", { kid: key_id, typ: "JWT" })
end end

View File

@@ -17,6 +17,56 @@
<%= form.text_area :description, rows: 3, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Optional description of this application" %> <%= form.text_area :description, rows: 3, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Optional description of this application" %>
</div> </div>
<div>
<%= form.label :icon, "Application Icon", class: "block text-sm font-medium text-gray-700" %>
<% if application.icon.attached? %>
<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 %>
<div class="mt-2" data-controller="file-drop">
<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-action="dragover->file-drop#dragover dragleave->file-drop#dragleave drop->file-drop#drop">
<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", 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>
</div>
</div>
<div data-file-drop-target="preview" class="mt-3 hidden">
<div class="flex items-center gap-3 p-3 bg-blue-50 rounded-md border border-blue-200">
<img data-file-drop-target="previewImage" class="h-12 w-12 rounded object-cover" alt="Preview">
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900" data-file-drop-target="filename"></p>
<p class="text-xs text-gray-500" data-file-drop-target="filesize"></p>
</div>
<button type="button" data-action="click->file-drop#clear" class="text-gray-400 hover:text-gray-600">
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</button>
</div>
</div>
</div>
</div>
<div> <div>
<%= form.label :landing_url, "Landing URL", class: "block text-sm font-medium text-gray-700" %> <%= form.label :landing_url, "Landing URL", class: "block text-sm font-medium text-gray-700" %>
<%= form.url_field :landing_url, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "https://app.example.com" %> <%= form.url_field :landing_url, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "https://app.example.com" %>
@@ -45,6 +95,16 @@
<p class="mt-1 text-sm text-gray-500">One URI per line. These are the allowed callback URLs for your application.</p> <p class="mt-1 text-sm text-gray-500">One URI per line. These are the allowed callback URLs for your application.</p>
</div> </div>
<div>
<%= form.label :backchannel_logout_uri, "Backchannel Logout URI (Optional)", class: "block text-sm font-medium text-gray-700" %>
<%= form.url_field :backchannel_logout_uri, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono", placeholder: "https://app.example.com/oidc/backchannel-logout" %>
<p class="mt-1 text-sm text-gray-500">
If the application supports OpenID Connect Backchannel Logout, enter the logout endpoint URL.
When users log out, Clinch will send logout notifications to this endpoint for immediate session termination.
Leave blank if the application doesn't support backchannel logout.
</p>
</div>
<div class="border-t border-gray-200 pt-4 mt-4"> <div class="border-t border-gray-200 pt-4 mt-4">
<h4 class="text-sm font-semibold text-gray-900 mb-3">Token Expiration Settings</h4> <h4 class="text-sm font-semibold text-gray-900 mb-3">Token Expiration Settings</h4>
<p class="text-sm text-gray-500 mb-4">Configure how long tokens remain valid. Shorter times are more secure but require more frequent refreshes.</p> <p class="text-sm text-gray-500 mb-4">Configure how long tokens remain valid. Shorter times are more secure but require more frequent refreshes.</p>

View File

@@ -14,7 +14,7 @@
<table class="min-w-full divide-y divide-gray-300"> <table class="min-w-full divide-y divide-gray-300">
<thead> <thead>
<tr> <tr>
<th scope="col" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-0">Name</th> <th scope="col" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-0">Application</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Slug</th> <th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Slug</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Type</th> <th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Type</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Status</th> <th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Status</th>
@@ -28,7 +28,18 @@
<% @applications.each do |application| %> <% @applications.each do |application| %>
<tr> <tr>
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-0"> <td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-0">
<%= link_to application.name, admin_application_path(application), class: "text-blue-600 hover:text-blue-900" %> <div class="flex items-center gap-3">
<% if application.icon.attached? %>
<%= image_tag application.icon, class: "h-10 w-10 rounded-lg object-cover border border-gray-200 flex-shrink-0", alt: "#{application.name} icon" %>
<% else %>
<div class="h-10 w-10 rounded-lg bg-gray-100 border border-gray-200 flex items-center justify-center flex-shrink-0">
<svg class="h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
<% end %>
<%= link_to application.name, admin_application_path(application), class: "text-blue-600 hover:text-blue-900" %>
</div>
</td> </td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500"> <td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
<code class="text-xs bg-gray-100 px-2 py-1 rounded"><%= application.slug %></code> <code class="text-xs bg-gray-100 px-2 py-1 rounded"><%= application.slug %></code>

View File

@@ -16,10 +16,21 @@
</div> </div>
<% end %> <% end %>
<div class="sm:flex sm:items-center sm:justify-between"> <div class="sm:flex sm:items-start sm:justify-between">
<div> <div class="flex items-start gap-4">
<h1 class="text-2xl font-semibold text-gray-900"><%= @application.name %></h1> <% if @application.icon.attached? %>
<p class="mt-1 text-sm text-gray-500"><%= @application.description %></p> <%= image_tag @application.icon, class: "h-16 w-16 rounded-lg object-cover border border-gray-200 shrink-0", alt: "#{@application.name} icon" %>
<% else %>
<div class="h-16 w-16 rounded-lg bg-gray-100 border border-gray-200 flex items-center justify-center shrink-0">
<svg class="h-8 w-8 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
<% end %>
<div>
<h1 class="text-2xl font-semibold text-gray-900"><%= @application.name %></h1>
<p class="mt-1 text-sm text-gray-500"><%= @application.description %></p>
</div>
</div> </div>
<div class="mt-4 sm:mt-0 flex gap-3"> <div class="mt-4 sm:mt-0 flex gap-3">
<%= link_to "Edit", edit_admin_application_path(@application), class: "rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50" %> <%= link_to "Edit", edit_admin_application_path(@application), class: "rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50" %>
@@ -78,27 +89,29 @@
<div class="bg-white shadow sm:rounded-lg"> <div class="bg-white shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6"> <div class="px-4 py-5 sm:p-6">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<h3 class="text-base font-semibold leading-6 text-gray-900">OIDC Credentials</h3> <h3 class="text-base font-semibold leading-6 text-gray-900">OIDC Configuration</h3>
<%= button_to "Regenerate Credentials", regenerate_credentials_admin_application_path(@application), method: :post, data: { turbo_confirm: "This will invalidate the current credentials. Continue?" }, class: "text-sm text-red-600 hover:text-red-900" %> <%= button_to "Regenerate Credentials", regenerate_credentials_admin_application_path(@application), method: :post, data: { turbo_confirm: "This will invalidate the current credentials. Continue?" }, class: "text-sm text-red-600 hover:text-red-900" %>
</div> </div>
<dl class="space-y-4"> <dl class="space-y-4">
<div> <% unless flash[:client_id] && flash[:client_secret] %>
<dt class="text-sm font-medium text-gray-500">Client ID</dt> <div>
<dd class="mt-1 text-sm text-gray-900"> <dt class="text-sm font-medium text-gray-500">Client ID</dt>
<code class="block bg-gray-100 px-3 py-2 rounded font-mono text-xs break-all"><%= @application.client_id %></code> <dd class="mt-1 text-sm text-gray-900">
</dd> <code class="block bg-gray-100 px-3 py-2 rounded font-mono text-xs break-all"><%= @application.client_id %></code>
</div> </dd>
<div> </div>
<dt class="text-sm font-medium text-gray-500">Client Secret</dt> <div>
<dd class="mt-1 text-sm text-gray-900"> <dt class="text-sm font-medium text-gray-500">Client Secret</dt>
<div class="bg-gray-100 px-3 py-2 rounded text-xs text-gray-500 italic"> <dd class="mt-1 text-sm text-gray-900">
🔒 Client secret is stored securely and cannot be displayed <div class="bg-gray-100 px-3 py-2 rounded text-xs text-gray-500 italic">
</div> 🔒 Client secret is stored securely and cannot be displayed
<p class="mt-2 text-xs text-gray-500"> </div>
To get a new client secret, use the "Regenerate Credentials" button above. <p class="mt-2 text-xs text-gray-500">
</p> To get a new client secret, use the "Regenerate Credentials" button above.
</dd> </p>
</div> </dd>
</div>
<% end %>
<div> <div>
<dt class="text-sm font-medium text-gray-500">Redirect URIs</dt> <dt class="text-sm font-medium text-gray-500">Redirect URIs</dt>
<dd class="mt-1 text-sm text-gray-900"> <dd class="mt-1 text-sm text-gray-900">
@@ -111,6 +124,27 @@
<% end %> <% end %>
</dd> </dd>
</div> </div>
<div>
<dt class="text-sm font-medium text-gray-500">
Backchannel Logout URI
<% if @application.supports_backchannel_logout? %>
<span class="ml-2 inline-flex items-center rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700">Enabled</span>
<% end %>
</dt>
<dd class="mt-1 text-sm text-gray-900">
<% if @application.backchannel_logout_uri.present? %>
<code class="block bg-gray-100 px-3 py-2 rounded font-mono text-xs break-all"><%= @application.backchannel_logout_uri %></code>
<p class="mt-2 text-xs text-gray-500">
When users log out, Clinch will send logout notifications to this endpoint for immediate session termination.
</p>
<% else %>
<span class="text-gray-400 italic">Not configured</span>
<p class="mt-1 text-xs text-gray-500">
Backchannel logout is optional. Configure it if the application supports OpenID Connect Backchannel Logout.
</p>
<% end %>
</dd>
</div>
</dl> </dl>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,185 @@
<% oidc_apps = applications.select(&:oidc?) %>
<% forward_auth_apps = applications.select(&:forward_auth?) %>
<!-- OIDC Apps: Custom Claims -->
<% if oidc_apps.any? %>
<div class="mt-12 border-t pt-8">
<h2 class="text-xl font-semibold text-gray-900 mb-4">OIDC App-Specific Claims</h2>
<p class="text-sm text-gray-600 mb-6">
Configure custom claims that apply only to specific OIDC applications. These override both group and user global claims and are included in ID tokens.
</p>
<div class="space-y-6">
<% oidc_apps.each do |app| %>
<% app_claim = user.application_user_claims.find_by(application: app) %>
<details class="border rounded-lg" <%= "open" if app_claim&.custom_claims&.any? %>>
<summary class="cursor-pointer bg-gray-50 px-4 py-3 hover:bg-gray-100 rounded-t-lg flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="font-medium text-gray-900"><%= app.name %></span>
<span class="text-xs px-2 py-1 rounded-full bg-blue-100 text-blue-700">
OIDC
</span>
<% if app_claim&.custom_claims&.any? %>
<span class="text-xs px-2 py-1 rounded-full bg-amber-100 text-amber-700">
<%= app_claim.custom_claims.keys.count %> claim(s)
</span>
<% end %>
</div>
<svg class="h-5 w-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</summary>
<div class="p-4 space-y-4">
<%= form_with url: update_application_claims_admin_user_path(user), method: :post, class: "space-y-4", data: { controller: "json-validator" } do |form| %>
<%= hidden_field_tag :application_id, app.id %>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Custom Claims (JSON)</label>
<%= text_area_tag :custom_claims,
(app_claim&.custom_claims.present? ? JSON.pretty_generate(app_claim.custom_claims) : ""),
rows: 8,
class: "w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono",
placeholder: '{"kavita_groups": ["admin"], "library_access": "all"}',
data: {
action: "input->json-validator#validate blur->json-validator#format",
json_validator_target: "textarea"
} %>
<div class="mt-2 space-y-1">
<p class="text-xs text-gray-600">
Example for <%= app.name %>: Add claims that this app specifically needs to read.
</p>
<p class="text-xs text-amber-600">
<strong>Note:</strong> Do not use reserved claim names (<code class="bg-amber-50 px-1 rounded">groups</code>, <code class="bg-amber-50 px-1 rounded">email</code>, <code class="bg-amber-50 px-1 rounded">name</code>, etc.). Use app-specific names like <code class="bg-amber-50 px-1 rounded">kavita_groups</code> instead.
</p>
<div data-json-validator-target="status" class="text-xs font-medium"></div>
</div>
</div>
<div class="flex gap-3">
<%= button_tag type: :submit, class: "rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500" do %>
<%= app_claim ? "Update" : "Add" %> Claims
<% end %>
<% if app_claim %>
<%= button_to "Remove Override",
delete_application_claims_admin_user_path(user, application_id: app.id),
method: :delete,
data: { turbo_confirm: "Remove app-specific claims for #{app.name}?" },
class: "rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50" %>
<% end %>
</div>
<% end %>
<!-- Preview merged claims -->
<div class="mt-4 border-t pt-4">
<h4 class="text-sm font-medium text-gray-700 mb-2">Preview: Final ID Token Claims for <%= app.name %></h4>
<div class="bg-gray-50 rounded-lg p-3">
<pre class="text-xs font-mono text-gray-800 overflow-x-auto"><%= JSON.pretty_generate(preview_user_claims(user, app)) %></pre>
</div>
<details class="mt-2">
<summary class="cursor-pointer text-xs text-gray-600 hover:text-gray-900">Show claim sources</summary>
<div class="mt-2 space-y-1">
<% claim_sources(user, app).each do |source| %>
<div class="flex gap-2 items-start text-xs">
<span class="px-2 py-1 rounded <%= source[:type] == :group ? 'bg-blue-100 text-blue-700' : (source[:type] == :user ? 'bg-green-100 text-green-700' : 'bg-amber-100 text-amber-700') %>">
<%= source[:name] %>
</span>
<code class="text-gray-700"><%= source[:claims].to_json %></code>
</div>
<% end %>
</div>
</details>
</div>
</div>
</details>
<% end %>
</div>
</div>
<% end %>
<!-- ForwardAuth Apps: Headers Preview -->
<% if forward_auth_apps.any? %>
<div class="mt-12 border-t pt-8">
<h2 class="text-xl font-semibold text-gray-900 mb-4">ForwardAuth Headers Preview</h2>
<p class="text-sm text-gray-600 mb-6">
ForwardAuth applications receive HTTP headers (not OIDC tokens). Headers are based on user's email, name, groups, and admin status.
</p>
<div class="space-y-6">
<% forward_auth_apps.each do |app| %>
<details class="border rounded-lg">
<summary class="cursor-pointer bg-gray-50 px-4 py-3 hover:bg-gray-100 rounded-t-lg flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="font-medium text-gray-900"><%= app.name %></span>
<span class="text-xs px-2 py-1 rounded-full bg-green-100 text-green-700">
FORWARD AUTH
</span>
<span class="text-xs text-gray-500">
<%= app.domain_pattern %>
</span>
</div>
<svg class="h-5 w-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</summary>
<div class="p-4 space-y-4">
<div class="bg-blue-50 border border-blue-200 rounded-lg p-3">
<div class="flex items-start">
<svg class="h-5 w-5 text-blue-400 mr-2 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
</svg>
</div>
</div>
<div>
<h4 class="text-sm font-medium text-gray-700 mb-2">Headers Sent to <%= app.name %></h4>
<div class="bg-gray-50 rounded-lg p-3 border">
<% headers = app.headers_for_user(user) %>
<% if headers.any? %>
<dl class="space-y-2 text-xs font-mono">
<% headers.each do |header_name, value| %>
<div class="flex">
<dt class="text-blue-600 font-semibold w-48"><%= header_name %>:</dt>
<dd class="text-gray-800 flex-1"><%= value %></dd>
</div>
<% end %>
</dl>
<% else %>
<p class="text-xs text-gray-500 italic">All headers disabled for this application.</p>
<% end %>
</div>
<p class="mt-2 text-xs text-gray-500">
These headers are configured in the application settings and sent by your reverse proxy (Caddy/Traefik) to the upstream application.
</p>
</div>
<% if user.groups.any? %>
<div>
<h4 class="text-sm font-medium text-gray-700 mb-2">User's Groups</h4>
<div class="flex flex-wrap gap-2">
<% user.groups.each do |group| %>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
<%= group.name %>
</span>
<% end %>
</div>
</div>
<% end %>
</div>
</details>
<% end %>
</div>
</div>
<% end %>
<% if oidc_apps.empty? && forward_auth_apps.empty? %>
<div class="mt-12 border-t pt-8">
<div class="text-center py-12 bg-gray-50 rounded-lg">
<p class="text-gray-500">No active applications found.</p>
<p class="text-sm text-gray-400 mt-1">Create applications in the Admin panel first.</p>
</div>
</div>
<% end %>

View File

@@ -6,10 +6,16 @@
<%= form.email_field :email_address, required: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "user@example.com" %> <%= form.email_field :email_address, required: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "user@example.com" %>
</div> </div>
<div>
<%= form.label :username, "Username (Optional)", class: "block text-sm font-medium text-gray-700" %>
<%= form.text_field :username, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "jsmith" %>
<p class="mt-1 text-sm text-gray-500">Optional: Short username/handle for login. Can only contain letters, numbers, underscores, and hyphens.</p>
</div>
<div> <div>
<%= form.label :name, "Display Name (Optional)", class: "block text-sm font-medium text-gray-700" %> <%= form.label :name, "Display Name (Optional)", class: "block text-sm font-medium text-gray-700" %>
<%= form.text_field :name, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "John Smith" %> <%= form.text_field :name, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "John Smith" %>
<p class="mt-1 text-sm text-gray-500">Optional: Name shown in applications. Defaults to email address if not set.</p> <p class="mt-1 text-sm text-gray-500">Optional: Full name shown in applications. Defaults to email address if not set.</p>
</div> </div>
<div> <div>

View File

@@ -1,5 +1,12 @@
<div class="max-w-2xl"> <div class="max-w-4xl">
<h1 class="text-2xl font-semibold text-gray-900 mb-6">Edit User</h1> <h1 class="text-2xl font-semibold text-gray-900 mb-6">Edit User</h1>
<p class="text-sm text-gray-600 mb-6">Editing: <%= @user.email_address %></p> <p class="text-sm text-gray-600 mb-6">Editing: <%= @user.email_address %></p>
<%= render "form", user: @user %>
<div class="max-w-2xl">
<%= render "form", user: @user %>
</div>
<% if @user.persisted? %>
<%= render "application_claims", user: @user, applications: @applications %>
<% end %>
</div> </div>

View File

@@ -102,38 +102,56 @@
<% @applications.each do |app| %> <% @applications.each do |app| %>
<div class="bg-white rounded-lg border border-gray-200 shadow-sm hover:shadow-md transition"> <div class="bg-white rounded-lg border border-gray-200 shadow-sm hover:shadow-md transition">
<div class="p-6"> <div class="p-6">
<div class="flex items-center justify-between mb-3"> <div class="flex items-start gap-3 mb-4">
<h3 class="text-lg font-semibold text-gray-900 truncate"> <% if app.icon.attached? %>
<%= app.name %> <%= image_tag app.icon, class: "h-12 w-12 rounded-lg object-cover border border-gray-200 shrink-0", alt: "#{app.name} icon" %>
</h3> <% else %>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium <div class="h-12 w-12 rounded-lg bg-gray-100 border border-gray-200 flex items-center justify-center shrink-0">
<% if app.oidc? %> <svg class="h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
bg-blue-100 text-blue-800 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
<% else %> </svg>
bg-green-100 text-green-800 </div>
<% end %>"> <% end %>
<%= app.app_type.humanize %> <div class="flex-1 min-w-0">
</span> <div class="flex items-start justify-between">
<h3 class="text-lg font-semibold text-gray-900 truncate">
<%= app.name %>
</h3>
<span class="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium shrink-0
<% if app.oidc? %>
bg-blue-100 text-blue-800
<% else %>
bg-green-100 text-green-800
<% end %>">
<%= app.app_type.humanize %>
</span>
</div>
<% if app.description.present? %>
<p class="text-sm text-gray-600 mt-1 line-clamp-2">
<%= app.description %>
</p>
<% end %>
</div>
</div> </div>
<p class="text-sm text-gray-600 mb-4"> <div class="space-y-2">
<% if app.oidc? %> <% if app.landing_url.present? %>
OIDC Application <%= link_to "Open Application", app.landing_url,
target: "_blank",
rel: "noopener noreferrer",
class: "w-full flex justify-center items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition" %>
<% else %> <% else %>
ForwardAuth Protected Application <div class="text-sm text-gray-500 italic">
No landing URL configured
</div>
<% end %> <% end %>
</p>
<% if app.landing_url.present? %> <% if app.user_has_active_session?(@user) %>
<%= link_to "Open Application", app.landing_url, <%= button_to "Logout", logout_from_app_active_sessions_path(application_id: app.id), method: :delete,
target: "_blank", class: "w-full flex justify-center items-center px-4 py-2 border border-orange-300 text-sm font-medium rounded-md text-orange-700 bg-white hover:bg-orange-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-orange-500 transition",
rel: "noopener noreferrer", form: { data: { turbo_confirm: "This will log you out of #{app.name}. You can sign back in without re-authorizing. Continue?" } } %>
class: "w-full flex justify-center items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition" %> <% end %>
<% else %> </div>
<div class="text-sm text-gray-500 italic">
No landing URL configured
</div>
<% end %>
</div> </div>
</div> </div>
<% end %> <% end %>

View File

@@ -1,6 +1,15 @@
<div class="mx-auto max-w-md"> <div class="mx-auto max-w-md">
<div class="bg-white py-8 px-6 shadow rounded-lg sm:px-10"> <div class="bg-white py-8 px-6 shadow rounded-lg sm:px-10">
<div class="mb-8"> <div class="mb-8 text-center">
<% if @application.icon.attached? %>
<%= image_tag @application.icon, class: "mx-auto h-20 w-20 rounded-xl object-cover border-2 border-gray-200 shadow-sm mb-4", alt: "#{@application.name} icon" %>
<% else %>
<div class="mx-auto h-20 w-20 rounded-xl bg-gray-100 border-2 border-gray-200 flex items-center justify-center mb-4">
<svg class="h-10 w-10 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
<% end %>
<h2 class="text-2xl font-bold text-gray-900">Authorize Application</h2> <h2 class="text-2xl font-bold text-gray-900">Authorize Application</h2>
<p class="mt-2 text-sm text-gray-600"> <p class="mt-2 text-sm text-gray-600">
<strong><%= @application.name %></strong> is requesting access to your account. <strong><%= @application.name %></strong> is requesting access to your account.

View File

@@ -0,0 +1,14 @@
# Configure ActiveStorage content type resolution
Rails.application.config.after_initialize do
# Ensure SVG files are served with the correct content type
ActiveStorage::Blob.class_eval do
def content_type_for_serving
# Override content type for SVG files
if filename.extension == "svg" && content_type == "application/octet-stream"
"image/svg+xml"
else
content_type
end
end
end
end

View File

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

17
config/recurring.yml Normal file
View File

@@ -0,0 +1,17 @@
# Solid Queue Recurring Jobs Configuration
# This file defines scheduled/cron-like jobs that run periodically
production:
oidc_token_cleanup:
class: OidcTokenCleanupJob
schedule: "0 3 * * *" # Run daily at 3:00 AM
queue: default
development:
oidc_token_cleanup:
class: OidcTokenCleanupJob
schedule: "0 3 * * *" # Run daily at 3:00 AM
queue: default
test:
# No recurring jobs in test environment

View File

@@ -49,6 +49,7 @@ Rails.application.routes.draw do
end end
resource :active_sessions, only: [:show] do resource :active_sessions, only: [:show] do
member do member do
delete :logout_from_app
delete :revoke_consent delete :revoke_consent
delete :revoke_all_consents delete :revoke_all_consents
end end
@@ -82,6 +83,8 @@ Rails.application.routes.draw do
resources :users do resources :users do
member do member do
post :resend_invitation post :resend_invitation
post :update_application_claims
delete :delete_application_claims
end end
end end
resources :applications do resources :applications do

View File

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

View File

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

View File

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

View File

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

View File

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

48
db/schema.rb generated
View File

@@ -10,7 +10,35 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2025_11_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| create_table "application_groups", force: :cascade do |t|
t.integer "application_id", null: false t.integer "application_id", null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
@@ -21,10 +49,22 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_22_235519) do
t.index ["group_id"], name: "index_application_groups_on_group_id" t.index ["group_id"], name: "index_application_groups_on_group_id"
end end
create_table "application_user_claims", force: :cascade do |t|
t.integer "application_id", null: false
t.datetime "created_at", null: false
t.json "custom_claims", default: {}, null: false
t.datetime "updated_at", null: false
t.integer "user_id", null: false
t.index ["application_id", "user_id"], name: "index_app_user_claims_unique", unique: true
t.index ["application_id"], name: "index_application_user_claims_on_application_id"
t.index ["user_id"], name: "index_application_user_claims_on_user_id"
end
create_table "applications", force: :cascade do |t| create_table "applications", force: :cascade do |t|
t.integer "access_token_ttl", default: 3600 t.integer "access_token_ttl", default: 3600
t.boolean "active", default: true, null: false t.boolean "active", default: true, null: false
t.string "app_type", null: false t.string "app_type", null: false
t.string "backchannel_logout_uri"
t.string "client_id" t.string "client_id"
t.string "client_secret_digest" t.string "client_secret_digest"
t.datetime "created_at", null: false t.datetime "created_at", null: false
@@ -169,10 +209,12 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_22_235519) do
t.boolean "totp_required", default: false, null: false t.boolean "totp_required", default: false, null: false
t.string "totp_secret" t.string "totp_secret"
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.string "username"
t.string "webauthn_id" t.string "webauthn_id"
t.boolean "webauthn_required", default: false, null: false t.boolean "webauthn_required", default: false, null: false
t.index ["email_address"], name: "index_users_on_email_address", unique: true t.index ["email_address"], name: "index_users_on_email_address", unique: true
t.index ["status"], name: "index_users_on_status" t.index ["status"], name: "index_users_on_status"
t.index ["username"], name: "index_users_on_username", unique: true
t.index ["webauthn_id"], name: "index_users_on_webauthn_id", unique: true t.index ["webauthn_id"], name: "index_users_on_webauthn_id", unique: true
end end
@@ -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" t.index ["user_id"], name: "index_webauthn_credentials_on_user_id"
end end
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "application_groups", "applications" add_foreign_key "application_groups", "applications"
add_foreign_key "application_groups", "groups" add_foreign_key "application_groups", "groups"
add_foreign_key "application_user_claims", "applications", on_delete: :cascade
add_foreign_key "application_user_claims", "users", on_delete: :cascade
add_foreign_key "oidc_access_tokens", "applications" add_foreign_key "oidc_access_tokens", "applications"
add_foreign_key "oidc_access_tokens", "users" add_foreign_key "oidc_access_tokens", "users"
add_foreign_key "oidc_authorization_codes", "applications" add_foreign_key "oidc_authorization_codes", "applications"

View File

@@ -19,8 +19,9 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
end end
def teardown def teardown
OidcAuthorizationCode.where(application: @application).destroy_all OidcAuthorizationCode.where(application: @application).delete_all
OidcAccessToken.where(application: @application).destroy_all # Use delete_all to avoid triggering callbacks that might have issues with the schema
OidcAccessToken.where(application: @application).delete_all
@user.destroy @user.destroy
@application.destroy @application.destroy
end end

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -58,8 +58,8 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
# Domain and Rule Integration Tests # Domain and Rule Integration Tests
test "different domain patterns with same session" do test "different domain patterns with same session" do
# Create test rules # Create test rules
wildcard_rule = ForwardAuthRule.create!(domain_pattern: "*.example.com", active: true) wildcard_rule = Application.create!(domain_pattern: "*.example.com", active: true)
exact_rule = ForwardAuthRule.create!(domain_pattern: "api.example.com", active: true) exact_rule = Application.create!(domain_pattern: "api.example.com", active: true)
# Sign in # Sign in
post "/signin", params: { email_address: @user.email_address, password: "password" } post "/signin", params: { email_address: @user.email_address, password: "password" }
@@ -82,7 +82,7 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
test "group-based access control integration" do test "group-based access control integration" do
# Create restricted rule # Create restricted rule
restricted_rule = ForwardAuthRule.create!(domain_pattern: "restricted.example.com", active: true) restricted_rule = Application.create!(domain_pattern: "restricted.example.com", active: true)
restricted_rule.allowed_groups << @group restricted_rule.allowed_groups << @group
# Sign in user without group # Sign in user without group
@@ -104,17 +104,19 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
# Header Configuration Integration Tests # Header Configuration Integration Tests
test "different header configurations with same user" do test "different header configurations with same user" do
# Create rules with different header configs # Create applications with different configs
default_rule = ForwardAuthRule.create!(domain_pattern: "default.example.com", active: true) default_rule = Application.create!(name: "Default App", slug: "default-app", app_type: "forward_auth", domain_pattern: "default.example.com", active: true)
custom_rule = ForwardAuthRule.create!( custom_rule = Application.create!(
name: "Custom App", slug: "custom-app", app_type: "forward_auth",
domain_pattern: "custom.example.com", domain_pattern: "custom.example.com",
active: true, active: true,
headers_config: { user: "X-WEBAUTH-USER", groups: "X-WEBAUTH-ROLES" } metadata: { headers: { user: "X-WEBAUTH-USER", groups: "X-WEBAUTH-ROLES" } }.to_json
) )
no_headers_rule = ForwardAuthRule.create!( no_headers_rule = Application.create!(
name: "No Headers App", slug: "no-headers-app", app_type: "forward_auth",
domain_pattern: "noheaders.example.com", domain_pattern: "noheaders.example.com",
active: true, active: true,
headers_config: { user: "", email: "", name: "", groups: "", admin: "" } metadata: { headers: { user: "", email: "", name: "", groups: "", admin: "" } }.to_json
) )
# Add user to groups # Add user to groups
@@ -191,7 +193,7 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
admin_user = users(:two) admin_user = users(:two)
# Create restricted rule # Create restricted rule
admin_rule = ForwardAuthRule.create!( admin_rule = Application.create!(
domain_pattern: "admin.example.com", domain_pattern: "admin.example.com",
active: true, active: true,
headers_config: { user: "X-Admin-User", admin: "X-Admin-Flag" } headers_config: { user: "X-Admin-User", admin: "X-Admin-Flag" }

View File

@@ -25,8 +25,8 @@ class InvitationsMailerTest < ActionMailer::TestCase
assert_equal "You're invited to join Clinch", email.subject assert_equal "You're invited to join Clinch", email.subject
assert_equal [@user.email_address], email.to assert_equal [@user.email_address], email.to
assert_equal [], email.cc assert_equal [], email.cc || []
assert_equal [], email.bcc assert_equal [], email.bcc || []
# From address is configured in ApplicationMailer # From address is configured in ApplicationMailer
assert_not_nil email.from assert_not_nil email.from
assert email.from.is_a?(Array) assert email.from.is_a?(Array)

View File

@@ -25,8 +25,8 @@ class PasswordsMailerTest < ActionMailer::TestCase
assert_equal "Reset your password", email.subject assert_equal "Reset your password", email.subject
assert_equal [@user.email_address], email.to assert_equal [@user.email_address], email.to
assert_equal [], email.cc assert_equal [], email.cc || []
assert_equal [], email.bcc assert_equal [], email.bcc || []
# From address is configured in ApplicationMailer # From address is configured in ApplicationMailer
assert_not_nil email.from assert_not_nil email.from
assert email.from.is_a?(Array) assert email.from.is_a?(Array)

View 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

View File

@@ -14,7 +14,8 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
assert token.length > 100, "Token should be substantial" assert token.length > 100, "Token should be substantial"
assert token.include?('.') assert token.include?('.')
decoded = JWT.decode(token, nil, true) # Decode without verification for testing the payload
decoded = JWT.decode(token, nil, false).first
assert_equal @application.client_id, decoded['aud'], "Should have correct audience" assert_equal @application.client_id, decoded['aud'], "Should have correct audience"
assert_equal @user.id.to_s, decoded['sub'], "Should have correct subject" assert_equal @user.id.to_s, decoded['sub'], "Should have correct subject"
assert_equal @user.email_address, decoded['email'], "Should have correct email" assert_equal @user.email_address, decoded['email'], "Should have correct email"
@@ -22,16 +23,16 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
assert_equal @user.email_address, decoded['preferred_username'], "Should have preferred username" assert_equal @user.email_address, decoded['preferred_username'], "Should have preferred username"
assert_equal @user.email_address, decoded['name'], "Should have name" assert_equal @user.email_address, decoded['name'], "Should have name"
assert_equal "https://localhost:3000", decoded['iss'], "Should have correct issuer" assert_equal "https://localhost:3000", decoded['iss'], "Should have correct issuer"
assert_equal Time.now.to_i + 3600, decoded['exp'], "Should have correct expiration" assert_in_delta Time.current.to_i + 3600, decoded['exp'], 5, "Should have correct expiration"
end end
test "should handle nonce in id token" do test "should handle nonce in id token" do
nonce = "test-nonce-12345" nonce = "test-nonce-12345"
token = @service.generate_id_token(@user, @application, nonce: nonce) token = @service.generate_id_token(@user, @application, nonce: nonce)
decoded = JWT.decode(token, nil, true) decoded = JWT.decode(token, nil, false).first
assert_equal nonce, decoded['nonce'], "Should preserve nonce in token" assert_equal nonce, decoded['nonce'], "Should preserve nonce in token"
assert_equal Time.now.to_i + 3600, decoded['exp'], "Should have correct expiration with nonce" assert_in_delta Time.current.to_i + 3600, decoded['exp'], 5, "Should have correct expiration with nonce"
end end
test "should include groups in token when user has groups" do test "should include groups in token when user has groups" do
@@ -39,17 +40,17 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
token = @service.generate_id_token(@user, @application) token = @service.generate_id_token(@user, @application)
decoded = JWT.decode(token, nil, true) decoded = JWT.decode(token, nil, false).first
assert_includes decoded['groups'], "admin", "Should include user's groups" assert_includes decoded['groups'], "admin", "Should include user's groups"
end end
test "should include admin claim for admin users" do test "admin claim should not be included in token" do
@user.update!(admin: true) @user.update!(admin: true)
token = @service.generate_id_token(@user, @application) token = @service.generate_id_token(@user, @application)
decoded = JWT.decode(token, nil, true) decoded = JWT.decode(token, nil, false).first
assert_equal true, decoded['admin'], "Admin users should have admin claim" refute decoded.key?('admin'), "Admin claim should not be included in ID tokens (use groups instead)"
end end
test "should handle role-based claims when enabled" do test "should handle role-based claims when enabled" do
@@ -63,7 +64,7 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
token = @service.generate_id_token(@user, @application) token = @service.generate_id_token(@user, @application)
decoded = JWT.decode(token, nil, true) decoded = JWT.decode(token, nil, false).first
assert_includes decoded['roles'], "editor", "Should include user's role" assert_includes decoded['roles'], "editor", "Should include user's role"
end end
@@ -96,7 +97,7 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
token = @service.generate_id_token(@user, @application) token = @service.generate_id_token(@user, @application)
decoded = JWT.decode(token, nil, true) decoded = JWT.decode(token, nil, false).first
assert_equal "Content Editor", decoded['role_display_name'], "Should include role display name" assert_equal "Content Editor", decoded['role_display_name'], "Should include role display name"
assert_includes decoded['role_permissions'], "read", "Should include read permission" assert_includes decoded['role_permissions'], "read", "Should include read permission"
assert_includes decoded['role_permissions'], "write", "Should include write permission" assert_includes decoded['role_permissions'], "write", "Should include write permission"
@@ -107,7 +108,7 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
test "should handle missing roles gracefully" do test "should handle missing roles gracefully" do
token = @service.generate_id_token(@user, @application) token = @service.generate_id_token(@user, @application)
decoded = JWT.decode(token, nil, true) decoded = JWT.decode(token, nil, false).first
refute_includes decoded, 'roles', "Should not have roles when not configured" refute_includes decoded, 'roles', "Should not have roles when not configured"
end end
@@ -260,7 +261,7 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
test "should handle access token generation" do test "should handle access token generation" do
token = @service.generate_id_token(@user, @application) token = @service.generate_id_token(@user, @application)
decoded = JWT.decode(token, nil, true) decoded = JWT.decode(token, nil, false).first
refute_includes decoded.keys, 'email_verified' refute_includes decoded.keys, 'email_verified'
assert_equal @user.id.to_s, decoded['sub'], "Should decode subject correctly" assert_equal @user.id.to_s, decoded['sub'], "Should decode subject correctly"
assert_equal @application.client_id, decoded['aud'], "Should decode audience correctly" assert_equal @application.client_id, decoded['aud'], "Should decode audience correctly"
@@ -291,4 +292,215 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
end end
assert_match /no key found/, error.message, "Should warn about missing private key" assert_match /no key found/, error.message, "Should warn about missing private key"
end end
test "should include app-specific custom claims in token" do
# Use bob and another_app to avoid fixture conflicts
user = users(:bob)
app = applications(:another_app)
# Create app-specific claim
ApplicationUserClaim.create!(
user: user,
application: app,
custom_claims: { "app_groups": ["admin"], "library_access": "all" }
)
token = @service.generate_id_token(user, app)
decoded = JWT.decode(token, nil, false).first
assert_equal ["admin"], decoded["app_groups"]
assert_equal "all", decoded["library_access"]
end
test "app-specific claims should override user and group claims" do
# Use bob and another_app to avoid fixture conflicts
user = users(:bob)
app = applications(:another_app)
# Add user to group with claims
group = groups(:admin_group)
group.update!(custom_claims: { "role": "viewer", "max_items": 10 })
user.groups << group
# Add user custom claims
user.update!(custom_claims: { "role": "editor", "theme": "dark" })
# Add app-specific claims (should override both)
ApplicationUserClaim.create!(
user: user,
application: app,
custom_claims: { "role": "admin", "app_specific": true }
)
token = @service.generate_id_token(user, app)
decoded = JWT.decode(token, nil, false).first
# App-specific claim should win
assert_equal "admin", decoded["role"]
# App-specific claim should be present
assert_equal true, decoded["app_specific"]
# User claim not overridden should still be present
assert_equal "dark", decoded["theme"]
# Group claim not overridden should still be present
assert_equal 10, decoded["max_items"]
end
test "should deep merge array claims from group and user" do
user = users(:bob)
app = applications(:another_app)
# Group has roles: ["user"]
group = groups(:admin_group)
group.update!(custom_claims: { "roles" => ["user"], "permissions" => ["read"] })
user.groups << group
# User adds roles: ["admin"]
user.update!(custom_claims: { "roles" => ["admin"], "permissions" => ["write"] })
token = @service.generate_id_token(user, app)
decoded = JWT.decode(token, nil, false).first
# Roles should be combined (not overwritten)
assert_equal 2, decoded["roles"].length
assert_includes decoded["roles"], "user"
assert_includes decoded["roles"], "admin"
# Permissions should also be combined
assert_equal 2, decoded["permissions"].length
assert_includes decoded["permissions"], "read"
assert_includes decoded["permissions"], "write"
end
test "should deep merge array claims from multiple groups" do
user = users(:bob)
app = applications(:another_app)
# First group has roles: ["user"]
group1 = groups(:admin_group)
group1.update!(custom_claims: { "roles" => ["user"] })
user.groups << group1
# Second group has roles: ["moderator"]
group2 = Group.create!(name: "moderators", description: "Moderators group")
group2.update!(custom_claims: { "roles" => ["moderator"] })
user.groups << group2
# User adds roles: ["admin"]
user.update!(custom_claims: { "roles" => ["admin"] })
token = @service.generate_id_token(user, app)
decoded = JWT.decode(token, nil, false).first
# All roles should be combined
assert_equal 3, decoded["roles"].length
assert_includes decoded["roles"], "user"
assert_includes decoded["roles"], "moderator"
assert_includes decoded["roles"], "admin"
end
test "should remove duplicate values when merging arrays" do
user = users(:bob)
app = applications(:another_app)
# Group has roles: ["user", "reader"]
group = groups(:admin_group)
group.update!(custom_claims: { "roles" => ["user", "reader"] })
user.groups << group
# User also has "user" role (duplicate)
user.update!(custom_claims: { "roles" => ["user", "admin"] })
token = @service.generate_id_token(user, app)
decoded = JWT.decode(token, nil, false).first
# "user" should only appear once
assert_equal 3, decoded["roles"].length
assert_includes decoded["roles"], "user"
assert_includes decoded["roles"], "reader"
assert_includes decoded["roles"], "admin"
end
test "should override non-array values while merging arrays" do
user = users(:bob)
app = applications(:another_app)
# Group has roles array and max_items scalar
group = groups(:admin_group)
group.update!(custom_claims: { "roles" => ["user"], "max_items" => 10, "theme" => "light" })
user.groups << group
# User overrides max_items and theme, adds to roles
user.update!(custom_claims: { "roles" => ["admin"], "max_items" => 100, "theme" => "dark" })
token = @service.generate_id_token(user, app)
decoded = JWT.decode(token, nil, false).first
# Arrays should be combined
assert_equal 2, decoded["roles"].length
assert_includes decoded["roles"], "user"
assert_includes decoded["roles"], "admin"
# Scalar values should be overridden (user wins)
assert_equal 100, decoded["max_items"]
assert_equal "dark", decoded["theme"]
end
test "should deep merge nested hashes in claims" do
user = users(:bob)
app = applications(:another_app)
# Group has nested config
group = groups(:admin_group)
group.update!(custom_claims: {
"config" => {
"theme" => "light",
"notifications" => { "email" => true }
}
})
user.groups << group
# User adds to nested config
user.update!(custom_claims: {
"config" => {
"language" => "en",
"notifications" => { "sms" => true }
}
})
token = @service.generate_id_token(user, app)
decoded = JWT.decode(token, nil, false).first
# Nested hashes should be deep merged
assert_equal "light", decoded["config"]["theme"]
assert_equal "en", decoded["config"]["language"]
assert_equal true, decoded["config"]["notifications"]["email"]
assert_equal true, decoded["config"]["notifications"]["sms"]
end
test "app-specific claims should combine arrays with group and user claims" do
user = users(:bob)
app = applications(:another_app)
# Group has roles: ["user"]
group = groups(:admin_group)
group.update!(custom_claims: { "roles" => ["user"] })
user.groups << group
# User has roles: ["moderator"]
user.update!(custom_claims: { "roles" => ["moderator"] })
# App-specific has roles: ["app_admin"]
ApplicationUserClaim.create!(
user: user,
application: app,
custom_claims: { "roles" => ["app_admin"] }
)
token = @service.generate_id_token(user, app)
decoded = JWT.decode(token, nil, false).first
# All three sources should be combined
assert_equal 3, decoded["roles"].length
assert_includes decoded["roles"], "user"
assert_includes decoded["roles"], "moderator"
assert_includes decoded["roles"], "app_admin"
end
end end