OIDC app creation with encrypted secrets and application roles
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled

This commit is contained in:
Dan Milne
2025-10-24 14:47:24 +11:00
parent 831bd083c2
commit 12e0ef66ed
32 changed files with 1983 additions and 72 deletions

View File

@@ -1,21 +1,26 @@
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
one:
name: MyString
slug: MyString
app_type: MyString
client_id: MyString
client_secret: MyString
redirect_uris: MyText
metadata: MyText
active: false
<% require 'bcrypt' %>
two:
name: MyString
slug: MyString
app_type: MyString
client_id: MyString
client_secret: MyString
redirect_uris: MyText
metadata: MyText
active: false
kavita_app:
name: Kavita Reader
slug: kavita-reader
app_type: oidc
client_id: <%= SecureRandom.urlsafe_base64(32) %>
client_secret_digest: <%= BCrypt::Password.create(SecureRandom.urlsafe_base64(48)) %>
redirect_uris: |
https://kavita.example.com/signin-oidc
https://kavita.example.com/signout-callback-oidc
metadata: "{}"
active: true
another_app:
name: Another App
slug: another-app
app_type: oidc
client_id: <%= SecureRandom.urlsafe_base64(32) %>
client_secret_digest: <%= BCrypt::Password.create(SecureRandom.urlsafe_base64(48)) %>
redirect_uris: |
https://app.example.com/auth/callback
metadata: "{}"
active: true

View File

@@ -1,9 +1,9 @@
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
one:
name: MyString
description: MyText
admin_group:
name: Administrators
description: System administrators with full access
two:
name: MyString
description: MyText
editor_group:
name: Editors
description: Content editors with limited access

View File

@@ -1,15 +1,15 @@
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
one:
token: MyString
application: one
user: one
scope: MyString
expires_at: 2025-10-23 16:40:39
token: <%= SecureRandom.urlsafe_base64(32) %>
application: kavita_app
user: alice
scope: "openid profile email"
expires_at: 2025-12-31 23:59:59
two:
token: MyString
application: two
user: two
scope: MyString
expires_at: 2025-10-23 16:40:39
token: <%= SecureRandom.urlsafe_base64(32) %>
application: another_app
user: bob
scope: "openid profile email"
expires_at: 2025-12-31 23:59:59

View File

@@ -1,19 +1,19 @@
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
one:
code: MyString
application: one
user: one
redirect_uri: MyString
scope: MyString
expires_at: 2025-10-23 16:40:38
code: <%= SecureRandom.urlsafe_base64(32) %>
application: kavita_app
user: alice
redirect_uri: "https://kavita.example.com/signin-oidc"
scope: "openid profile email"
expires_at: 2025-12-31 23:59:59
used: false
two:
code: MyString
application: two
user: two
redirect_uri: MyString
scope: MyString
expires_at: 2025-10-23 16:40:38
code: <%= SecureRandom.urlsafe_base64(32) %>
application: another_app
user: bob
redirect_uri: "https://app.example.com/auth/callback"
scope: "openid profile email"
expires_at: 2025-12-31 23:59:59
used: false

View File

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

View File

