395 lines
13 KiB
Ruby
395 lines
13 KiB
Ruby
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
|