require "test_helper" class OidcClaimsSecurityTest < ActionDispatch::IntegrationTest setup do @user = User.create!(email_address: "claims_security_test@example.com", password: "password123") @application = Application.create!( name: "Claims Security Test App", slug: "claims-security-test-app", app_type: "oidc", redirect_uris: ["http://localhost:4000/callback"].to_json, active: true, require_pkce: false ) # Store the plain text client secret for testing @application.generate_new_client_secret! @plain_client_secret = @application.client_secret @application.save! end def teardown # Delete in correct order to avoid foreign key constraints OidcRefreshToken.where(application: @application).delete_all OidcAccessToken.where(application: @application).delete_all OidcAuthorizationCode.where(application: @application).delete_all OidcUserConsent.where(application: @application).delete_all @user.destroy @application.destroy end # ==================== # CLAIMS PARAMETER ESCALATION ATTACKS # ==================== test "rejects claims parameter during authorization code exchange" do # Create consent with minimal scopes (no profile, email, or admin access) OidcUserConsent.create!( user: @user, application: @application, scopes_granted: "openid", granted_at: Time.current, sid: "test-sid-123" ) auth_code = OidcAuthorizationCode.create!( application: @application, user: @user, redirect_uri: "http://localhost:4000/callback", scope: "openid", expires_at: 10.minutes.from_now ) # ATTEMPT: Inject claims parameter during token exchange (ATTACK!) # The client is trying to request 'admin' claim that they never got consent for post "/oauth/token", params: { grant_type: "authorization_code", code: auth_code.plaintext_code, redirect_uri: "http://localhost:4000/callback", claims: '{"id_token":{"admin":{"essential":true}}}' # ← ATTACK! }, headers: { "Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}") } # SHOULD: Reject the claims parameter - it's only allowed in authorization requests assert_response :bad_request error = JSON.parse(response.body) assert_equal "invalid_request", error["error"], "Should reject claims parameter at token endpoint" assert_match(/claims.*not allowed|unsupported parameter/i, error["error_description"], "Error should mention claims parameter not allowed") end test "rejects claims parameter during authorization code exchange with profile escalation" do # Create consent with ONLY openid scope (no profile scope) OidcUserConsent.create!( user: @user, application: @application, scopes_granted: "openid", granted_at: Time.current, sid: "test-sid-123" ) auth_code = OidcAuthorizationCode.create!( application: @application, user: @user, redirect_uri: "http://localhost:4000/callback", scope: "openid", expires_at: 10.minutes.from_now ) # ATTEMPT: Try to get profile claims via claims parameter without profile scope post "/oauth/token", params: { grant_type: "authorization_code", code: auth_code.plaintext_code, redirect_uri: "http://localhost:4000/callback", claims: '{"id_token":{"name":null,"email":{"essential":true}}}' }, headers: { "Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}") } # SHOULD: Reject the claims parameter assert_response :bad_request error = JSON.parse(response.body) assert_equal "invalid_request", error["error"] end test "rejects claims parameter during refresh token grant" do access_token = OidcAccessToken.create!( application: @application, user: @user, scope: "openid" ) refresh_token = OidcRefreshToken.create!( application: @application, user: @user, oidc_access_token: access_token, scope: "openid" ) plaintext_refresh_token = refresh_token.token # ATTEMPT: Inject claims parameter during refresh (ATTACK!) # Trying to escalate to admin claims during refresh post "/oauth/token", params: { grant_type: "refresh_token", refresh_token: plaintext_refresh_token, claims: '{"id_token":{"admin":true,"role":{"essential":true}}}' # ← ATTACK! }, headers: { "Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}") } # SHOULD: Reject the claims parameter assert_response :bad_request error = JSON.parse(response.body) assert_equal "invalid_request", error["error"], "Should reject claims parameter at refresh token endpoint" assert_match(/claims.*not allowed|unsupported parameter/i, error["error_description"]) end test "rejects claims parameter during refresh token grant with custom claims escalation" do # Setup: User has a custom claim at user level @user.update!(custom_claims: {"role" => "user"}) access_token = OidcAccessToken.create!( application: @application, user: @user, scope: "openid" ) refresh_token = OidcRefreshToken.create!( application: @application, user: @user, oidc_access_token: access_token, scope: "openid" ) plaintext_refresh_token = refresh_token.token # ATTEMPT: Try to escalate role to admin via claims parameter post "/oauth/token", params: { grant_type: "refresh_token", refresh_token: plaintext_refresh_token, claims: '{"id_token":{"role":{"value":"admin"}}}' # ← ATTACK! Trying to override role value }, headers: { "Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}") } # SHOULD: Reject the claims parameter assert_response :bad_request error = JSON.parse(response.body) assert_equal "invalid_request", error["error"] end test "allows token exchange without claims parameter" do # Create consent OidcUserConsent.create!( user: @user, application: @application, scopes_granted: "openid profile", granted_at: Time.current, sid: "test-sid-123" ) auth_code = OidcAuthorizationCode.create!( application: @application, user: @user, redirect_uri: "http://localhost:4000/callback", scope: "openid profile", expires_at: 10.minutes.from_now ) # Normal token exchange WITHOUT claims parameter should work fine post "/oauth/token", params: { grant_type: "authorization_code", code: auth_code.plaintext_code, redirect_uri: "http://localhost:4000/callback" }, headers: { "Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}") } assert_response :success response_body = JSON.parse(response.body) assert response_body.key?("access_token") assert response_body.key?("id_token") end test "allows refresh without claims parameter" do # Create consent for this application OidcUserConsent.create!( user: @user, application: @application, scopes_granted: "openid profile", granted_at: Time.current, sid: "test-sid-refresh-456" ) access_token = OidcAccessToken.create!( application: @application, user: @user, scope: "openid profile" ) refresh_token = OidcRefreshToken.create!( application: @application, user: @user, oidc_access_token: access_token, scope: "openid profile" ) plaintext_refresh_token = refresh_token.token # Normal refresh WITHOUT claims parameter should work fine post "/oauth/token", params: { grant_type: "refresh_token", refresh_token: plaintext_refresh_token }, headers: { "Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}") } assert_response :success response_body = JSON.parse(response.body) assert response_body.key?("access_token") assert response_body.key?("id_token") end # ==================== # CLAIMS PARAMETER IS AUTHORIZATION-ONLY # ==================== test "claims parameter is only valid in authorization request per OIDC spec" do # Per OIDC Core spec section 18.2.1, claims parameter usage location is "Authorization Request" # This test verifies that claims parameter cannot be used at token endpoint OidcUserConsent.create!( user: @user, application: @application, scopes_granted: "openid", granted_at: Time.current, sid: "test-sid-123" ) auth_code = OidcAuthorizationCode.create!( application: @application, user: @user, redirect_uri: "http://localhost:4000/callback", scope: "openid", expires_at: 10.minutes.from_now ) # Test various attempts to inject claims parameter malicious_claims = [ '{"id_token":{"admin":true}}', '{"id_token":{"email":{"essential":true}}}', '{"userinfo":{"groups":{"values":["admin"]}}}', '{"id_token":{"custom_claim":"custom_value"}}', "invalid-json" ] malicious_claims.each do |claims_value| post "/oauth/token", params: { grant_type: "authorization_code", code: auth_code.plaintext_code, redirect_uri: "http://localhost:4000/callback", claims: claims_value }, headers: { "Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}") } # All should be rejected assert_response :bad_request, "Claims parameter '#{claims_value}' should be rejected" error = JSON.parse(response.body) assert_equal "invalid_request", error["error"] end end # ==================== # VERIFY CONSENT-BASED ACCESS IS ENFORCED # ==================== test "token endpoint respects scopes granted during authorization" do # Create consent with ONLY openid scope (no email, profile, etc.) OidcUserConsent.create!( user: @user, application: @application, scopes_granted: "openid", granted_at: Time.current, sid: "test-sid-123" ) auth_code = OidcAuthorizationCode.create!( application: @application, user: @user, redirect_uri: "http://localhost:4000/callback", scope: "openid", expires_at: 10.minutes.from_now ) # Exchange code for tokens post "/oauth/token", params: { grant_type: "authorization_code", code: auth_code.plaintext_code, redirect_uri: "http://localhost:4000/callback" }, headers: { "Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}") } assert_response :success response_body = JSON.parse(response.body) id_token = response_body["id_token"] # Decode ID token to check claims decoded = JWT.decode(id_token, nil, false).first # Should only have required claims, not email/profile assert_includes decoded.keys, "iss" assert_includes decoded.keys, "sub" assert_includes decoded.keys, "aud" assert_includes decoded.keys, "exp" assert_includes decoded.keys, "iat" # Should NOT have claims that weren't consented to refute_includes decoded.keys, "email", "Should not include email without email scope" refute_includes decoded.keys, "email_verified", "Should not include email_verified without email scope" refute_includes decoded.keys, "name", "Should not include name without profile scope" refute_includes decoded.keys, "preferred_username", "Should not include preferred_username without profile scope" end test "refresh token preserves original scopes granted during authorization" do # Create consent with specific scopes OidcUserConsent.create!( user: @user, application: @application, scopes_granted: "openid email", granted_at: Time.current, sid: "test-sid-refresh-123" ) access_token = OidcAccessToken.create!( application: @application, user: @user, scope: "openid email" ) refresh_token = OidcRefreshToken.create!( application: @application, user: @user, oidc_access_token: access_token, scope: "openid email" ) plaintext_refresh_token = refresh_token.token # Refresh the token post "/oauth/token", params: { grant_type: "refresh_token", refresh_token: plaintext_refresh_token }, headers: { "Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}") } assert_response :success response_body = JSON.parse(response.body) id_token = response_body["id_token"] # Decode ID token to verify scopes are preserved decoded = JWT.decode(id_token, nil, false).first # Should have email claims (from original consent) assert_includes decoded.keys, "email", "Should preserve email scope from original consent" assert_includes decoded.keys, "email_verified", "Should preserve email_verified scope from original consent" # Should NOT have profile claims (not in original consent) refute_includes decoded.keys, "name", "Should not add profile claims that weren't consented to" refute_includes decoded.keys, "preferred_username", "Should not add preferred_username that wasn't consented to" end end