@@ -0,0 +1,210 @@
require "test_helper"
class OidcRoleMappingTest < ActionDispatch::IntegrationTest
def setup
@application = applications(:kavita_app)
@user = users(:alice)
# Set a known client secret for testing
@test_client_secret = "test_secret_for_testing_only"
@application.client_secret = @test_client_secret
@application.save!
@application.update!(
role_mapping_mode: "oidc_managed",
role_claim_name: "roles"
)
@admin_role = @application.application_roles.create!(
name: "admin",
display_name: "Administrator"
)
@editor_role = @application.application_roles.create!(
name: "editor",
display_name: "Editor"
)
sign_in @user
end
test "should include roles in JWT tokens" do
# Assign roles to user
@application.assign_role_to_user!(@user, "admin", source: 'oidc')
@application.assign_role_to_user!(@user, "editor", source: 'oidc')
# Get authorization code
post oauth_authorize_path, params: {
client_id: @application.client_id,
response_type: "code",
redirect_uri: "https://example.com/callback",
scope: "openid profile email",
state: "test-state",
nonce: "test-nonce"
}
follow_redirect!
post oauth_consent_path, params: {
consent: "approve",
client_id: @application.client_id,
redirect_uri: "https://example.com/callback",
scope: "openid profile email",
state: "test-state"
}
assert_response :redirect
authorization_code = extract_code_from_redirect(response.location)
# Exchange code for token
post oauth_token_path, params: {
grant_type: "authorization_code",
code: authorization_code,
redirect_uri: "https://example.com/callback",
client_id: @application.client_id,
client_secret: @test_client_secret
}
assert_response :success
token_response = JSON.parse(response.body)
id_token = token_response["id_token"]
# Decode and verify ID token contains roles
decoded_token = JWT.decode(id_token, nil, false).first
assert_includes decoded_token["roles"], "admin"
assert_includes decoded_token["roles"], "editor"
end
test "should filter roles by prefix" do
@application.update!(role_prefix: "app-")
@admin_role.update!(name: "app-admin")
@editor_role.update!(name: "external-editor") # Should be filtered out
@application.assign_role_to_user!(@user, "app-admin", source: 'oidc')
@application.assign_role_to_user!(@user, "external-editor", source: 'oidc')
# Get token
post oauth_authorize_path, params: {
client_id: @application.client_id,
response_type: "code",
redirect_uri: "https://example.com/callback",
scope: "openid profile email",
state: "test-state"
}
follow_redirect!
post oauth_consent_path, params: {
consent: "approve",
client_id: @application.client_id,
redirect_uri: "https://example.com/callback",
scope: "openid profile email",
state: "test-state"
}
authorization_code = extract_code_from_redirect(response.location)
post oauth_token_path, params: {
grant_type: "authorization_code",
code: authorization_code,
redirect_uri: "https://example.com/callback",
client_id: @application.client_id,
client_secret: @test_client_secret
}
token_response = JSON.parse(response.body)
id_token = token_response["id_token"]
decoded_token = JWT.decode(id_token, nil, false).first
assert_includes decoded_token["roles"], "app-admin"
assert_not_includes decoded_token["roles"], "external-editor"
end
test "should include role permissions when configured" do
@application.update!(managed_permissions: { "include_permissions" => true })
@admin_role.update!(permissions: { "read" => true, "write" => true, "delete" => true })
@application.assign_role_to_user!(@user, "admin", source: 'oidc')
# Get token and check for role permissions
post oauth_authorize_path, params: {
client_id: @application.client_id,
response_type: "code",
redirect_uri: "https://example.com/callback",
scope: "openid profile email",
state: "test-state"
}
follow_redirect!
post oauth_consent_path, params: {
consent: "approve",
client_id: @application.client_id,
redirect_uri: "https://example.com/callback",
scope: "openid profile email",
state: "test-state"
}
authorization_code = extract_code_from_redirect(response.location)
post oauth_token_path, params: {
grant_type: "authorization_code",
code: authorization_code,
redirect_uri: "https://example.com/callback",
client_id: @application.client_id,
client_secret: @test_client_secret
}
token_response = JSON.parse(response.body)
id_token = token_response["id_token"]
decoded_token = JWT.decode(id_token, nil, false).first
assert decoded_token["role_permissions"].present?
role_permissions = decoded_token["role_permissions"].find { |rp| rp["name"] == "admin" }
assert_equal({ "read" => true, "write" => true, "delete" => true }, role_permissions["permissions"])
end
test "should use custom role claim name" do
@application.update!(role_claim_name: "user_roles")
@application.assign_role_to_user!(@user, "admin", source: 'oidc')
# Get token
post oauth_authorize_path, params: {
client_id: @application.client_id,
response_type: "code",
redirect_uri: "https://example.com/callback",
scope: "openid profile email",
state: "test-state"
}
follow_redirect!
post oauth_consent_path, params: {
consent: "approve",
client_id: @application.client_id,
redirect_uri: "https://example.com/callback",
scope: "openid profile email",
state: "test-state"
}
authorization_code = extract_code_from_redirect(response.location)
post oauth_token_path, params: {
grant_type: "authorization_code",
code: authorization_code,
redirect_uri: "https://example.com/callback",
client_id: @application.client_id,
client_secret: @test_client_secret
}
token_response = JSON.parse(response.body)
id_token = token_response["id_token"]
decoded_token = JWT.decode(id_token, nil, false).first
assert_nil decoded_token["roles"]
assert_includes decoded_token["user_roles"], "admin"
end
private
def extract_code_from_redirect(redirect_url)
uri = URI.parse(redirect_url)
query_params = CGI.parse(uri.query)
query_params["code"]&.first
end
end

