diff --git a/README.md b/README.md index a29594f..923d75b 100644 --- a/README.md +++ b/README.md @@ -122,10 +122,54 @@ Send emails for: - **Session revocation** - Users and admins can revoke individual sessions ### Access Control -- **Group-based allowlists** - Restrict applications to specific user groups -- **Per-application access** - Each app defines which groups can access it -- **Automatic enforcement** - Access checks during OIDC authorization and ForwardAuth -- **Custom claims** - Add arbitrary claims to OIDC tokens via groups and users (perfect for app-specific roles) + +#### Group-Based Application Access +Clinch uses groups to control which users can access which applications: + +- **Create groups** - Organize users into logical groups (readers, editors, family, developers, etc.) +- **Assign groups to applications** - Each app defines which groups are allowed to access it + - Example: Kavita app allows the "readers" group → only users in the "readers" group can sign in + - If no groups are assigned to an app → all active users can access it +- **Automatic enforcement** - Access checks happen automatically: + - During OIDC authorization flow (before consent) + - During ForwardAuth verification (before proxying requests) + - Users not in allowed groups receive a "You do not have permission" error + +#### Group Claims in Tokens +- **OIDC tokens include group membership** - ID tokens contain a `groups` claim with all user's groups +- **Custom claims** - Add arbitrary key-value pairs to tokens via groups and users + - Group claims apply to all members (e.g., `{"role": "viewer"}`) + - User claims override group claims for fine-grained control + - Perfect for app-specific authorization (e.g., admin vs. read-only roles) + +#### Custom Claims Merging +Custom claims from groups and users are merged into OIDC ID tokens with the following precedence: + +1. **Default OIDC claims** - Standard claims (`iss`, `sub`, `aud`, `exp`, `email`, etc.) +2. **Standard Clinch claims** - `groups` array (list of user's group names) +3. **Group custom claims** - Merged in order; later groups override earlier ones +4. **User custom claims** - Override all group claims +5. **Application-specific claims** - Highest priority; override all other claims + +**Example:** +- Group "readers" has `{"role": "viewer", "max_items": 10}` +- Group "premium" has `{"role": "subscriber", "max_items": 100}` +- User (in both groups) has `{"max_items": 500}` +- **Result:** `{"role": "subscriber", "max_items": 500}` (user overrides max_items, premium overrides role) + +#### Application-Specific Claims +Configure different claims for different applications on a per-user basis: + +- **Per-app customization** - Each application can have unique claims for each user +- **Highest precedence** - App-specific claims override group and user global claims +- **Use case** - Different roles in different apps (e.g., admin in Kavita, user in Audiobookshelf) +- **Admin UI** - Configure via Admin → Users → Edit User → App-Specific Claim Overrides + +**Example:** +- User Alice, global claims: `{"theme": "dark"}` +- Kavita app-specific: `{"kavita_groups": ["admin"]}` +- Audiobookshelf app-specific: `{"abs_groups": ["user"]}` +- **Result:** Kavita receives `{"theme": "dark", "kavita_groups": ["admin"]}`, Audiobookshelf receives `{"theme": "dark", "abs_groups": ["user"]}` --- diff --git a/VERSION b/VERSION index 5c27e65..919d666 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2025.02 +2025.03 diff --git a/app/controllers/admin/groups_controller.rb b/app/controllers/admin/groups_controller.rb index d27842d..49e82c0 100644 --- a/app/controllers/admin/groups_controller.rb +++ b/app/controllers/admin/groups_controller.rb @@ -18,7 +18,25 @@ module Admin end def create - @group = Group.new(group_params) + create_params = group_params + + # Parse custom_claims JSON if provided + if create_params[:custom_claims].present? + begin + create_params[:custom_claims] = JSON.parse(create_params[:custom_claims]) + rescue JSON::ParserError + @group = Group.new + @group.errors.add(:custom_claims, "must be valid JSON") + @available_users = User.order(:email_address) + render :new, status: :unprocessable_entity + return + end + else + # If empty or blank, set to empty hash (NOT NULL constraint) + create_params[:custom_claims] = {} + end + + @group = Group.new(create_params) if @group.save # Handle user assignments @@ -39,7 +57,24 @@ module Admin end def update - if @group.update(group_params) + update_params = group_params + + # Parse custom_claims JSON if provided + if update_params[:custom_claims].present? + begin + update_params[:custom_claims] = JSON.parse(update_params[:custom_claims]) + rescue JSON::ParserError + @group.errors.add(:custom_claims, "must be valid JSON") + @available_users = User.order(:email_address) + render :edit, status: :unprocessable_entity + return + end + else + # If empty or blank, set to empty hash (NOT NULL constraint) + update_params[:custom_claims] = {} + end + + if @group.update(update_params) # Handle user assignments if params[:group][:user_ids].present? user_ids = params[:group][:user_ids].reject(&:blank?) @@ -67,7 +102,7 @@ module Admin end def group_params - params.require(:group).permit(:name, :description, custom_claims: {}) + params.require(:group).permit(:name, :description, :custom_claims) end end end diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index a74d4a9..7c86216 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -1,6 +1,6 @@ module Admin class UsersController < BaseController - before_action :set_user, only: [:show, :edit, :update, :destroy, :resend_invitation] + before_action :set_user, only: [:show, :edit, :update, :destroy, :resend_invitation, :update_application_claims, :delete_application_claims] def index @users = User.order(created_at: :desc) @@ -27,6 +27,7 @@ module Admin end def edit + @applications = Application.active.order(:name) end def update @@ -35,9 +36,25 @@ module Admin # Only update password if provided update_params.delete(:password) if update_params[:password].blank? + # Parse custom_claims JSON if provided + if update_params[:custom_claims].present? + begin + update_params[:custom_claims] = JSON.parse(update_params[:custom_claims]) + rescue JSON::ParserError + @user.errors.add(:custom_claims, "must be valid JSON") + @applications = Application.active.order(:name) + render :edit, status: :unprocessable_entity + return + end + else + # If empty or blank, set to empty hash (NOT NULL constraint) + update_params[:custom_claims] = {} + end + if @user.update(update_params) redirect_to admin_users_path, notice: "User updated successfully." else + @applications = Application.active.order(:name) render :edit, status: :unprocessable_entity end end @@ -63,6 +80,41 @@ module Admin redirect_to admin_users_path, notice: "User deleted successfully." end + # POST /admin/users/:id/update_application_claims + def update_application_claims + application = Application.find(params[:application_id]) + + claims_json = params[:custom_claims].presence || "{}" + begin + claims = JSON.parse(claims_json) + rescue JSON::ParserError + redirect_to edit_admin_user_path(@user), alert: "Invalid JSON format for claims." + return + end + + app_claim = @user.application_user_claims.find_or_initialize_by(application: application) + app_claim.custom_claims = claims + + if app_claim.save + redirect_to edit_admin_user_path(@user), notice: "App-specific claims updated for #{application.name}." + else + error_message = app_claim.errors.full_messages.join(", ") + redirect_to edit_admin_user_path(@user), alert: "Failed to update claims: #{error_message}" + end + end + + # DELETE /admin/users/:id/delete_application_claims + def delete_application_claims + application = Application.find(params[:application_id]) + app_claim = @user.application_user_claims.find_by(application: application) + + if app_claim&.destroy + redirect_to edit_admin_user_path(@user), notice: "App-specific claims removed for #{application.name}." + else + redirect_to edit_admin_user_path(@user), alert: "No claims found to remove." + end + end + private def set_user @@ -71,7 +123,7 @@ module Admin def user_params # Base attributes that all admins can modify - base_params = params.require(:user).permit(:email_address, :name, :password, :status, :totp_required, custom_claims: {}) + base_params = params.require(:user).permit(:email_address, :username, :name, :password, :status, :totp_required, :custom_claims) # Only allow modifying admin status when editing other users (prevent self-demotion) if params[:id] != Current.session.user.id.to_s diff --git a/app/controllers/oidc_controller.rb b/app/controllers/oidc_controller.rb index 7b424b7..605cad9 100644 --- a/app/controllers/oidc_controller.rb +++ b/app/controllers/oidc_controller.rb @@ -534,9 +534,6 @@ class OidcController < ApplicationController claims[:groups] = user.groups.pluck(:name) end - # Add admin claim if user is admin - claims[:admin] = true if user.admin? - # Merge custom claims from groups user.groups.each do |group| claims.merge!(group.parsed_custom_claims) @@ -545,6 +542,10 @@ class OidcController < ApplicationController # Merge custom claims from user (overrides group claims) claims.merge!(user.parsed_custom_claims) + # Merge app-specific custom claims (highest priority) + application = access_token.application + claims.merge!(application.custom_claims_for_user(user)) + render json: claims end diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb index 5b97998..b0f2df5 100644 --- a/app/controllers/passwords_controller.rb +++ b/app/controllers/passwords_controller.rb @@ -11,7 +11,7 @@ class PasswordsController < ApplicationController PasswordsMailer.reset(user).deliver_later end - redirect_to new_session_path, notice: "Password reset instructions sent (if user with that email address exists)." + redirect_to signin_path, notice: "Password reset instructions sent (if user with that email address exists)." end def edit @@ -20,7 +20,7 @@ class PasswordsController < ApplicationController def update if @user.update(params.permit(:password, :password_confirmation)) @user.sessions.destroy_all - redirect_to new_session_path, notice: "Password has been reset." + redirect_to signin_path, notice: "Password has been reset." else redirect_to edit_password_path(params[:token]), alert: "Passwords did not match." end @@ -29,6 +29,7 @@ class PasswordsController < ApplicationController private def set_user_by_token @user = User.find_by_token_for(:password_reset, params[:token]) + redirect_to new_password_path, alert: "Password reset link is invalid or has expired." if @user.nil? rescue ActiveSupport::MessageVerifier::InvalidSignature redirect_to new_password_path, alert: "Password reset link is invalid or has expired." end diff --git a/app/helpers/claims_helper.rb b/app/helpers/claims_helper.rb new file mode 100644 index 0000000..3ddbd74 --- /dev/null +++ b/app/helpers/claims_helper.rb @@ -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 diff --git a/app/models/application.rb b/app/models/application.rb index 6f6c336..21a54a0 100644 --- a/app/models/application.rb +++ b/app/models/application.rb @@ -3,6 +3,7 @@ class Application < ApplicationRecord has_many :application_groups, dependent: :destroy has_many :allowed_groups, through: :application_groups, source: :group + has_many :application_user_claims, dependent: :destroy has_many :oidc_authorization_codes, dependent: :destroy has_many :oidc_access_tokens, dependent: :destroy has_many :oidc_refresh_tokens, dependent: :destroy @@ -186,6 +187,12 @@ class Application < ApplicationRecord duration_to_human(id_token_ttl || 3600) end + # Get app-specific custom claims for a user + def custom_claims_for_user(user) + app_claim = application_user_claims.find_by(user: user) + app_claim&.parsed_custom_claims || {} + end + private def duration_to_human(seconds) diff --git a/app/models/application_user_claim.rb b/app/models/application_user_claim.rb new file mode 100644 index 0000000..2e67073 --- /dev/null +++ b/app/models/application_user_claim.rb @@ -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 diff --git a/app/models/group.rb b/app/models/group.rb index 778b494..4a1c77d 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -4,11 +4,31 @@ class Group < ApplicationRecord has_many :application_groups, dependent: :destroy has_many :applications, through: :application_groups + # Reserved OIDC claim names that should not be overridden + RESERVED_CLAIMS = %w[ + iss sub aud exp iat nbf jti nonce azp + email email_verified preferred_username name + groups + ].freeze + validates :name, presence: true, uniqueness: { case_sensitive: false } normalizes :name, with: ->(name) { name.strip.downcase } + validate :no_reserved_claim_names # Parse custom_claims JSON field def parsed_custom_claims - custom_claims || {} + return {} if custom_claims.blank? + custom_claims.is_a?(Hash) ? custom_claims : {} + end + + private + + def no_reserved_claim_names + return if custom_claims.blank? + + reserved_used = parsed_custom_claims.keys.map(&:to_s) & RESERVED_CLAIMS + if reserved_used.any? + errors.add(:custom_claims, "cannot override reserved OIDC claims: #{reserved_used.join(', ')}") + end end end diff --git a/app/models/user.rb b/app/models/user.rb index fd3753f..703a574 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -3,6 +3,7 @@ class User < ApplicationRecord has_many :sessions, dependent: :destroy has_many :user_groups, dependent: :destroy has_many :groups, through: :user_groups + has_many :application_user_claims, dependent: :destroy has_many :oidc_user_consents, dependent: :destroy has_many :webauthn_credentials, dependent: :destroy @@ -20,10 +21,22 @@ class User < ApplicationRecord end normalizes :email_address, with: ->(e) { e.strip.downcase } + normalizes :username, with: ->(u) { u.strip.downcase if u.present? } + + # Reserved OIDC claim names that should not be overridden + RESERVED_CLAIMS = %w[ + iss sub aud exp iat nbf jti nonce azp + email email_verified preferred_username name + groups + ].freeze validates :email_address, presence: true, uniqueness: { case_sensitive: false }, format: { with: URI::MailTo::EMAIL_REGEXP } + validates :username, uniqueness: { case_sensitive: false }, allow_nil: true, + format: { with: /\A[a-zA-Z0-9_-]+\z/, message: "can only contain letters, numbers, underscores, and hyphens" }, + length: { minimum: 2, maximum: 30 } validates :password, length: { minimum: 8 }, allow_nil: true + validate :no_reserved_claim_names # Enum - automatically creates scopes (User.active, User.disabled, etc.) enum :status, { active: 0, disabled: 1, pending_invitation: 2 } @@ -182,11 +195,39 @@ class User < ApplicationRecord # Parse custom_claims JSON field def parsed_custom_claims - custom_claims || {} + return {} if custom_claims.blank? + custom_claims.is_a?(Hash) ? custom_claims : {} + end + + # Get fully merged claims for a specific application + def merged_claims_for_application(application) + merged = {} + + # Start with group claims (in order) + groups.each do |group| + merged.merge!(group.parsed_custom_claims) + end + + # Merge user global claims + merged.merge!(parsed_custom_claims) + + # Merge app-specific claims (highest priority) + merged.merge!(application.custom_claims_for_user(self)) + + merged end private + def no_reserved_claim_names + return if custom_claims.blank? + + reserved_used = parsed_custom_claims.keys.map(&:to_s) & RESERVED_CLAIMS + if reserved_used.any? + errors.add(:custom_claims, "cannot override reserved OIDC claims: #{reserved_used.join(', ')}") + end + end + def generate_backup_codes # Generate plain codes for user to see/save plain_codes = Array.new(10) { SecureRandom.alphanumeric(8).upcase } diff --git a/app/services/concerns/claims_merger.rb b/app/services/concerns/claims_merger.rb new file mode 100644 index 0000000..e58c0e8 --- /dev/null +++ b/app/services/concerns/claims_merger.rb @@ -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 diff --git a/app/services/oidc_jwt_service.rb b/app/services/oidc_jwt_service.rb index 712cf4a..57ea186 100644 --- a/app/services/oidc_jwt_service.rb +++ b/app/services/oidc_jwt_service.rb @@ -1,4 +1,6 @@ class OidcJwtService + extend ClaimsMerger + class << self # Generate an ID token (JWT) for the user def generate_id_token(user, application, consent: nil, nonce: nil) @@ -17,7 +19,7 @@ class OidcJwtService iat: now, email: user.email_address, email_verified: true, - preferred_username: user.email_address, + preferred_username: user.username.presence || user.email_address, name: user.name.presence || user.email_address } @@ -29,16 +31,16 @@ class OidcJwtService payload[:groups] = user.groups.pluck(:name) end - # Add admin claim if user is admin - payload[:admin] = true if user.admin? - - # Merge custom claims from groups + # Merge custom claims from groups (arrays are combined, not overwritten) user.groups.each do |group| - payload.merge!(group.parsed_custom_claims) + payload = deep_merge_claims(payload, group.parsed_custom_claims) end - # Merge custom claims from user (overrides group claims) - payload.merge!(user.parsed_custom_claims) + # Merge custom claims from user (arrays are combined, other values override) + payload = deep_merge_claims(payload, user.parsed_custom_claims) + + # Merge app-specific custom claims (highest priority, arrays are combined) + payload = deep_merge_claims(payload, application.custom_claims_for_user(user)) JWT.encode(payload, private_key, "RS256", { kid: key_id, typ: "JWT" }) end diff --git a/app/views/admin/users/_application_claims.html.erb b/app/views/admin/users/_application_claims.html.erb new file mode 100644 index 0000000..f477ee0 --- /dev/null +++ b/app/views/admin/users/_application_claims.html.erb @@ -0,0 +1,185 @@ +<% oidc_apps = applications.select(&:oidc?) %> +<% forward_auth_apps = applications.select(&:forward_auth?) %> + + +<% if oidc_apps.any? %> +
+ Configure custom claims that apply only to specific OIDC applications. These override both group and user global claims and are included in ID tokens. +
+ ++ Example for <%= app.name %>: Add claims that this app specifically needs to read. +
+
+ Note: Do not use reserved claim names (groups, email, name, etc.). Use app-specific names like kavita_groups instead.
+
<%= JSON.pretty_generate(preview_user_claims(user, app)) %>+
<%= source[:claims].to_json %>
+ + ForwardAuth applications receive HTTP headers (not OIDC tokens). Headers are based on user's email, name, groups, and admin status. +
+ +All headers disabled for this application.
+ <% end %> ++ These headers are configured in the application settings and sent by your reverse proxy (Caddy/Traefik) to the upstream application. +
+No active applications found.
+Create applications in the Admin panel first.
+Optional: Short username/handle for login. Can only contain letters, numbers, underscores, and hyphens.
+Optional: Name shown in applications. Defaults to email address if not set.
+Optional: Full name shown in applications. Defaults to email address if not set.
Editing: <%= @user.email_address %>
- <%= render "form", user: @user %> + +