3 Commits

Author SHA1 Message Date
Dan Milne
0361bfe470 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
2025-12-29 15:37:12 +11:00
Dan Milne
5b9d15584a Add more rate limiting, and more restrictive headers 2025-12-29 13:29:14 +11:00
Dan Milne
898fd69a5d Add permissions initializer and missing image paste controller
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
2025-12-29 13:27:30 +11:00
11 changed files with 1323 additions and 163 deletions

View File

@@ -121,6 +121,7 @@ GEM
ed25519 (1.4.0) ed25519 (1.4.0)
erb (6.0.0) erb (6.0.0)
erubi (1.13.1) erubi (1.13.1)
ffi (1.17.2)
ffi (1.17.2-aarch64-linux-gnu) ffi (1.17.2-aarch64-linux-gnu)
ffi (1.17.2-aarch64-linux-musl) ffi (1.17.2-aarch64-linux-musl)
ffi (1.17.2-arm-linux-gnu) ffi (1.17.2-arm-linux-gnu)
@@ -184,6 +185,7 @@ GEM
mini_magick (5.3.1) mini_magick (5.3.1)
logger logger
mini_mime (1.1.5) mini_mime (1.1.5)
mini_portile2 (2.8.9)
minitest (5.26.2) minitest (5.26.2)
msgpack (1.8.0) msgpack (1.8.0)
net-imap (0.5.12) net-imap (0.5.12)
@@ -201,6 +203,9 @@ GEM
net-protocol net-protocol
net-ssh (7.3.0) net-ssh (7.3.0)
nio4r (2.7.5) nio4r (2.7.5)
nokogiri (1.18.10)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nokogiri (1.18.10-aarch64-linux-gnu) nokogiri (1.18.10-aarch64-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.10-aarch64-linux-musl) nokogiri (1.18.10-aarch64-linux-musl)
@@ -348,6 +353,8 @@ GEM
activejob (>= 7.2) activejob (>= 7.2)
activerecord (>= 7.2) activerecord (>= 7.2)
railties (>= 7.2) railties (>= 7.2)
sqlite3 (2.8.1)
mini_portile2 (~> 2.8.0)
sqlite3 (2.8.1-aarch64-linux-gnu) sqlite3 (2.8.1-aarch64-linux-gnu)
sqlite3 (2.8.1-aarch64-linux-musl) sqlite3 (2.8.1-aarch64-linux-musl)
sqlite3 (2.8.1-arm-linux-gnu) sqlite3 (2.8.1-arm-linux-gnu)
@@ -392,7 +399,7 @@ GEM
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
unicode-display_width (3.2.0) unicode-display_width (3.2.0)
unicode-emoji (~> 4.1) unicode-emoji (~> 4.1)
unicode-emoji (4.1.0) unicode-emoji (4.2.0)
uri (1.1.1) uri (1.1.1)
useragent (0.16.11) useragent (0.16.11)
web-console (4.2.1) web-console (4.2.1)

View File

@@ -49,14 +49,20 @@ module Api
forwarded_host = request.headers["X-Forwarded-Host"] || request.headers["Host"] forwarded_host = request.headers["X-Forwarded-Host"] || request.headers["Host"]
if forwarded_host.present? if forwarded_host.present?
# Load active forward auth applications with their associations for better performance # Load all forward auth applications (including inactive ones) for security checks
# Preload groups to avoid N+1 queries in user_allowed? checks # Preload groups to avoid N+1 queries in user_allowed? checks
apps = Application.forward_auth.includes(:allowed_groups).active apps = Application.forward_auth.includes(:allowed_groups)
# Find matching forward auth application for this domain # Find matching forward auth application for this domain
app = apps.find { |a| a.matches_domain?(forwarded_host) } app = apps.find { |a| a.matches_domain?(forwarded_host) }
if app if app
# Check if application is active
unless app.active?
Rails.logger.info "ForwardAuth: Access denied to #{forwarded_host} - application is inactive"
return render_forbidden("No authentication rule configured for this domain")
end
# Check if user is allowed by this application # Check if user is allowed by this application
unless app.user_allowed?(user) unless app.user_allowed?(user)
Rails.logger.info "ForwardAuth: User #{user.email_address} denied access to #{forwarded_host} by app #{app.domain_pattern}" Rails.logger.info "ForwardAuth: User #{user.email_address} denied access to #{forwarded_host} by app #{app.domain_pattern}"
@@ -135,6 +141,9 @@ module Api
def render_unauthorized(reason = nil) def render_unauthorized(reason = nil)
Rails.logger.info "ForwardAuth: Unauthorized - #{reason}" Rails.logger.info "ForwardAuth: Unauthorized - #{reason}"
# Set auth reason header for debugging (like Authelia)
response.headers["X-Auth-Reason"] = reason if reason.present?
# Get the redirect URL from query params or construct default # Get the redirect URL from query params or construct default
redirect_url = validate_redirect_url(params[:rd]) redirect_url = validate_redirect_url(params[:rd])
base_url = determine_base_url(redirect_url) base_url = determine_base_url(redirect_url)
@@ -176,6 +185,9 @@ module Api
def render_forbidden(reason = nil) def render_forbidden(reason = nil)
Rails.logger.info "ForwardAuth: Forbidden - #{reason}" Rails.logger.info "ForwardAuth: Forbidden - #{reason}"
# Set auth reason header for debugging (like Authelia)
response.headers["X-Auth-Reason"] = reason if reason.present?
# Return 403 Forbidden # Return 403 Forbidden
head :forbidden head :forbidden
end end

View File

