From b517ebe80956d8c8d55aa381e3f975940b6c9cce Mon Sep 17 00:00:00 2001 From: Dan Milne Date: Fri, 2 Jan 2026 15:41:07 +1100 Subject: [PATCH] OpenID conformance test: Allow posting the access token in the body for userinfo endpoint --- app/controllers/oidc_controller.rb | 14 +- .../oidc_userinfo_controller_test.rb | 268 ++++++++++++++++++ 2 files changed, 277 insertions(+), 5 deletions(-) create mode 100644 test/controllers/oidc_userinfo_controller_test.rb diff --git a/app/controllers/oidc_controller.rb b/app/controllers/oidc_controller.rb index 5488c20..29144db 100644 --- a/app/controllers/oidc_controller.rb +++ b/app/controllers/oidc_controller.rb @@ -603,15 +603,19 @@ class OidcController < ApplicationController # GET/POST /oauth/userinfo # OIDC Core spec: UserInfo endpoint MUST support GET, SHOULD support POST def userinfo - # Extract access token from Authorization header - auth_header = request.headers["Authorization"] - unless auth_header&.start_with?("Bearer ") + # Extract access token from Authorization header or POST body + # RFC 6750: Bearer token can be in Authorization header, request body, or query string + token = if request.headers["Authorization"]&.start_with?("Bearer ") + request.headers["Authorization"].sub("Bearer ", "") + elsif request.params["access_token"].present? + request.params["access_token"] + end + + unless token head :unauthorized return end - token = auth_header.sub("Bearer ", "") - # Find and validate access token (opaque token with BCrypt hashing) access_token = OidcAccessToken.find_by_token(token) unless access_token&.active? diff --git a/test/controllers/oidc_userinfo_controller_test.rb b/test/controllers/oidc_userinfo_controller_test.rb new file mode 100644 index 0000000..f749b5f --- /dev/null +++ b/test/controllers/oidc_userinfo_controller_test.rb @@ -0,0 +1,268 @@ +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 should be present + assert_equal @user.email_address, json["preferred_username"], "Should include preferred_username with profile scope" + assert json["name"].present?, "Should include name 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