View File

@@ -0,0 +1,86 @@
require "test_helper"
class ApplicationRoleTest < ActiveSupport::TestCase
def setup
@application = applications(:kavita_app)
@role = @application.application_roles.create!(
name: "admin",
display_name: "Administrator",
description: "Full access to all features"
)
end
test "should be valid" do
assert @role.valid?
end
test "should require name" do
@role.name = ""
assert_not @role.valid?
assert_includes @role.errors[:name], "can't be blank"
end
test "should require display_name" do
@role.display_name = ""
assert_not @role.valid?
assert_includes @role.errors[:display_name], "can't be blank"
end
test "should enforce unique role name per application" do
duplicate_role = @application.application_roles.build(
name: @role.name,
display_name: "Another Admin"
)
assert_not duplicate_role.valid?
assert_includes duplicate_role.errors[:name], "has already been taken"
end
test "should allow same role name in different applications" do
other_app = Application.create!(
name: "Other App",
slug: "other-app",
app_type: "oidc"
)
other_role = other_app.application_roles.build(
name: @role.name,
display_name: "Other Admin"
)
assert other_role.valid?
end
test "should track user assignments" do
user = users(:alice)
assert_not @role.user_has_role?(user)
@role.assign_to_user!(user)
assert @role.user_has_role?(user)
assert @role.users.include?(user)
end
test "should handle role removal" do
user = users(:alice)
@role.assign_to_user!(user)
assert @role.user_has_role?(user)
@role.remove_from_user!(user)
assert_not @role.user_has_role?(user)
assert_not @role.users.include?(user)
end
test "should default to active" do
new_role = @application.application_roles.build(
name: "member",
display_name: "Member"
)
assert new_role.active?
end
test "should support default permissions" do
role_with_permissions = @application.application_roles.create!(
name: "editor",
display_name: "Editor",
permissions: { "read" => true, "write" => true, "delete" => false }
)
assert_equal({ "read" => true, "write" => true, "delete" => false }, role_with_permissions.permissions)
end
end

View File

@@ -0,0 +1,87 @@
require "test_helper"
class UserRoleAssignmentTest < ActiveSupport::TestCase
def setup
@application = applications(:kavita_app)
@role = @application.application_roles.create!(
name: "admin",
display_name: "Administrator"
)
@user = users(:alice)
@assignment = UserRoleAssignment.create!(
user: @user,
application_role: @role
)
end
test "should be valid" do
assert @assignment.valid?
end
test "should enforce unique user-role combination" do
duplicate_assignment = UserRoleAssignment.new(
user: @user,
application_role: @role
)
assert_not duplicate_assignment.valid?
assert_includes duplicate_assignment.errors[:user], "has already been taken"
end
test "should allow same user with different roles" do
other_role = @application.application_roles.create!(
name: "editor",
display_name: "Editor"
)
other_assignment = UserRoleAssignment.new(
user: @user,
application_role: other_role
)
assert other_assignment.valid?
end
test "should allow same role for different users" do
other_user = users(:bob)
other_assignment = UserRoleAssignment.new(
user: other_user,
application_role: @role
)
assert other_assignment.valid?
end
test "should validate source" do
@assignment.source = "invalid_source"
assert_not @assignment.valid?
assert_includes @assignment.errors[:source], "is not included in the list"
end
test "should support valid sources" do %w[oidc manual group_sync].each do |source|
@assignment.source = source
assert @assignment.valid?, "Source '#{source}' should be valid"
end
end
test "should default to oidc source" do
new_assignment = UserRoleAssignment.new(
user: @user,
application_role: @role
)
assert_equal "oidc", new_assignment.source
end
test "should support metadata" do
metadata = { "synced_at" => Time.current, "external_source" => "authentik" }
@assignment.metadata = metadata
@assignment.save
assert_equal metadata, @assignment.reload.metadata
end
test "should identify oidc managed assignments" do
@assignment.source = "oidc"
assert @assignment.sync_from_oidc?
end
test "should not identify manually managed assignments as oidc" do
@assignment.source = "manual"
assert_not @assignment.sync_from_oidc?
end
end