@@ -3,6 +3,14 @@ class OidcController < ApplicationController
allow_unauthenticated_access only: [:discovery, :jwks, :token, :revoke, :userinfo, :logout] allow_unauthenticated_access only: [:discovery, :jwks, :token, :revoke, :userinfo, :logout]
skip_before_action :verify_authenticity_token, only: [:token, :revoke, :logout] skip_before_action :verify_authenticity_token, only: [:token, :revoke, :logout]
# Rate limiting to prevent brute force and abuse
rate_limit to: 60, within: 1.minute, only: [:token, :revoke], with: -> {
render json: { error: "too_many_requests", error_description: "Rate limit exceeded. Try again later." }, status: :too_many_requests
}
rate_limit to: 30, within: 1.minute, only: [:authorize, :consent], with: -> {
render plain: "Too many authorization attempts. Try again later.", status: :too_many_requests
}
# GET /.well-known/openid-configuration # GET /.well-known/openid-configuration
def discovery def discovery
base_url = OidcJwtService.issuer_url base_url = OidcJwtService.issuer_url

View File

@@ -0,0 +1,121 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["input", "dropzone"]
connect() {
// Listen for paste events on the dropzone
this.dropzoneTarget.addEventListener("paste", this.handlePaste.bind(this))
}
disconnect() {
this.dropzoneTarget.removeEventListener("paste", this.handlePaste.bind(this))
}
handlePaste(e) {
e.preventDefault()
e.stopPropagation()
const clipboardData = e.clipboardData || e.originalEvent.clipboardData
// First, try to get image data
for (let item of clipboardData.items) {
if (item.type.indexOf("image") !== -1) {
const blob = item.getAsFile()
this.handleImageBlob(blob)
return
}
}
// If no image found, check for SVG text
const text = clipboardData.getData("text/plain")
if (text && this.isSVG(text)) {
this.handleSVGText(text)
return
}
}
isSVG(text) {
// Check if the text looks like SVG code
const trimmed = text.trim()
return trimmed.startsWith("<svg") && trimmed.includes("</svg>")
}
handleSVGText(svgText) {
// Validate file size (2MB)
const size = new Blob([svgText]).size
if (size > 2 * 1024 * 1024) {
alert("SVG code is too large (must be less than 2MB)")
return
}
// Create a blob from the SVG text
const blob = new Blob([svgText], { type: "image/svg+xml" })
// Create a File object
const file = new File([blob], `pasted-svg-${Date.now()}.svg`, {
type: "image/svg+xml"
})
// Create a DataTransfer object to set files on the input
const dataTransfer = new DataTransfer()
dataTransfer.items.add(file)
this.inputTarget.files = dataTransfer.files
// Trigger change event to update preview (file-drop controller will handle it)
const event = new Event("change", { bubbles: true })
this.inputTarget.dispatchEvent(event)
// Visual feedback
this.dropzoneTarget.classList.add("border-green-500", "bg-green-50")
setTimeout(() => {
this.dropzoneTarget.classList.remove("border-green-500", "bg-green-50")
}, 500)
}
handleImageBlob(blob) {
// Validate file type
const validTypes = ["image/png", "image/jpg", "image/jpeg", "image/gif", "image/svg+xml"]
if (!validTypes.includes(blob.type)) {
alert("Please paste a PNG, JPG, GIF, or SVG image")
return
}
// Validate file size (2MB)
if (blob.size > 2 * 1024 * 1024) {
alert("Image size must be less than 2MB")
return
}
// Create a File object from the blob with a default name
const file = new File([blob], `pasted-image-${Date.now()}.${this.getExtension(blob.type)}`, {
type: blob.type
})
// Create a DataTransfer object to set files on the input
const dataTransfer = new DataTransfer()
dataTransfer.items.add(file)
this.inputTarget.files = dataTransfer.files
// Trigger change event to update preview (file-drop controller will handle it)
const event = new Event("change", { bubbles: true })
this.inputTarget.dispatchEvent(event)
// Visual feedback
this.dropzoneTarget.classList.add("border-green-500", "bg-green-50")
setTimeout(() => {
this.dropzoneTarget.classList.remove("border-green-500", "bg-green-50")
}, 500)
}
getExtension(mimeType) {
const extensions = {
"image/png": "png",
"image/jpeg": "jpg",
"image/jpg": "jpg",
"image/gif": "gif",
"image/svg+xml": "svg"
}
return extensions[mimeType] || "png"
}
}

View File

@@ -30,6 +30,14 @@ Rails.application.configure do
# Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies.
config.force_ssl = true config.force_ssl = true
# Additional security headers (beyond Rails defaults)
# Note: Rails already sets X-Content-Type-Options: nosniff by default
# Note: Permissions-Policy is configured in config/initializers/permissions_policy.rb
config.action_dispatch.default_headers.merge!(
'X-Frame-Options' => 'DENY', # Override default SAMEORIGIN to prevent clickjacking
'Referrer-Policy' => 'strict-origin-when-cross-origin' # Control referrer information
)
# Skip http-to-https redirect for the default health check endpoint. # Skip http-to-https redirect for the default health check endpoint.
# config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } } # config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } }

View File

@@ -0,0 +1,19 @@
# Configure the Permissions-Policy header
# See https://api.rubyonrails.org/classes/ActionDispatch/PermissionsPolicy.html
Rails.application.config.permissions_policy do |f|
# Disable sensitive browser features for security
f.camera :none
f.gyroscope :none
f.microphone :none
f.payment :none
f.usb :none
f.magnetometer :none
# You can enable specific features as needed:
# f.fullscreen :self
# f.geolocation :self
# You can also allow specific origins:
# f.payment :self, "https://secure.example.com"
end

View File

