require "test_helper" class OidcUserinfoControllerTest < ActionDispatch::IntegrationTest def setup @user = users(:alice) @application = applications(:kavita_app) # Add user to a group for groups claim testing @admin_group = groups(:admin_group) @user.groups << @admin_group unless @user.groups.include?(@admin_group) end def teardown # Clean up OidcAccessToken.where(user: @user, application: @application).destroy_all end # ============================================================================ # HTTP Method Tests (GET and POST) # ============================================================================ test "userinfo endpoint accepts GET requests" do access_token = create_access_token("openid email profile") get "/oauth/userinfo", headers: { "Authorization" => "Bearer #{access_token.plaintext_token}" } assert_response :success json = JSON.parse(response.body) assert json["sub"].present? end test "userinfo endpoint accepts POST requests" do access_token = create_access_token("openid email profile") post "/oauth/userinfo", headers: { "Authorization" => "Bearer #{access_token.plaintext_token}" } assert_response :success json = JSON.parse(response.body) assert json["sub"].present? end test "userinfo endpoint accepts POST with access_token in body" do access_token = create_access_token("openid email profile") post "/oauth/userinfo", params: { access_token: access_token.plaintext_token } assert_response :success json = JSON.parse(response.body) assert json["sub"].present? end # ============================================================================ # Scope-Based Claim Filtering Tests # ============================================================================ test "userinfo with openid scope only returns minimal claims" do access_token = create_access_token("openid") get "/oauth/userinfo", headers: { "Authorization" => "Bearer #{access_token.plaintext_token}" } assert_response :success json = JSON.parse(response.body) # Required claims assert json["sub"].present?, "Should include sub claim" # Scope-dependent claims should NOT be present assert_nil json["email"], "Should not include email without email scope" assert_nil json["email_verified"], "Should not include email_verified without email scope" assert_nil json["name"], "Should not include name without profile scope" assert_nil json["preferred_username"], "Should not include preferred_username without profile scope" assert_nil json["groups"], "Should not include groups without groups scope" end test "userinfo with email scope includes email claims" do access_token = create_access_token("openid email") get "/oauth/userinfo", headers: { "Authorization" => "Bearer #{access_token.plaintext_token}" } assert_response :success json = JSON.parse(response.body) # Required claims assert json["sub"].present? # Email claims should be present assert_equal @user.email_address, json["email"], "Should include email with email scope" assert_equal true, json["email_verified"], "Should include email_verified with email scope" # Profile claims should NOT be present assert_nil json["name"], "Should not include name without profile scope" assert_nil json["preferred_username"], "Should not include preferred_username without profile scope" end test "userinfo with profile scope includes profile claims" do access_token = create_access_token("openid profile") get "/oauth/userinfo", headers: { "Authorization" => "Bearer #{access_token.plaintext_token}" } assert_response :success json = JSON.parse(response.body) # Required claims assert json["sub"].present? # Profile claims we support should be present assert json["name"].present?, "Should include name with profile scope" assert json["preferred_username"].present?, "Should include preferred_username with profile scope" assert json["updated_at"].present?, "Should include updated_at with profile scope" # Email claims should NOT be present assert_nil json["email"], "Should not include email without email scope" assert_nil json["email_verified"], "Should not include email_verified without email scope" end test "userinfo with groups scope includes groups claim" do access_token = create_access_token("openid groups") get "/oauth/userinfo", headers: { "Authorization" => "Bearer #{access_token.plaintext_token}" } assert_response :success json = JSON.parse(response.body) # Required claims assert json["sub"].present? # Groups claim should be present assert json["groups"].present?, "Should include groups with groups scope" assert_includes json["groups"], "Administrators", "Should include user's groups" # Email and profile claims should NOT be present assert_nil json["email"], "Should not include email without email scope" assert_nil json["name"], "Should not include name without profile scope" end test "userinfo with multiple scopes includes all requested claims" do access_token = create_access_token("openid email profile groups") get "/oauth/userinfo", headers: { "Authorization" => "Bearer #{access_token.plaintext_token}" } assert_response :success json = JSON.parse(response.body) # All scope-based claims should be present assert json["sub"].present? assert json["email"].present?, "Should include email" assert json["email_verified"].present?, "Should include email_verified" assert json["name"].present?, "Should include name" assert json["preferred_username"].present?, "Should include preferred_username" assert json["groups"].present?, "Should include groups" end test "userinfo returns same filtered claims for GET and POST" do access_token = create_access_token("openid email") # GET request get "/oauth/userinfo", headers: { "Authorization" => "Bearer #{access_token.plaintext_token}" } get_json = JSON.parse(response.body) # POST request post "/oauth/userinfo", headers: { "Authorization" => "Bearer #{access_token.plaintext_token}" } post_json = JSON.parse(response.body) # Both should return the same claims assert_equal get_json, post_json, "GET and POST should return identical claims" end # ============================================================================ # Authentication Tests # ============================================================================ test "userinfo endpoint requires Bearer token" do get "/oauth/userinfo" assert_response :unauthorized end test "userinfo endpoint rejects invalid token" do get "/oauth/userinfo", headers: { "Authorization" => "Bearer invalid_token_12345" } assert_response :unauthorized end test "userinfo endpoint rejects expired token" do access_token = create_access_token("openid email profile") # Expire the token access_token.update!(expires_at: 1.hour.ago) get "/oauth/userinfo", headers: { "Authorization" => "Bearer #{access_token.plaintext_token}" } assert_response :unauthorized end test "userinfo endpoint rejects revoked token" do access_token = create_access_token("openid email profile") # Revoke the token access_token.revoke! get "/oauth/userinfo", headers: { "Authorization" => "Bearer #{access_token.plaintext_token}" } assert_response :unauthorized end # ============================================================================ # Pairwise Subject Identifier Test # ============================================================================ test "userinfo returns pairwise SID when consent exists" do access_token = create_access_token("openid") # Find existing consent or create new one (ensure it has a SID) consent = OidcUserConsent.find_or_initialize_by( user: @user, application: @application ) consent.scopes_granted ||= "openid" consent.save! # Reload to get the auto-generated SID consent.reload get "/oauth/userinfo", headers: { "Authorization" => "Bearer #{access_token.plaintext_token}" } assert_response :success json = JSON.parse(response.body) assert_equal consent.sid, json["sub"], "Should use pairwise SID from consent" assert consent.sid.present?, "Consent should have a SID" end private def create_access_token(scope) OidcAccessToken.create!( application: @application, user: @user, scope: scope ) end end