View File

@@ -0,0 +1,163 @@
require "test_helper"
class RoleMappingEngineTest < ActiveSupport::TestCase
def setup
@application = applications(:kavita_app)
@user = users(:alice)
@application.update!(
role_mapping_mode: "oidc_managed",
role_claim_name: "roles"
)
@admin_role = @application.application_roles.create!(
name: "admin",
display_name: "Administrator"
)
@editor_role = @application.application_roles.create!(
name: "editor",
display_name: "Editor"
)
end
test "should sync user roles from claims" do
claims = { "roles" => ["admin", "editor"] }
RoleMappingEngine.sync_user_roles!(@user, @application, claims)
assert @application.user_has_role?(@user, "admin")
assert @application.user_has_role?(@user, "editor")
end
test "should remove roles not present in claims for oidc managed" do
# Assign initial roles
@application.assign_role_to_user!(@user, "admin", source: 'oidc')
@application.assign_role_to_user!(@user, "editor", source: 'oidc')
# Sync with only admin role
claims = { "roles" => ["admin"] }
RoleMappingEngine.sync_user_roles!(@user, @application, claims)
assert @application.user_has_role?(@user, "admin")
assert_not @application.user_has_role?(@user, "editor")
end
test "should handle hybrid mode role sync" do
@application.update!(role_mapping_mode: "hybrid")
# Assign manual role first
@application.assign_role_to_user!(@user, "editor", source: 'manual')
# Sync with admin role from OIDC
claims = { "roles" => ["admin"] }
RoleMappingEngine.sync_user_roles!(@user, @application, claims)
assert @application.user_has_role?(@user, "admin")
assert @application.user_has_role?(@user, "editor") # Manual role preserved
end
test "should filter roles by prefix" do
@application.update!(role_prefix: "app-")
@admin_role.update!(name: "app-admin")
@editor_role.update!(name: "app-editor")
# Create non-matching role
external_role = @application.application_roles.create!(
name: "external-role",
display_name: "External"
)
claims = { "roles" => ["app-admin", "app-editor", "external-role"] }
RoleMappingEngine.sync_user_roles!(@user, @application, claims)
assert @application.user_has_role?(@user, "app-admin")
assert @application.user_has_role?(@user, "app-editor")
assert_not @application.user_has_role?(@user, "external-role")
end
test "should handle different claim names" do
@application.update!(role_claim_name: "groups")
claims = { "groups" => ["admin", "editor"] }
RoleMappingEngine.sync_user_roles!(@user, @application, claims)
assert @application.user_has_role?(@user, "admin")
assert @application.user_has_role?(@user, "editor")
end
test "should handle microsoft role claim format" do
microsoft_claim = "http://schemas.microsoft.com/ws/2008/06/identity/claims/role"
claims = { microsoft_claim => ["admin", "editor"] }
RoleMappingEngine.sync_user_roles!(@user, @application, claims)
assert @application.user_has_role?(@user, "admin")
assert @application.user_has_role?(@user, "editor")
end
test "should determine user access based on roles" do
# OIDC managed mode - user needs roles to access
claims = { "roles" => ["admin"] }
assert RoleMappingEngine.user_allowed_with_roles?(@user, @application, claims)
# No roles should deny access
empty_claims = { "roles" => [] }
assert_not RoleMappingEngine.user_allowed_with_roles?(@user, @application, empty_claims)
end
test "should handle hybrid mode access control" do
@application.update!(role_mapping_mode: "hybrid")
# User with group access should be allowed
group_access = @application.user_allowed?(@user)
assert RoleMappingEngine.user_allowed_with_roles?(@user, @application)
# User with role access should be allowed
claims = { "roles" => ["admin"] }
assert RoleMappingEngine.user_allowed_with_roles?(@user, @application, claims)
# User without either should be denied
empty_claims = { "roles" => [] }
result = RoleMappingEngine.user_allowed_with_roles?(@user, @application, empty_claims)
# Should be allowed if group access exists, otherwise denied
assert_equal group_access, result
end
test "should map external roles to internal roles" do
external_roles = ["admin", "editor", "unknown-role"]
mapped_roles = RoleMappingEngine.map_external_to_internal_roles(@application, external_roles)
assert_includes mapped_roles, "admin"
assert_includes mapped_roles, "editor"
assert_not_includes mapped_roles, "unknown-role"
end
test "should extract roles from various claim formats" do
# Array format
claims_array = { "roles" => ["admin", "editor"] }
roles = RoleMappingEngine.send(:extract_roles_from_claims, @application, claims_array)
assert_equal ["admin", "editor"], roles
# String format
claims_string = { "roles" => "admin" }
roles = RoleMappingEngine.send(:extract_roles_from_claims, @application, claims_string)
assert_equal ["admin"], roles
# No roles
claims_empty = { "other_claim" => "value" }
roles = RoleMappingEngine.send(:extract_roles_from_claims, @application, claims_empty)
assert_equal [], roles
end
test "should handle disabled role mapping" do
@application.update!(role_mapping_mode: "disabled")
claims = { "roles" => ["admin"] }
# Should not sync roles when disabled
RoleMappingEngine.sync_user_roles!(@user, @application, claims)
assert_not @application.user_has_role?(@user, "admin")
# Should fall back to regular access control
assert RoleMappingEngine.user_allowed_with_roles?(@user, @application, claims)
end
end

