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

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

View File

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

View File

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

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
metadata: "{}"
active: true
audiobookshelf_app:
name: Audiobookshelf
slug: audiobookshelf
app_type: oidc
client_id: <%= SecureRandom.urlsafe_base64(32) %>
client_secret_digest: <%= BCrypt::Password.create(SecureRandom.urlsafe_base64(48)) %>
redirect_uris: |
https://abs.example.com/auth/openid/callback
metadata: "{}"
active: true

View File

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

View File

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

View File

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

View File

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

View File

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

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