Fix forward_auth bugs - including disabled apps still working. Fix forward_auth tests
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-12-29 15:37:12 +11:00
parent 5b9d15584a
commit 0361bfe470
3 changed files with 364 additions and 162 deletions

View File

@@ -438,4 +438,315 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
assert timing_difference < 0.05,
"Timing difference #{timing_difference}s suggests potential timing attack vulnerability"
end
# ====================
# STATE PARAMETER BINDING (CSRF PREVENTION FOR OAUTH)
# ====================
test "state parameter is required and validated in authorization flow" do
# Create consent to skip consent page
OidcUserConsent.create!(
user: @user,
application: @application,
scopes_granted: "openid profile",
granted_at: Time.current,
sid: "test-sid-123"
)
# Test authorization with state parameter
get "/oauth/authorize", params: {
client_id: @application.client_id,
redirect_uri: "http://localhost:4000/callback",
response_type: "code",
scope: "openid profile",
state: "random_state_123"
}
# Should include state in redirect
assert_response :redirect
assert_match(/state=random_state_123/, response.location)
end
test "authorization without state parameter still works but is less secure" do
# Create consent to skip consent page
OidcUserConsent.create!(
user: @user,
application: @application,
scopes_granted: "openid profile",
granted_at: Time.current,
sid: "test-sid-123"
)
# Sign in first
post signin_path, params: { email_address: "security_test@example.com", password: "password123" }
# Test authorization without state parameter
get "/oauth/authorize", params: {
client_id: @application.client_id,
redirect_uri: "http://localhost:4000/callback",
response_type: "code",
scope: "openid profile"
}
# Should work but state is recommended for CSRF protection
assert_response :redirect
end
# ====================
# NONCE PARAMETER VALIDATION (FOR ID TOKENS)
# ====================
test "nonce parameter is included in ID token" do
# Create consent
consent = OidcUserConsent.create!(
user: @user,
application: @application,
scopes_granted: "openid profile",
granted_at: Time.current,
sid: "test-sid-123"
)
# Create authorization code with nonce
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
nonce: "test_nonce_123",
expires_at: 10.minutes.from_now
)
# Exchange code for tokens
post "/oauth/token", params: {
grant_type: "authorization_code",
code: auth_code.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 (without verification for this test)
decoded_token = JWT.decode(id_token, nil, false)
# Verify nonce is included in ID token
assert_equal "test_nonce_123", decoded_token[0]["nonce"]
end
# ====================
# TOKEN LEAKAGE VIA REFERER HEADER TESTS
# ====================
test "access tokens are not exposed in referer header" do
# Create consent and authorization code
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,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
expires_at: 10.minutes.from_now
)
# Exchange code for tokens
post "/oauth/token", params: {
grant_type: "authorization_code",
code: auth_code.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)
access_token = response_body["access_token"]
# Verify token is not in response headers (especially Referer)
assert_nil response.headers["Referer"], "Access token should not leak in Referer header"
assert_nil response.headers["Location"], "Access token should not leak in Location header"
end
# ====================
# PKCE ENFORCEMENT FOR PUBLIC CLIENTS TESTS
# ====================
test "PKCE code_verifier is required when code_challenge was provided" do
# Create consent
consent = OidcUserConsent.create!(
user: @user,
application: @application,
scopes_granted: "openid profile",
granted_at: Time.current,
sid: "test-sid-123"
)
# Create authorization code with PKCE challenge
code_verifier = SecureRandom.urlsafe_base64(32)
code_challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false)
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
code_challenge: code_challenge,
code_challenge_method: "S256",
expires_at: 10.minutes.from_now
)
# Try to exchange code without code_verifier
post "/oauth/token", params: {
grant_type: "authorization_code",
code: auth_code.code,
redirect_uri: "http://localhost:4000/callback"
}, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
}
assert_response :bad_request
error = JSON.parse(@response.body)
assert_equal "invalid_request", error["error"]
assert_match(/code_verifier is required/, error["error_description"])
end
test "PKCE with S256 method validates correctly" do
# Create consent
consent = OidcUserConsent.create!(
user: @user,
application: @application,
scopes_granted: "openid profile",
granted_at: Time.current,
sid: "test-sid-123"
)
# Create authorization code with PKCE S256
code_verifier = SecureRandom.urlsafe_base64(32)
code_challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false)
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
code_challenge: code_challenge,
code_challenge_method: "S256",
expires_at: 10.minutes.from_now
)
# Exchange code with correct code_verifier
post "/oauth/token", params: {
grant_type: "authorization_code",
code: auth_code.code,
redirect_uri: "http://localhost:4000/callback",
code_verifier: code_verifier
}, 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")
end
test "PKCE rejects invalid code_verifier" do
# Create consent
consent = OidcUserConsent.create!(
user: @user,
application: @application,
scopes_granted: "openid profile",
granted_at: Time.current,
sid: "test-sid-123"
)
# Create authorization code with PKCE
code_verifier = SecureRandom.urlsafe_base64(32)
code_challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false)
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
code_challenge: code_challenge,
code_challenge_method: "S256",
expires_at: 10.minutes.from_now
)
# Try with wrong code_verifier
post "/oauth/token", params: {
grant_type: "authorization_code",
code: auth_code.code,
redirect_uri: "http://localhost:4000/callback",
code_verifier: "wrong_code_verifier_12345678901234567890"
}, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
}
assert_response :bad_request
error = JSON.parse(@response.body)
assert_equal "invalid_grant", error["error"]
end
# ====================
# REFRESH TOKEN ROTATION TESTS
# ====================
test "refresh token rotation is enforced" do
# Create initial access and refresh tokens
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"
)
original_token_family_id = refresh_token.token_family_id
old_refresh_token = refresh_token.token
# Refresh the token
post "/oauth/token", params: {
grant_type: "refresh_token",
refresh_token: old_refresh_token
}, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
}
assert_response :success
response_body = JSON.parse(@response.body)
new_refresh_token = response_body["refresh_token"]
# Verify new refresh token is different
assert_not_equal old_refresh_token, new_refresh_token
# Verify token family is preserved
new_token_record = OidcRefreshToken.where(application: @application).find do |rt|
rt.token_matches?(new_refresh_token)
end
assert_equal original_token_family_id, new_token_record.token_family_id
# Old refresh token should be revoked
old_token_record = OidcRefreshToken.find(refresh_token.id)
assert old_token_record.revoked?
end
end