View File

@@ -0,0 +1,111 @@
require "test_helper"
class RoleMappingTest < ActiveSupport::TestCase
self.use_transactional_tests = true
# Don't load any fixtures
def self.fixtures :all
# Disable fixtures
end
# Test without fixtures for simplicity
def setup
@user = User.create!(
email_address: "test@example.com",
password: "password123",
admin: false,
status: :active
)
@application = Application.create!(
name: "Test App",
slug: "test-app",
app_type: "oidc"
)
@admin_role = @application.application_roles.create!(
name: "admin",
display_name: "Administrator",
description: "Full access user"
)
end
def teardown
UserRoleAssignment.delete_all
ApplicationRole.delete_all
Application.delete_all
User.delete_all
end
test "should create application role" do
assert @admin_role.valid?
assert @admin_role.active?
assert_equal "Administrator", @admin_role.display_name
end
test "should assign role to user" do
assert_not @application.user_has_role?(@user, "admin")
@application.assign_role_to_user!(@user, "admin", source: 'manual')
assert @application.user_has_role?(@user, "admin")
assert @admin_role.user_has_role?(@user)
end
test "should remove role from user" do
@application.assign_role_to_user!(@user, "admin", source: 'manual')
assert @application.user_has_role?(@user, "admin")
@application.remove_role_from_user!(@user, "admin")
assert_not @application.user_has_role?(@user, "admin")
end
test "should support role mapping modes" do
assert_equal "disabled", @application.role_mapping_mode
@application.update!(role_mapping_mode: "oidc_managed")
assert @application.role_mapping_enabled?
assert @application.oidc_managed_roles?
@application.update!(role_mapping_mode: "hybrid")
assert @application.hybrid_roles?
end
test "should sync roles from OIDC claims" do
@application.update!(role_mapping_mode: "oidc_managed")
claims = { "roles" => ["admin"] }
RoleMappingEngine.sync_user_roles!(@user, @application, claims)
assert @application.user_has_role?(@user, "admin")
end
test "should filter roles by prefix" do
@application.update!(role_prefix: "app-")
@admin_role.update!(name: "app-admin")
claims = { "roles" => ["app-admin", "external-role"] }
RoleMappingEngine.sync_user_roles!(@user, @application, claims)
assert @application.user_has_role?(@user, "app-admin")
end
test "should include roles in JWT tokens" do
@application.assign_role_to_user!(@user, "admin", source: 'oidc')
token = OidcJwtService.generate_id_token(@user, @application)
decoded = JWT.decode(token, nil, false).first
assert_includes decoded["roles"], "admin"
end
test "should support custom role claim name" do
@application.update!(role_claim_name: "user_roles")
@application.assign_role_to_user!(@user, "admin", source: 'oidc')
token = OidcJwtService.generate_id_token(@user, @application)
decoded = JWT.decode(token, nil, false).first
assert_includes decoded["user_roles"], "admin"
assert_nil decoded["roles"]
end
end