Add OIDC fixes, add prefered_username, add application-user claims

This commit is contained in:
Dan Milne
2025-11-25 16:29:40 +11:00
parent 7796c38c08
commit d6029556d3
34 changed files with 1003 additions and 64 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

@@ -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

@@ -534,9 +534,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 +542,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

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

@@ -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

@@ -3,6 +3,7 @@ class Application < ApplicationRecord
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
@@ -186,6 +187,12 @@ 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
private private
def duration_to_human(seconds) def duration_to_human(seconds)

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,16 +31,16 @@ 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" }) JWT.encode(payload, private_key, "RS256", { kid: key_id, typ: "JWT" })
end end

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>

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

@@ -82,6 +82,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

17
db/schema.rb generated
View File

@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2025_11_22_235519) do ActiveRecord::Schema[8.1].define(version: 2025_11_25_012446) do
create_table "application_groups", force: :cascade do |t| create_table "application_groups", force: :cascade do |t|
t.integer "application_id", null: false t.integer "application_id", null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
@@ -21,6 +21,17 @@ 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
@@ -169,10 +180,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
@@ -200,6 +213,8 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_22_235519) do
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