Files
clinch/test/controllers/oidc_authorization_code_security_test.rb

442 lines
14 KiB
Ruby

require "test_helper"
class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
def setup
@user = User.create!(email_address: "security_test@example.com", password: "password123")
@application = Application.create!(
name: "Security Test App",
slug: "security-test-app",
app_type: "oidc",
redirect_uris: ["http://localhost:4000/callback"].to_json,
active: true
)
# Store the plain text client secret for testing
@client_secret = @application.client_secret_digest
@application.generate_new_client_secret!
@plain_client_secret = @application.client_secret
@application.save!
end
def teardown
OidcAuthorizationCode.where(application: @application).delete_all
# Use delete_all to avoid triggering callbacks that might have issues with the schema
OidcAccessToken.where(application: @application).delete_all
@user.destroy
@application.destroy
end
# ====================
# CRITICAL SECURITY TESTS
# ====================
test "prevents authorization code reuse - sequential attempts" do
# Create a valid authorization code
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
)
token_params = {
grant_type: "authorization_code",
code: auth_code.code,
redirect_uri: "http://localhost:4000/callback"
}
# First request should succeed
post "/oauth/token", params: token_params, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
}
assert_response :success
first_response = JSON.parse(@response.body)
assert first_response.key?("access_token")
assert first_response.key?("id_token")
# Second request with same code should fail
post "/oauth/token", params: token_params, 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"]
assert_match(/already been used/, error["error_description"])
end
test "revokes existing tokens when authorization code is reused" do
# Create a valid authorization code
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
)
token_params = {
grant_type: "authorization_code",
code: auth_code.code,
redirect_uri: "http://localhost:4000/callback"
}
# First request - get access token
post "/oauth/token", params: token_params, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
}
assert_response :success
first_response = JSON.parse(@response.body)
first_access_token = first_response["access_token"]
# Verify the token works
get "/oauth/userinfo", headers: {
"Authorization" => "Bearer #{first_access_token}"
}
assert_response :success
# Second request with same code - should fail AND revoke first token
post "/oauth/token", params: token_params, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
}
assert_response :bad_request
# Verify the first token is now revoked (expired)
get "/oauth/userinfo", headers: {
"Authorization" => "Bearer #{first_access_token}"
}
assert_response :unauthorized, "First access token should be revoked after code reuse"
end
test "rejects already used authorization code" do
# Create and mark code as used
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
used: true,
expires_at: 10.minutes.from_now
)
token_params = {
grant_type: "authorization_code",
code: auth_code.code,
redirect_uri: "http://localhost:4000/callback"
}
post "/oauth/token", params: token_params, 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"]
assert_match(/already been used/, error["error_description"])
end
test "rejects expired authorization code" do
# Create expired code
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
expires_at: 5.minutes.ago
)
token_params = {
grant_type: "authorization_code",
code: auth_code.code,
redirect_uri: "http://localhost:4000/callback"
}
post "/oauth/token", params: token_params, 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"]
assert_match(/expired/, error["error_description"])
end
test "rejects authorization code with mismatched redirect_uri" do
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
)
token_params = {
grant_type: "authorization_code",
code: auth_code.code,
redirect_uri: "http://evil.com/callback" # Wrong redirect URI
}
post "/oauth/token", params: token_params, 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"]
assert_match(/Redirect URI mismatch/, error["error_description"])
end
test "rejects non-existent authorization code" do
token_params = {
grant_type: "authorization_code",
code: "nonexistent_code_12345",
redirect_uri: "http://localhost:4000/callback"
}
post "/oauth/token", params: token_params, 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
test "rejects authorization code for different application" do
# Create another application
other_app = Application.create!(
name: "Other App",
slug: "other-app",
app_type: "oidc",
redirect_uris: ["http://localhost:5000/callback"].to_json,
active: true
)
other_secret = other_app.client_secret
# Create auth code for first application
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
)
# Try to use it with different application credentials
token_params = {
grant_type: "authorization_code",
code: auth_code.code,
redirect_uri: "http://localhost:4000/callback"
}
post "/oauth/token", params: token_params, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{other_app.client_id}:#{other_secret}")
}
assert_response :bad_request
error = JSON.parse(@response.body)
assert_equal "invalid_grant", error["error"]
other_app.destroy
end
# ====================
# CLIENT AUTHENTICATION TESTS
# ====================
test "rejects invalid client_id in Basic auth" do
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
)
token_params = {
grant_type: "authorization_code",
code: auth_code.code,
redirect_uri: "http://localhost:4000/callback"
}
post "/oauth/token", params: token_params, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("invalid_client_id:#{@plain_client_secret}")
}
assert_response :unauthorized
error = JSON.parse(@response.body)
assert_equal "invalid_client", error["error"]
end
test "rejects invalid client_secret in Basic auth" do
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
)
token_params = {
grant_type: "authorization_code",
code: auth_code.code,
redirect_uri: "http://localhost:4000/callback"
}
post "/oauth/token", params: token_params, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:wrong_secret")
}
assert_response :unauthorized
error = JSON.parse(@response.body)
assert_equal "invalid_client", error["error"]
end
test "accepts client credentials in POST body" do
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
)
token_params = {
grant_type: "authorization_code",
code: auth_code.code,
redirect_uri: "http://localhost:4000/callback",
client_id: @application.client_id,
client_secret: @plain_client_secret
}
post "/oauth/token", params: token_params
assert_response :success
response_body = JSON.parse(@response.body)
assert response_body.key?("access_token")
assert response_body.key?("id_token")
end
test "rejects request with no client authentication" do
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
)
token_params = {
grant_type: "authorization_code",
code: auth_code.code,
redirect_uri: "http://localhost:4000/callback"
}
post "/oauth/token", params: token_params
assert_response :unauthorized
error = JSON.parse(@response.body)
assert_equal "invalid_client", error["error"]
end
# ====================
# GRANT TYPE VALIDATION
# ====================
test "rejects unsupported grant_type" do
post "/oauth/token", params: {
grant_type: "password",
username: "user",
password: "pass"
}, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
}
assert_response :bad_request
error = JSON.parse(@response.body)
assert_equal "unsupported_grant_type", error["error"]
end
test "rejects missing grant_type" do
post "/oauth/token", params: {
code: "some_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 "unsupported_grant_type", error["error"]
end
# ====================
# TIMING ATTACK PROTECTION
# ====================
test "client authentication uses constant-time comparison" do
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
)
token_params = {
grant_type: "authorization_code",
code: auth_code.code,
redirect_uri: "http://localhost:4000/callback"
}
# Test with completely wrong secret
times_wrong = []
5.times do
start_time = Time.now.to_f
post "/oauth/token", params: token_params, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:wrong_secret_xxx")
}
times_wrong << (Time.now.to_f - start_time)
assert_response :unauthorized
end
# Test with almost correct secret (differs by one character)
correct_secret = @plain_client_secret
almost_correct = correct_secret[0..-2] + "X"
times_almost = []
5.times do
start_time = Time.now.to_f
post "/oauth/token", params: token_params, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{almost_correct}")
}
times_almost << (Time.now.to_f - start_time)
assert_response :unauthorized
end
# The timing difference should be minimal (within 50ms) if using constant-time comparison
avg_wrong = times_wrong.sum / times_wrong.size
avg_almost = times_almost.sum / times_almost.size
timing_difference = (avg_wrong - avg_almost).abs
# This is a best-effort check - in practice, constant-time comparison is handled by bcrypt
assert timing_difference < 0.05,
"Timing difference #{timing_difference}s suggests potential timing attack vulnerability"
end
end