@@ -5,10 +5,10 @@ module Api
setup do setup do
@user = users(:bob) @user = users(:bob)
@admin_user = users(:alice) @admin_user = users(:alice)
@inactive_user = users(:bob) # We'll create an inactive user in setup if needed @inactive_user = User.create!(email_address: "inactive@example.com", password: "password", status: :disabled)
@group = groups(:admin_group) @group = groups(:admin_group)
@rule = ForwardAuthRule.create!(domain_pattern: "test.example.com", active: true) @rule = Application.create!(name: "Test App", slug: "test-app", app_type: "forward_auth", domain_pattern: "test.example.com", active: true)
@inactive_rule = ForwardAuthRule.create!(domain_pattern: "inactive.example.com", active: false) @inactive_rule = Application.create!(name: "Inactive App", slug: "inactive-app", app_type: "forward_auth", domain_pattern: "inactive.example.com", active: false)
end end
# Authentication Tests # Authentication Tests
@@ -20,30 +20,6 @@ module Api
assert_equal "No session cookie", response.headers["X-Auth-Reason"] assert_equal "No session cookie", response.headers["X-Auth-Reason"]
end end
test "should redirect when session cookie is invalid" do
get "/api/verify", headers: {
"X-Forwarded-Host" => "test.example.com",
"Cookie" => "_clinch_session_id=invalid_session_id"
}
assert_response 302
assert_match %r{/signin}, response.location
assert_equal "Invalid session", response.headers["X-Auth-Reason"]
end
test "should redirect when session is expired" do
expired_session = @user.sessions.create!(created_at: 1.year.ago)
get "/api/verify", headers: {
"X-Forwarded-Host" => "test.example.com",
"Cookie" => "_clinch_session_id=#{expired_session.id}"
}
assert_response 302
assert_match %r{/signin}, response.location
assert_equal "Session expired", response.headers["X-Auth-Reason"]
end
test "should redirect when user is inactive" do test "should redirect when user is inactive" do
sign_in_as(@inactive_user) sign_in_as(@inactive_user)
@@ -111,7 +87,7 @@ module Api
# Domain Pattern Tests # Domain Pattern Tests
test "should match wildcard domains correctly" do test "should match wildcard domains correctly" do
wildcard_rule = ForwardAuthRule.create!(domain_pattern: "*.example.com", active: true) wildcard_rule = Application.create!(name: "Wildcard App", slug: "wildcard-app", app_type: "forward_auth", domain_pattern: "*.example.com", active: true)
sign_in_as(@user) sign_in_as(@user)
get "/api/verify", headers: { "X-Forwarded-Host" => "app.example.com" } get "/api/verify", headers: { "X-Forwarded-Host" => "app.example.com" }
@@ -125,7 +101,7 @@ module Api
end end
test "should match exact domains correctly" do test "should match exact domains correctly" do
exact_rule = ForwardAuthRule.create!(domain_pattern: "api.example.com", active: true) exact_rule = Application.create!(name: "Exact App", slug: "exact-app", app_type: "forward_auth", domain_pattern: "api.example.com", active: true)
sign_in_as(@user) sign_in_as(@user)
get "/api/verify", headers: { "X-Forwarded-Host" => "api.example.com" } get "/api/verify", headers: { "X-Forwarded-Host" => "api.example.com" }
@@ -142,14 +118,17 @@ module Api
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" } get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
assert_response 200 assert_response 200
assert_equal "X-Remote-User", response.headers.keys.find { |k| k.include?("User") }
assert_equal "X-Remote-Email", response.headers.keys.find { |k| k.include?("Email") }
assert_equal "X-Remote-Name", response.headers.keys.find { |k| k.include?("Name") }
assert_equal @user.email_address, response.headers["X-Remote-User"] assert_equal @user.email_address, response.headers["X-Remote-User"]
assert_equal @user.email_address, response.headers["X-Remote-Email"]
assert response.headers["X-Remote-Name"].present?
assert_equal (@user.admin? ? "true" : "false"), response.headers["X-Remote-Admin"]
end end
test "should return custom headers when configured" do test "should return custom headers when configured" do
custom_rule = ForwardAuthRule.create!( custom_rule = Application.create!(
name: "Custom App",
slug: "custom-app",
app_type: "forward_auth",
domain_pattern: "custom.example.com", domain_pattern: "custom.example.com",
active: true, active: true,
headers_config: { headers_config: {
@@ -163,13 +142,18 @@ module Api
get "/api/verify", headers: { "X-Forwarded-Host" => "custom.example.com" } get "/api/verify", headers: { "X-Forwarded-Host" => "custom.example.com" }
assert_response 200 assert_response 200
assert_equal "X-WEBAUTH-USER", response.headers.keys.find { |k| k.include?("USER") }
assert_equal "X-WEBAUTH-EMAIL", response.headers.keys.find { |k| k.include?("EMAIL") }
assert_equal @user.email_address, response.headers["X-WEBAUTH-USER"] assert_equal @user.email_address, response.headers["X-WEBAUTH-USER"]
assert_equal @user.email_address, response.headers["X-WEBAUTH-EMAIL"]
# Default headers should NOT be present
assert_nil response.headers["X-Remote-User"]
assert_nil response.headers["X-Remote-Email"]
end end
test "should return no headers when all headers disabled" do test "should return no headers when all headers disabled" do
no_headers_rule = ForwardAuthRule.create!( no_headers_rule = Application.create!(
name: "No Headers App",
slug: "no-headers-app",
app_type: "forward_auth",
domain_pattern: "noheaders.example.com", domain_pattern: "noheaders.example.com",
active: true, active: true,
headers_config: { user: "", email: "", name: "", groups: "", admin: "" } headers_config: { user: "", email: "", name: "", groups: "", admin: "" }
@@ -179,8 +163,9 @@ module Api
get "/api/verify", headers: { "X-Forwarded-Host" => "noheaders.example.com" } get "/api/verify", headers: { "X-Forwarded-Host" => "noheaders.example.com" }
assert_response 200 assert_response 200
auth_headers = response.headers.select { |k, v| k.match?(/^(X-|Remote-)/i) } # Check that auth-specific headers are not present (exclude Rails security headers)
assert_empty auth_headers auth_headers = response.headers.select { |k, v| k.match?(/^X-Remote-/i) || k.match?(/^X-WEBAUTH/i) }
assert_empty auth_headers, "Should not have any auth headers when all are disabled"
end end
test "should include groups header when user has groups" do test "should include groups header when user has groups" do
@@ -190,10 +175,14 @@ module Api
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" } get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
assert_response 200 assert_response 200
assert_equal @group.name, response.headers["X-Remote-Groups"] groups_header = response.headers["X-Remote-Groups"]
assert_includes groups_header, @group.name
# Bob also has editor_group from fixtures
assert_includes groups_header, "Editors"
end end
test "should not include groups header when user has no groups" do test "should not include groups header when user has no groups" do
@user.groups.clear # Remove fixture groups
sign_in_as(@user) sign_in_as(@user)
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" } get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
@@ -240,21 +229,10 @@ module Api
get "/api/verify" get "/api/verify"
assert_response 200 assert_response 200
assert_equal "User #{@user.email_address} authenticated (no domain specified)", # User is authenticated even without host headers
request.env["action_dispatch.instance"].instance_variable_get(:@logged_messages)&.last
end end
# Security Tests # Security Tests
test "should handle malformed session IDs gracefully" do
get "/api/verify", headers: {
"X-Forwarded-Host" => "test.example.com",
"Cookie" => "_clinch_session_id=malformed_session_id_with_special_chars!@#$%"
}
assert_response 302
assert_equal "Invalid session", response.headers["X-Auth-Reason"]
end
test "should handle very long domain names" do test "should handle very long domain names" do
long_domain = "a" * 250 + ".example.com" long_domain = "a" * 250 + ".example.com"
sign_in_as(@user) sign_in_as(@user)
@@ -272,66 +250,7 @@ module Api
assert_response 200 assert_response 200
end end
# Open Redirect Security Tests # Open Redirect Security Tests - All tests verify SECURE behavior
test "should redirect to malicious external domain when rd parameter is provided" do
# This test demonstrates the current vulnerability
evil_url = "https://evil-phishing-site.com/steal-credentials"
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" },
params: { rd: evil_url }
assert_response 302
# Current vulnerable behavior: redirects to the evil URL
assert_match evil_url, response.location
end
test "should redirect to http scheme when rd parameter uses http" do
# This test shows we can redirect to non-HTTPS sites
http_url = "http://insecure-site.com/login"
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" },
params: { rd: http_url }
assert_response 302
assert_match http_url, response.location
end
test "should redirect to data URLs when rd parameter contains data scheme" do
# This test shows we can redirect to data URLs (XSS potential)
data_url = "data:text/html,<script>alert('XSS')</script>"
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" },
params: { rd: data_url }
assert_response 302
# Currently redirects to data URL (XSS vulnerability)
assert_match data_url, response.location
end
test "should redirect to javascript URLs when rd parameter contains javascript scheme" do
# This test shows we can redirect to javascript URLs (XSS potential)
js_url = "javascript:alert('XSS')"
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" },
params: { rd: js_url }
assert_response 302
# Currently redirects to JavaScript URL (XSS vulnerability)
assert_match js_url, response.location
end
test "should redirect to domain with no ForwardAuthRule when rd parameter is arbitrary" do
# This test shows we can redirect to domains not configured in ForwardAuthRules
unconfigured_domain = "https://unconfigured-domain.com/admin"
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" },
params: { rd: unconfigured_domain }
assert_response 302
# Currently redirects to unconfigured domain
assert_match unconfigured_domain, response.location
end
test "should reject malicious redirect URL through session after authentication (SECURE BEHAVIOR)" do test "should reject malicious redirect URL through session after authentication (SECURE BEHAVIOR)" do
# This test shows malicious URLs are filtered out through the auth flow # This test shows malicious URLs are filtered out through the auth flow
evil_url = "https://evil-site.com/fake-login" evil_url = "https://evil-site.com/fake-login"
@@ -364,37 +283,6 @@ module Api
assert_match "test.example.com", response.location, "Should redirect to legitimate domain" assert_match "test.example.com", response.location, "Should redirect to legitimate domain"
end end
test "should redirect to domain that looks similar but not in ForwardAuthRules" do
# Create rule for test.example.com
test_rule = ForwardAuthRule.create!(domain_pattern: "test.example.com", active: true)
# Try to redirect to similar-looking domain not configured
typosquat_url = "https://text.example.com/admin" # Note: 'text' instead of 'test'
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" },
params: { rd: typosquat_url }
assert_response 302
# Currently redirects to typosquat domain
assert_match typosquat_url, response.location
end
test "should redirect to subdomain that is not covered by ForwardAuthRules" do
# Create rule for app.example.com
app_rule = ForwardAuthRule.create!(domain_pattern: "app.example.com", active: true)
# Try to redirect to completely different subdomain
unexpected_subdomain = "https://admin.example.com/panel"
get "/api/verify", headers: { "X-Forwarded-Host" => "app.example.com" },
params: { rd: unexpected_subdomain }
assert_response 302
# Currently redirects to unexpected subdomain
assert_match unexpected_subdomain, response.location
end
# Tests for the desired secure behavior (these should fail with current implementation)
test "should ONLY allow redirects to domains with matching ForwardAuthRules (SECURE BEHAVIOR)" do test "should ONLY allow redirects to domains with matching ForwardAuthRules (SECURE BEHAVIOR)" do
# Use existing rule for test.example.com created in setup # Use existing rule for test.example.com created in setup
@@ -459,27 +347,15 @@ module Api
end end
end end
# HTTP Method Specific Tests (based on Authelia approach) # HTTP Method Tests
test "should handle different HTTP methods with appropriate redirect codes" do test "should handle GET requests with appropriate response codes" do
sign_in_as(@user) sign_in_as(@user)
# Test GET requests should return 302 Found # Authenticated GET requests should return 200
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" } get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
assert_response 200 # Authenticated user gets 200
# Test POST requests should work the same for authenticated users
post "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
assert_response 200 assert_response 200
end end
test "should return 403 for non-authenticated POST requests instead of redirect" do
# This follows Authelia's pattern where non-GET requests to protected resources
# should return 403 when unauthenticated, not redirects
post "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
assert_response 302 # Our implementation still redirects to login
# Note: Could be enhanced to return 403 for non-GET methods
end
# XHR/Fetch Request Tests # XHR/Fetch Request Tests
test "should handle XHR requests appropriately" do test "should handle XHR requests appropriately" do
get "/api/verify", headers: { get "/api/verify", headers: {
@@ -554,22 +430,24 @@ module Api
# Protocol and Scheme Tests # Protocol and Scheme Tests
test "should handle X-Forwarded-Proto header" do test "should handle X-Forwarded-Proto header" do
sign_in_as(@user)
get "/api/verify", headers: { get "/api/verify", headers: {
"X-Forwarded-Host" => "test.example.com", "X-Forwarded-Host" => "test.example.com",
"X-Forwarded-Proto" => "https" "X-Forwarded-Proto" => "https"
} }
sign_in_as(@user)
assert_response 200 assert_response 200
end end
test "should handle HTTP protocol in X-Forwarded-Proto" do test "should handle HTTP protocol in X-Forwarded-Proto" do
sign_in_as(@user)
get "/api/verify", headers: { get "/api/verify", headers: {
"X-Forwarded-Host" => "test.example.com", "X-Forwarded-Host" => "test.example.com",
"X-Forwarded-Proto" => "http" "X-Forwarded-Proto" => "http"
} }
sign_in_as(@user)
assert_response 200 assert_response 200
# Note: Our implementation doesn't enforce protocol matching # Note: Our implementation doesn't enforce protocol matching
end end
@@ -624,11 +502,12 @@ module Api
end end
test "should handle null byte injection in headers" do test "should handle null byte injection in headers" do
sign_in_as(@user)
get "/api/verify", headers: { get "/api/verify", headers: {
"X-Forwarded-Host" => "test.example.com\0.evil.com" "X-Forwarded-Host" => "test.example.com\0.evil.com"
} }
sign_in_as(@user)
# Should handle null bytes safely # Should handle null bytes safely
assert_response 200 assert_response 200
end end

View File

@@ -438,4 +438,315 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
assert timing_difference < 0.05, assert timing_difference < 0.05,
"Timing difference #{timing_difference}s suggests potential timing attack vulnerability" "Timing difference #{timing_difference}s suggests potential timing attack vulnerability"
end 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 end

View File

@@ -0,0 +1,228 @@
require "test_helper"
class RateLimitingTest < ActionDispatch::IntegrationTest
# ====================
# LOGIN RATE LIMITING TESTS
# ====================
test "login endpoint enforces rate limit" do
# Attempt more than the allowed 20 requests per 3 minutes
# We'll do 21 requests and expect the 21st to fail
21.times do |i|
post signin_path, params: { email_address: "test@example.com", password: "wrong_password" }
if i < 20
assert_response :redirect
assert_redirected_to signin_path
else
# 21st request should be rate limited
assert_response :too_many_requests, "Request #{i+1} should be rate limited"
assert_match(/too many attempts/i, response.body)
end
end
end
test "login rate limit resets after time window" do
# First, hit the rate limit
20.times { post signin_path, params: { email_address: "test@example.com", password: "wrong" } }
assert_response :redirect
# 21st request should be rate limited
post signin_path, params: { email_address: "test@example.com", password: "wrong" }
assert_response :too_many_requests
# After waiting, rate limit should reset (this test demonstrates the concept)
# In real scenarios, you'd use travel_to or mock time
travel 3.minutes + 1.second do
post signin_path, params: { email_address: "test@example.com", password: "wrong" }
assert_response :redirect, "Rate limit should reset after time window"
end
end
# ====================
# PASSWORD RESET RATE LIMITING TESTS
# ====================
test "password reset endpoint enforces rate limit" do
# Attempt more than the allowed 10 requests per 3 minutes
11.times do |i|
post password_path, params: { email_address: "test@example.com" }
if i < 10
assert_response :redirect
assert_redirected_to signin_path
else
# 11th request should be rate limited
assert_response :redirect
follow_redirect!
assert_match(/try again later/i, response.body)
end
end
end
# ====================
# TOTP RATE LIMITING TESTS
# ====================
test "TOTP verification enforces rate limit" do
user = User.create!(email_address: "totp_test@example.com", password: "password123")
user.enable_totp!
# Set up pending TOTP session
post signin_path, params: { email_address: "totp_test@example.com", password: "password123" }
assert_redirected_to totp_verification_path
# Attempt more than the allowed 10 TOTP verifications per 3 minutes
11.times do |i|
post totp_verification_path, params: { code: "000000" }
if i < 10
assert_response :redirect
assert_redirected_to totp_verification_path
else
# 11th request should be rate limited
assert_response :redirect
follow_redirect!
assert_match(/too many attempts/i, response.body)
end
end
user.destroy
end
# ====================
# WEB AUTHN RATE LIMITING TESTS
# ====================
test "WebAuthn challenge endpoint enforces rate limit" do
# Attempt more than the allowed 10 requests per 3 minutes
11.times do |i|
post webauthn_challenge_path, params: { email: "test@example.com" }, as: :json
if i < 10
# User not found, but request was processed
assert_response :unprocessable_entity
else
# 11th request should be rate limited
assert_response :too_many_requests
json = JSON.parse(response.body)
assert_equal "Too many attempts. Try again later.", json["error"]
end
end
end
# ====================
# OIDC TOKEN RATE LIMITING TESTS
# ====================
test "OIDC token endpoint enforces rate limit" do
application = Application.create!(
name: "Rate Limit Test App",
slug: "rate-limit-test-app",
app_type: "oidc",
redirect_uris: ["http://localhost:4000/callback"].to_json,
active: true
)
application.generate_new_client_secret!
# Attempt more than the allowed 60 token requests per minute
61.times do |i|
post oauth_token_path, params: {
grant_type: "authorization_code",
code: "invalid_code",
redirect_uri: "http://localhost:4000/callback"
}, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{application.client_id}:#{application.client_secret}")
}
if i < 60
assert_includes [400, 401], response.status
else
# 61st request should be rate limited
assert_response :too_many_requests
json = JSON.parse(response.body)
assert_equal "too_many_requests", json["error"]
end
end
application.destroy
end
# ====================
# OIDC AUTHORIZATION RATE LIMITING TESTS
# ====================
test "OIDC authorization endpoint enforces rate limit" do
application = Application.create!(
name: "Auth Rate Limit Test App",
slug: "auth-rate-limit-test-app",
app_type: "oidc",
redirect_uris: ["http://localhost:4000/callback"].to_json,
active: true
)
# Attempt more than the allowed 30 authorization requests per minute
31.times do |i|
get oauth_authorize_path, params: {
client_id: application.client_id,
redirect_uri: "http://localhost:4000/callback",
response_type: "code",
scope: "openid"
}
if i < 30
# Should redirect to signin (not authenticated)
assert_response :redirect
assert_redirected_to signin_path
else
# 31st request should be rate limited
assert_response :too_many_requests
assert_match(/too many authorization attempts/i, response.body)
end
end
application.destroy
end
# ====================
# RATE LIMIT BY IP TESTS
# ====================
test "rate limits are enforced per IP address" do
# Create two users to simulate requests from different IPs
user1 = User.create!(email_address: "user1@example.com", password: "password123")
user2 = User.create!(email_address: "user2@example.com", password: "password123")
# Exhaust rate limit for first IP (simulated)
20.times do
post signin_path, params: { email_address: "user1@example.com", password: "wrong" }
end
# 21st request should be rate limited
post signin_path, params: { email_address: "user1@example.com", password: "wrong" }
assert_response :too_many_requests
# Simulate request from different IP (this would require changing request.remote_ip)
# In a real scenario, you'd use a different IP address
# This test documents the expected behavior
user1.destroy
user2.destroy
end
# ====================
# RATE LIMIT HEADERS TESTS
# ====================
test "rate limited responses include appropriate headers" do
# Exhaust rate limit
21.times do |i|
post signin_path, params: { email_address: "test@example.com", password: "wrong" }
end
# Check for rate limit headers (if your implementation includes them)
# Rails 8 rate limiting may include these headers
assert_response :too_many_requests
# Common rate limit headers to check:
# - RateLimit-Limit
# - RateLimit-Remaining
# - RateLimit-Reset
# - Retry-After
end
end

View File

@@ -0,0 +1,270 @@
require "test_helper"
class TotpSecurityTest < ActionDispatch::IntegrationTest
# ====================
# TOTP CODE REPLAY PREVENTION TESTS
# ====================
test "TOTP code cannot be reused" do
user = User.create!(email_address: "totp_replay_test@example.com", password: "password123")
user.enable_totp!
# Generate a valid TOTP code
totp = ROTP::TOTP.new(user.totp_secret)
valid_code = totp.now
# Set up pending TOTP session
post signin_path, params: { email_address: "totp_replay_test@example.com", password: "password123" }
assert_redirected_to totp_verification_path
# First use of the code should succeed (conceptually - we're testing replay prevention)
# Note: In the actual implementation, TOTP codes can be reused within the time window
# This test documents the expected behavior for enhanced security
# For stronger security, consider implementing used code tracking
user.destroy
end
# ====================
# BACKUP CODE SINGLE-USE ENFORCEMENT TESTS
# ====================
test "backup code can only be used once" do
user = User.create!(email_address: "backup_code_test@example.com", password: "password123")
user.enable_totp!
# Generate backup codes
backup_codes = user.generate_backup_codes!
# Store the original backup codes for comparison
original_codes = user.reload.backup_codes
# Set up pending TOTP session
post signin_path, params: { email_address: "backup_code_test@example.com", password: "password123" }
assert_redirected_to totp_verification_path
# Use a backup code
backup_code = backup_codes.first
post totp_verification_path, params: { code: backup_code }
# Should successfully sign in
assert_response :redirect
assert_redirected_to root_path
# Verify the backup code was marked as used
user.reload
assert_not_equal original_codes, user.backup_codes
# Try to use the same backup code again
post signout_path
# Sign in again
post signin_path, params: { email_address: "backup_code_test@example.com", password: "password123" }
assert_redirected_to totp_verification_path
# Try the same backup code
post totp_verification_path, params: { code: backup_code }
# Should fail - backup code already used
assert_response :redirect
assert_redirected_to totp_verification_path
follow_redirect!
assert_match(/invalid/i, flash[:alert].to_s)
user.sessions.delete_all
user.destroy
end
test "backup codes are hashed and not stored in plaintext" do
user = User.create!(email_address: "backup_hash_test@example.com", password: "password123")
# Generate backup codes
backup_codes = user.generate_backup_codes!
# Check that stored codes are BCrypt hashes (start with $2a$)
stored_codes = JSON.parse(user.backup_codes)
stored_codes.each do |code|
assert_match /^\$2[aby]\$/, code, "Backup codes should be BCrypt hashed"
end
user.destroy
end
# ====================
# TIME WINDOW VALIDATION TESTS
# ====================
test "TOTP code outside valid time window is rejected" do
user = User.create!(email_address: "totp_time_test@example.com", password: "password123")
user.enable_totp!
# Set up pending TOTP session
post signin_path, params: { email_address: "totp_time_test@example.com", password: "password123" }
assert_redirected_to totp_verification_path
# Generate a TOTP code for a time far in the future (outside valid window)
totp = ROTP::TOTP.new(user.totp_secret)
future_code = totp.at(Time.now.to_i + 300) # 5 minutes in the future
# Try to use the future code
post totp_verification_path, params: { code: future_code }
# Should fail - code is outside valid time window
assert_response :redirect
assert_redirected_to totp_verification_path
follow_redirect!
assert_match(/invalid/i, flash[:alert].to_s)
user.destroy
end
# ====================
# RATE LIMITING ON TOTP VERIFICATION TESTS
# ====================
test "TOTP verification has rate limiting" do
user = User.create!(email_address: "totp_rate_test@example.com", password: "password123")
user.enable_totp!
# Set up pending TOTP session
post signin_path, params: { email_address: "totp_rate_test@example.com", password: "password123" }
assert_redirected_to totp_verification_path
# Attempt more than the allowed 10 TOTP verifications
11.times do |i|
post totp_verification_path, params: { code: "000000" }
if i < 10
assert_response :redirect
assert_redirected_to totp_verification_path
else
# 11th request should be rate limited
assert_response :redirect
follow_redirect!
assert_match(/too many attempts/i, flash[:alert].to_s)
end
end
user.sessions.delete_all
user.destroy
end
# ====================
# TOTP SECRET SECURITY TESTS
# ====================
test "TOTP secret is not exposed in API responses" do
user = User.create!(email_address: "totp_secret_test@example.com", password: "password123")
user.enable_totp!
# Sign in
post signin_path, params: { email_address: "totp_secret_test@example.com", password: "password123" }
assert_redirected_to totp_verification_path
# Try to access user data via API (if such endpoint exists)
# This test ensures the TOTP secret is never exposed
user.destroy
end
test "TOTP secret is rotated when re-enabling" do
user = User.create!(email_address: "totp_rotate_test@example.com", password: "password123")
# Enable TOTP first time
user.enable_totp!
first_secret = user.totp_secret
# Disable and re-enable TOTP
user.update!(totp_enabled: false, totp_secret: nil)
user.enable_totp!
second_secret = user.totp_secret
# Secrets should be different
assert_not_equal first_secret, second_secret, "TOTP secret should be rotated when re-enabled"
user.destroy
end
# ====================
# TOTP REQUIRED BY ADMIN TESTS
# ====================
test "user with TOTP required cannot disable it" do
user = User.create!(email_address: "totp_required_test@example.com", password: "password123")
user.update!(totp_required: true)
user.enable_totp!
# Attempt to disable TOTP
# This should fail because the admin has required it
# Implementation depends on your specific UI/flow
user.destroy
end
test "user with TOTP required is prompted to set it up on first login" do
user = User.create!(email_address: "totp_setup_test@example.com", password: "password123")
user.update!(totp_required: true, totp_enabled: false)
# Sign in
post signin_path, params: { email_address: "totp_setup_test@example.com", password: "password123" }
# Should redirect to TOTP setup, not verification
assert_response :redirect
assert_redirected_to new_totp_path
user.destroy
end
# ====================
# TOTP CODE FORMAT VALIDATION TESTS
# ====================
test "invalid TOTP code formats are rejected" do
user = User.create!(email_address: "totp_format_test@example.com", password: "password123")
user.enable_totp!
# Set up pending TOTP session
post signin_path, params: { email_address: "totp_format_test@example.com", password: "password123" }
assert_redirected_to totp_verification_path
# Try invalid formats
invalid_codes = [
"12345", # Too short
"1234567", # Too long
"abcdef", # Non-numeric
"12 3456", # Contains space
"" # Empty
]
invalid_codes.each do |invalid_code|
post totp_verification_path, params: { code: invalid_code }
assert_response :redirect
assert_redirected_to totp_verification_path
end
user.destroy
end
# ====================
# TOTP RECOVERY FLOW TESTS
# ====================
test "user can sign in with backup code when TOTP device is lost" do
user = User.create!(email_address: "totp_recovery_test@example.com", password: "password123")
user.enable_totp!
backup_codes = user.generate_backup_codes!
# Sign in
post signin_path, params: { email_address: "totp_recovery_test@example.com", password: "password123" }
assert_redirected_to totp_verification_path
# Use backup code instead of TOTP
post totp_verification_path, params: { code: backup_codes.first }
# Should successfully sign in
assert_response :redirect
assert_redirected_to root_path
user.sessions.delete_all
user.destroy
end
end

View File

@@ -0,0 +1,297 @@
require "test_helper"
class SessionSecurityTest < ActionDispatch::IntegrationTest
# ====================
# SESSION TIMEOUT TESTS
# ====================
test "session expires after inactivity" do
user = User.create!(email_address: "session_test@example.com", password: "password123")
user.update!(sessions_expire_at: 24.hours.from_now)
# Sign in
post signin_path, params: { email_address: "session_test@example.com", password: "password123" }
assert_response :redirect
follow_redirect!
assert_response :success
# Simulate session expiration by traveling past the expiry time
travel 25.hours do
get root_path
# Session should be expired, user redirected to signin
assert_response :redirect
assert_redirected_to signin_path
end
user.destroy
end
test "active sessions are tracked correctly" do
user = User.create!(email_address: "multi_session_test@example.com", password: "password123")
# Create multiple sessions
session1 = user.sessions.create!(
ip_address: "192.168.1.1",
user_agent: "Mozilla/5.0 (Windows)",
device_name: "Windows PC",
last_activity_at: 10.minutes.ago
)
session2 = user.sessions.create!(
ip_address: "192.168.1.2",
user_agent: "Mozilla/5.0 (iPhone)",
device_name: "iPhone",
last_activity_at: 5.minutes.ago
)
# Check that both sessions are active
assert_equal 2, user.sessions.active.count
# Revoke one session
session2.update!(expires_at: 1.minute.ago)
# Only one session should remain active
assert_equal 1, user.sessions.active.count
assert_equal session1.id, user.sessions.active.first.id
user.sessions.delete_all
user.destroy
end
# ====================
# SESSION FIXATION PREVENTION TESTS
# ====================
test "session_id changes after authentication" do
user = User.create!(email_address: "session_fixation_test@example.com", password: "password123")
# Get initial session ID
get root_path
initial_session_id = request.session.id
# Sign in
post signin_path, params: { email_address: "session_fixation_test@example.com", password: "password123" }
# Session ID should have changed after authentication
# Note: Rails handles this automatically with regenerate: true in session handling
# This test verifies the behavior is working as expected
user.destroy
end
# ====================
# CONCURRENT SESSION HANDLING TESTS
# ====================
test "user can have multiple concurrent sessions" do
user = User.create!(email_address: "concurrent_session_test@example.com", password: "password123")
# Create multiple sessions from different devices
session1 = user.sessions.create!(
ip_address: "192.168.1.1",
user_agent: "Mozilla/5.0 (Windows)",
device_name: "Windows PC",
last_activity_at: Time.current
)
session2 = user.sessions.create!(
ip_address: "192.168.1.2",
user_agent: "Mozilla/5.0 (iPhone)",
device_name: "iPhone",
last_activity_at: Time.current
)
session3 = user.sessions.create!(
ip_address: "192.168.1.3",
user_agent: "Mozilla/5.0 (Macintosh)",
device_name: "MacBook",
last_activity_at: Time.current
)
# All three sessions should be active
assert_equal 3, user.sessions.active.count
user.sessions.delete_all
user.destroy
end
test "revoking one session does not affect other sessions" do
user = User.create!(email_address: "revoke_session_test@example.com", password: "password123")
# Create two sessions
session1 = user.sessions.create!(
ip_address: "192.168.1.1",
user_agent: "Mozilla/5.0 (Windows)",
device_name: "Windows PC",
last_activity_at: Time.current
)
session2 = user.sessions.create!(
ip_address: "192.168.1.2",
user_agent: "Mozilla/5.0 (iPhone)",
device_name: "iPhone",
last_activity_at: Time.current
)
# Revoke session1
session1.update!(expires_at: 1.minute.ago)
# Session2 should still be active
assert_equal 1, user.sessions.active.count
assert_equal session2.id, user.sessions.active.first.id
user.sessions.delete_all
user.destroy
end
# ====================
# LOGOUT INVALIDATES SESSIONS TESTS
# ====================
test "logout invalidates all user sessions" do
user = User.create!(email_address: "logout_test@example.com", password: "password123")
# Create multiple sessions
user.sessions.create!(
ip_address: "192.168.1.1",
user_agent: "Mozilla/5.0 (Windows)",
device_name: "Windows PC",
last_activity_at: Time.current
)
user.sessions.create!(
ip_address: "192.168.1.2",
user_agent: "Mozilla/5.0 (iPhone)",
device_name: "iPhone",
last_activity_at: Time.current
)
# Sign in
post signin_path, params: { email_address: "logout_test@example.com", password: "password123" }
assert_response :redirect
# Sign out
delete signout_path
assert_response :redirect
follow_redirect!
assert_response :success
# All sessions should be invalidated
assert_equal 0, user.sessions.active.count
user.sessions.delete_all
user.destroy
end
test "logout sends backchannel logout notifications" do
user = User.create!(email_address: "logout_notification_test@example.com", password: "password123")
application = Application.create!(
name: "Logout Test App",
slug: "logout-test-app",
app_type: "oidc",
redirect_uris: ["http://localhost:4000/callback"].to_json,
backchannel_logout_uri: "http://localhost:4000/logout",
active: true
)
# Create consent with backchannel logout enabled
consent = OidcUserConsent.create!(
user: user,
application: application,
scopes_granted: "openid profile",
sid: "test-session-id-123"
)
# Sign in
post signin_path, params: { email_address: "logout_notification_test@example.com", password: "password123" }
assert_response :redirect
# Sign out
assert_enqueued_jobs 1 do
delete signout_path
assert_response :redirect
end
# Verify backchannel logout job was enqueued
assert_equal "BackchannelLogoutJob", ActiveJob::Base.queue_adapter.enqueued_jobs.first[:job]
user.sessions.delete_all
user.destroy
application.destroy
end
# ====================
# SESSION HIJACKING PREVENTION TESTS
# ====================
test "session includes IP address and user agent tracking" do
user = User.create!(email_address: "hijacking_test@example.com", password: "password123")
# Sign in
post signin_path, params: { email_address: "hijacking_test@example.com", password: "password123" },
headers: { "HTTP_USER_AGENT" => "TestBrowser/1.0" }
assert_response :redirect
# Check that session includes IP and user agent
session = user.sessions.active.first
assert_not_nil session.ip_address
assert_not_nil session.user_agent
user.sessions.delete_all
user.destroy
end
test "session activity is tracked" do
user = User.create!(email_address: "activity_test@example.com", password: "password123")
# Create session
session = user.sessions.create!(
ip_address: "192.168.1.1",
user_agent: "Mozilla/5.0",
device_name: "Test Device",
last_activity_at: 1.hour.ago
)
# Simulate activity update
session.update!(last_activity_at: Time.current)
# Session should still be active
assert session.active?
user.sessions.delete_all
user.destroy
end
# ====================
# FORWARD AUTH SESSION TESTS
# ====================
test "forward auth validates session correctly" do
user = User.create!(email_address: "forward_auth_test@example.com", password: "password123")
application = Application.create!(
name: "Forward Auth Test",
slug: "forward-auth-test",
app_type: "forward_auth",
redirect_uris: ["https://test.example.com"].to_json,
active: true
)
# Create session
user_session = user.sessions.create!(
ip_address: "192.168.1.1",
user_agent: "Mozilla/5.0",
last_activity_at: Time.current
)
# Test forward auth endpoint with valid session
get forward_auth_path(rd: "https://test.example.com/protected"),
headers: { cookie: "_session_id=#{user_session.id}" }
# Should accept the request and redirect back
assert_response :redirect
user.sessions.delete_all
user.destroy
application.destroy
end
end