StandardRB fixes
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
2026-01-01 13:29:44 +11:00
parent 7d3af2bcec
commit 93a0edb0a2
79 changed files with 779 additions and 786 deletions

View File

@@ -32,13 +32,11 @@ module Admin
client_secret = @application.generate_new_client_secret!
end
if @application.oidc?
flash[:notice] = "Application created successfully."
if @application.oidc?
flash[:client_id] = @application.client_id
flash[:client_secret] = client_secret if client_secret
flash[:public_client] = true if @application.public_client?
else
flash[:notice] = "Application created successfully."
end
redirect_to admin_application_path(@application)

View File

@@ -8,7 +8,7 @@ module Api
def violation_report
# Parse CSP violation report
report_data = JSON.parse(request.body.read)
csp_report = report_data['csp-report']
csp_report = report_data["csp-report"]
# Validate that we have a proper CSP report
unless csp_report.is_a?(Hash) && csp_report.present?
@@ -19,28 +19,28 @@ module Api
# Log the violation for security monitoring
Rails.logger.warn "CSP Violation Report:"
Rails.logger.warn " Blocked URI: #{csp_report['blocked-uri']}"
Rails.logger.warn " Document URI: #{csp_report['document-uri']}"
Rails.logger.warn " Referrer: #{csp_report['referrer']}"
Rails.logger.warn " Violated Directive: #{csp_report['violated-directive']}"
Rails.logger.warn " Original Policy: #{csp_report['original-policy']}"
Rails.logger.warn " Blocked URI: #{csp_report["blocked-uri"]}"
Rails.logger.warn " Document URI: #{csp_report["document-uri"]}"
Rails.logger.warn " Referrer: #{csp_report["referrer"]}"
Rails.logger.warn " Violated Directive: #{csp_report["violated-directive"]}"
Rails.logger.warn " Original Policy: #{csp_report["original-policy"]}"
Rails.logger.warn " User Agent: #{request.user_agent}"
Rails.logger.warn " IP Address: #{request.remote_ip}"
# Emit structured event for CSP violation
# This allows multiple subscribers to process the event (Sentry, local logging, etc.)
Rails.event.notify("csp.violation", {
blocked_uri: csp_report['blocked-uri'],
document_uri: csp_report['document-uri'],
referrer: csp_report['referrer'],
violated_directive: csp_report['violated-directive'],
original_policy: csp_report['original-policy'],
disposition: csp_report['disposition'],
effective_directive: csp_report['effective-directive'],
source_file: csp_report['source-file'],
line_number: csp_report['line-number'],
column_number: csp_report['column-number'],
status_code: csp_report['status-code'],
blocked_uri: csp_report["blocked-uri"],
document_uri: csp_report["document-uri"],
referrer: csp_report["referrer"],
violated_directive: csp_report["violated-directive"],
original_policy: csp_report["original-policy"],
disposition: csp_report["disposition"],
effective_directive: csp_report["effective-directive"],
source_file: csp_report["source-file"],
line_number: csp_report["line-number"],
column_number: csp_report["column-number"],
status_code: csp_report["status-code"],
user_agent: request.user_agent,
ip_address: request.remote_ip,
current_user_id: Current.user&.id,

View File

@@ -81,7 +81,10 @@ module Api
# User is authenticated and authorized
# Return 200 with user information headers using app-specific configuration
headers = app ? app.headers_for_user(user) : Application::DEFAULT_HEADERS.map { |key, header_name|
headers = if app
app.headers_for_user(user)
else
Application::DEFAULT_HEADERS.map { |key, header_name|
case key
when :user, :email, :name
[header_name, user.email_address]
@@ -91,12 +94,13 @@ module Api
[header_name, user.admin? ? "true" : "false"]
end
}.compact.to_h
end
headers.each { |key, value| response.headers[key] = value }
# Log what headers we're sending (helpful for debugging)
if headers.any?
Rails.logger.debug "ForwardAuth: Headers sent: #{headers.keys.join(', ')}"
Rails.logger.debug "ForwardAuth: Headers sent: #{headers.keys.join(", ")}"
else
Rails.logger.debug "ForwardAuth: No headers sent (access only)"
end
@@ -129,8 +133,7 @@ module Api
def extract_session_id
# Extract session ID from cookie
# Rails uses signed cookies by default
session_id = cookies.signed[:session_id]
session_id
cookies.signed[:session_id]
end
def extract_app_from_headers
@@ -155,7 +158,7 @@ module Api
original_uri = request.headers["X-Forwarded-Uri"] || request.headers["X-Forwarded-Path"] || "/"
# Debug logging to see what headers we're getting
Rails.logger.info "ForwardAuth Headers: Host=#{request.headers['Host']}, X-Forwarded-Host=#{original_host}, X-Forwarded-Uri=#{request.headers['X-Forwarded-Uri']}, X-Forwarded-Path=#{request.headers['X-Forwarded-Path']}"
Rails.logger.info "ForwardAuth Headers: Host=#{request.headers["Host"]}, X-Forwarded-Host=#{original_host}, X-Forwarded-Uri=#{request.headers["X-Forwarded-Uri"]}, X-Forwarded-Path=#{request.headers["X-Forwarded-Path"]}"
original_url = if original_host
# Use the forwarded host and URI (original behavior)
@@ -203,7 +206,7 @@ module Api
return nil unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
# Only allow HTTPS in production
return nil unless Rails.env.development? || uri.scheme == 'https'
return nil unless Rails.env.development? || uri.scheme == "https"
redirect_domain = uri.host.downcase
return nil unless redirect_domain.present?
@@ -214,7 +217,6 @@ module Api
end
matching_app ? url : nil
rescue URI::InvalidURIError
nil
end
@@ -233,13 +235,13 @@ module Api
return redirect_url if redirect_url.present?
# Try CLINCH_HOST environment variable first
if ENV['CLINCH_HOST'].present?
host = ENV['CLINCH_HOST']
if ENV["CLINCH_HOST"].present?
host = ENV["CLINCH_HOST"]
# Ensure URL has https:// protocol
host.match?(/^https?:\/\//) ? host : "https://#{host}"
else
# Fallback to the request host
request_host = request.host || request.headers['X-Forwarded-Host']
request_host = request.host || request.headers["X-Forwarded-Host"]
if request_host.present?
Rails.logger.warn "ForwardAuth: CLINCH_HOST not set, using request host: #{request_host}"
"https://#{request_host}"

View File

@@ -1,5 +1,6 @@
class ApplicationController < ActionController::Base
include Authentication
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
allow_browser versions: :modern

View File

@@ -1,6 +1,6 @@
require 'uri'
require 'public_suffix'
require 'ipaddr'
require "uri"
require "public_suffix"
require "ipaddr"
module Authentication
extend ActiveSupport::Concern
@@ -17,6 +17,7 @@ module Authentication
end
private
def authenticated?
resume_session
end
@@ -39,9 +40,8 @@ module Authentication
end
def after_authentication_url
return_url = session[:return_to_after_authenticating]
final_url = session.delete(:return_to_after_authenticating) || root_url
final_url
session[:return_to_after_authenticating]
session.delete(:return_to_after_authenticating) || root_url
end
def start_new_session_for(user, acr: "1")
@@ -101,10 +101,14 @@ module Authentication
return nil if host.blank? || host.match?(/^(localhost|127\.0\.0\.1|::1)$/)
# Strip port number for domain parsing
host_without_port = host.split(':').first
host_without_port = host.split(":").first
# Check if it's an IP address (IPv4 or IPv6) - if so, don't set domain cookie
return nil if IPAddr.new(host_without_port) rescue false
begin
return nil if IPAddr.new(host_without_port)
rescue
false
end
# Use Public Suffix List for accurate domain parsing
domain = PublicSuffix.parse(host_without_port)
@@ -138,7 +142,7 @@ module Authentication
unless uri.path&.start_with?("/oauth/")
# Add token as query parameter
query_params = URI.decode_www_form(uri.query || "").to_h
query_params['fa_token'] = token
query_params["fa_token"] = token
uri.query = URI.encode_www_form(query_params)
# Update the session with the tokenized URL

View File

@@ -1,5 +1,6 @@
class InvitationsController < ApplicationController
include Authentication
allow_unauthenticated_access
before_action :set_user_by_invitation_token, only: %i[show update]
@@ -35,16 +36,16 @@ class InvitationsController < ApplicationController
# Check if user is still pending invitation
if @user.nil?
redirect_to signin_path, alert: "Invitation link is invalid or has expired."
return false
false
elsif @user.pending_invitation?
# User is valid and pending - proceed
return true
true
else
redirect_to signin_path, alert: "This invitation has already been used or is no longer valid."
return false
false
end
rescue ActiveSupport::MessageVerifier::InvalidSignature
redirect_to signin_path, alert: "Invitation link is invalid or has expired."
return false
false
end
end

View File

@@ -63,7 +63,7 @@ class OidcController < ApplicationController
error_details << "redirect_uri is required" unless redirect_uri.present?
error_details << "response_type must be 'code'" unless response_type == "code"
render plain: "Invalid request: #{error_details.join(', ')}", status: :bad_request
render plain: "Invalid request: #{error_details.join(", ")}", status: :bad_request
return
end
@@ -90,7 +90,7 @@ class OidcController < ApplicationController
Rails.logger.error "OAuth: Available OIDC applications: #{all_oidc_apps.pluck(:id, :client_id, :name)}"
error_msg = if Rails.env.development?
"Invalid request: Application not found for client_id '#{client_id}'. Available OIDC applications: #{all_oidc_apps.pluck(:name, :client_id).map { |name, id| "#{name} (#{id})" }.join(', ')}"
"Invalid request: Application not found for client_id '#{client_id}'. Available OIDC applications: #{all_oidc_apps.pluck(:name, :client_id).map { |name, id| "#{name} (#{id})" }.join(", ")}"
else
"Invalid request: Application not found"
end
@@ -105,7 +105,7 @@ class OidcController < ApplicationController
# For development, show detailed error
error_msg = if Rails.env.development?
"Invalid request: Redirect URI mismatch. Application is configured for: #{@application.parsed_redirect_uris.join(', ')}, but received: #{redirect_uri}"
"Invalid request: Redirect URI mismatch. Application is configured for: #{@application.parsed_redirect_uris.join(", ")}, but received: #{redirect_uri}"
else
"Invalid request: Redirect URI not registered for this application"
end
@@ -223,22 +223,22 @@ class OidcController < ApplicationController
# User denied consent
if params[:deny].present?
session.delete(:oauth_params)
error_uri = "#{oauth_params['redirect_uri']}?error=access_denied"
error_uri += "&state=#{CGI.escape(oauth_params['state'])}" if oauth_params['state']
error_uri = "#{oauth_params["redirect_uri"]}?error=access_denied"
error_uri += "&state=#{CGI.escape(oauth_params["state"])}" if oauth_params["state"]
redirect_to error_uri, allow_other_host: true
return
end
# Find the application
client_id = oauth_params['client_id']
client_id = oauth_params["client_id"]
application = Application.find_by(client_id: client_id, app_type: "oidc")
# Check if application is active (redirect with OAuth error)
unless application&.active?
Rails.logger.error "OAuth: Application is not active: #{application&.name || client_id}"
session.delete(:oauth_params)
error_uri = "#{oauth_params['redirect_uri']}?error=unauthorized_client&error_description=Application+is+not+active"
error_uri += "&state=#{CGI.escape(oauth_params['state'])}" if oauth_params['state'].present?
error_uri = "#{oauth_params["redirect_uri"]}?error=unauthorized_client&error_description=Application+is+not+active"
error_uri += "&state=#{CGI.escape(oauth_params["state"])}" if oauth_params["state"].present?
redirect_to error_uri, allow_other_host: true
return
end
@@ -246,9 +246,9 @@ class OidcController < ApplicationController
user = Current.session.user
# Record user consent
requested_scopes = oauth_params['scope'].split(' ')
requested_scopes = oauth_params["scope"].split(" ")
consent = OidcUserConsent.find_or_initialize_by(user: user, application: application)
consent.scopes_granted = requested_scopes.join(' ')
consent.scopes_granted = requested_scopes.join(" ")
consent.granted_at = Time.current
consent.save!
@@ -256,11 +256,11 @@ class OidcController < ApplicationController
auth_code = OidcAuthorizationCode.create!(
application: application,
user: user,
redirect_uri: oauth_params['redirect_uri'],
scope: oauth_params['scope'],
nonce: oauth_params['nonce'],
code_challenge: oauth_params['code_challenge'],
code_challenge_method: oauth_params['code_challenge_method'],
redirect_uri: oauth_params["redirect_uri"],
scope: oauth_params["scope"],
nonce: oauth_params["nonce"],
code_challenge: oauth_params["code_challenge"],
code_challenge_method: oauth_params["code_challenge_method"],
auth_time: Current.session.created_at.to_i,
acr: Current.session.acr,
expires_at: 10.minutes.from_now
@@ -270,8 +270,8 @@ class OidcController < ApplicationController
session.delete(:oauth_params)
# Redirect back to client with authorization code (plaintext)
redirect_uri = "#{oauth_params['redirect_uri']}?code=#{auth_code.plaintext_code}"
redirect_uri += "&state=#{CGI.escape(oauth_params['state'])}" if oauth_params['state']
redirect_uri = "#{oauth_params["redirect_uri"]}?code=#{auth_code.plaintext_code}"
redirect_uri += "&state=#{CGI.escape(oauth_params["state"])}" if oauth_params["state"]
redirect_to redirect_uri, allow_other_host: true
end
@@ -650,7 +650,7 @@ class OidcController < ApplicationController
# Find and validate the application
application = Application.find_by(client_id: client_id)
unless application && application.authenticate_client_secret(client_secret)
unless application&.authenticate_client_secret(client_secret)
Rails.logger.warn "OAuth: Token revocation attempted for invalid application: #{client_id}"
head :ok
return
@@ -695,7 +695,7 @@ class OidcController < ApplicationController
if access_token_record
access_token_record.revoke!
Rails.logger.info "OAuth: Access token revoked for application #{application.name}"
revoked = true
true
end
end
@@ -709,7 +709,7 @@ class OidcController < ApplicationController
# OpenID Connect RP-Initiated Logout
# Handle id_token_hint and post_logout_redirect_uri parameters
id_token_hint = params[:id_token_hint]
params[:id_token_hint]
post_logout_redirect_uri = params[:post_logout_redirect_uri]
state = params[:state]
@@ -835,7 +835,7 @@ class OidcController < ApplicationController
return nil unless parsed_uri.is_a?(URI::HTTP) || parsed_uri.is_a?(URI::HTTPS)
# Only allow HTTPS in production
return nil if Rails.env.production? && parsed_uri.scheme != 'https'
return nil if Rails.env.production? && parsed_uri.scheme != "https"
# Check if URI matches any registered OIDC application's redirect URIs
# According to OIDC spec, post_logout_redirect_uri should be pre-registered

View File

@@ -127,7 +127,7 @@ class SessionsController < ApplicationController
# Invalid code
redirect_to totp_verification_path, alert: "Invalid verification code. Please try again."
return
nil
end
# Just render the form
@@ -191,7 +191,6 @@ class SessionsController < ApplicationController
session[:webauthn_challenge] = options.challenge
render json: options
rescue => e
Rails.logger.error "WebAuthn challenge generation error: #{e.message}"
render json: {error: "Failed to generate WebAuthn challenge"}, status: :internal_server_error
@@ -276,7 +275,6 @@ class SessionsController < ApplicationController
redirect_to: after_authentication_url,
message: "Signed in successfully with passkey"
}
rescue WebAuthn::Error => e
Rails.logger.error "WebAuthn verification error: #{e.message}"
render json: {error: "Authentication failed: #{e.message}"}, status: :unprocessable_entity
@@ -301,7 +299,7 @@ class SessionsController < ApplicationController
return nil unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
# Only allow HTTPS in production
return nil unless Rails.env.development? || uri.scheme == 'https'
return nil unless Rails.env.development? || uri.scheme == "https"
redirect_domain = uri.host.downcase
return nil unless redirect_domain.present?
@@ -312,7 +310,6 @@ class SessionsController < ApplicationController
end
matching_app ? url : nil
rescue URI::InvalidURIError
nil
end

View File

@@ -96,7 +96,6 @@ class WebauthnController < ApplicationController
message: "Passkey '#{nickname}' registered successfully",
credential_id: @webauthn_credential.id
}
rescue WebAuthn::Error => e
Rails.logger.error "WebAuthn registration error: #{e.message}"
render json: {error: "Failed to register passkey: #{e.message}"}, status: :unprocessable_entity
@@ -158,7 +157,7 @@ class WebauthnController < ApplicationController
def extract_credential_params
# Use require.permit which is working and reliable
# The JavaScript sends params both directly and wrapped in webauthn key
begin
# Try direct parameters first
credential_params = params.require(:credential).permit(:id, :rawId, :type, response: {}, clientExtensionResults: {})
nickname = params.require(:nickname)
@@ -169,7 +168,6 @@ class WebauthnController < ApplicationController
webauthn_params = params.require(:webauthn).permit(:nickname, credential: [:id, :rawId, :type, response: {}, clientExtensionResults: {}])
[webauthn_params[:credential], webauthn_params[:nickname]]
end
end
def set_webauthn_credential
user = Current.session&.user
@@ -184,11 +182,11 @@ class WebauthnController < ApplicationController
# Helper method to convert Base64 to Base64URL if needed
def base64_to_base64url(str)
str.gsub('+', '-').gsub('/', '_').gsub(/=+$/, '')
str.tr("+", "-").tr("/", "_").gsub(/=+$/, "")
end
# Helper method to convert Base64URL to Base64 if needed
def base64url_to_base64(str)
str.gsub('-', '+').gsub('_', '/') + '=' * (4 - str.length % 4) % 4
str.tr("-", "+").tr("_", "/") + "=" * (4 - str.length % 4) % 4
end
end

View File

@@ -22,11 +22,11 @@ module ApplicationHelper
def border_class_for(type)
case type.to_s
when 'notice' then 'border-green-200'
when 'alert', 'error' then 'border-red-200'
when 'warning' then 'border-yellow-200'
when 'info' then 'border-blue-200'
else 'border-gray-200'
when "notice" then "border-green-200"
when "alert", "error" then "border-red-200"
when "warning" then "border-yellow-200"
when "info" then "border-blue-200"
else "border-gray-200"
end
end
end

View File

@@ -25,9 +25,7 @@ module ClaimsHelper
claims = deep_merge_claims(claims, user.parsed_custom_claims)
# Merge app-specific claims (arrays are combined)
claims = deep_merge_claims(claims, application.custom_claims_for_user(user))
claims
deep_merge_claims(claims, application.custom_claims_for_user(user))
end
# Get claim sources breakdown for display

View File

@@ -29,9 +29,9 @@ class BackchannelLogoutJob < ApplicationJob
uri = URI.parse(application.backchannel_logout_uri)
begin
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https', open_timeout: 5, read_timeout: 5) do |http|
request = Net::HTTP::Post.new(uri.path.presence || '/')
request['Content-Type'] = 'application/x-www-form-urlencoded'
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 5, read_timeout: 5) do |http|
request = Net::HTTP::Post.new(uri.path.presence || "/")
request["Content-Type"] = "application/x-www-form-urlencoded"
request.set_form_data({logout_token: logout_token})
http.request(request)
end
@@ -44,7 +44,7 @@ class BackchannelLogoutJob < ApplicationJob
rescue Net::OpenTimeout, Net::ReadTimeout => e
Rails.logger.warn "BackchannelLogout: Timeout sending logout to #{application.name} (#{application.backchannel_logout_uri}): #{e.message}"
raise # Retry on timeout
rescue StandardError => e
rescue => e
Rails.logger.error "BackchannelLogout: Failed to send logout to #{application.name} (#{application.backchannel_logout_uri}): #{e.class} - #{e.message}"
raise # Retry on error
end

View File

@@ -1,4 +1,4 @@
class ApplicationMailer < ActionMailer::Base
default from: ENV.fetch('CLINCH_FROM_EMAIL', 'clinch@example.com')
default from: ENV.fetch("CLINCH_FROM_EMAIL", "clinch@example.com")
layout "mailer"
end

View File

@@ -20,15 +20,15 @@ class Application < ApplicationRecord
validates :name, presence: true
validates :slug, presence: true, uniqueness: {case_sensitive: false},
format: { with: /\A[a-z0-9\-]+\z/, message: "only lowercase letters, numbers, and hyphens" }
format: {with: /\A[a-z0-9-]+\z/, message: "only lowercase letters, numbers, and hyphens"}
validates :app_type, presence: true,
inclusion: {in: %w[oidc forward_auth]}
validates :client_id, uniqueness: {allow_nil: true}
validates :client_secret, presence: true, on: :create, if: -> { oidc? && confidential_client? }
validates :domain_pattern, presence: true, uniqueness: {case_sensitive: false}, if: :forward_auth?
validates :landing_url, format: { with: URI::regexp(%w[http https]), allow_nil: true, message: "must be a valid URL" }
validates :landing_url, format: {with: URI::RFC2396_PARSER.make_regexp(%w[http https]), allow_nil: true, message: "must be a valid URL"}
validates :backchannel_logout_uri, format: {
with: URI::regexp(%w[http https]),
with: URI::RFC2396_PARSER.make_regexp(%w[http https]),
allow_nil: true,
message: "must be a valid HTTP or HTTPS URL"
}
@@ -56,11 +56,11 @@ class Application < ApplicationRecord
# Default header configuration for ForwardAuth
DEFAULT_HEADERS = {
user: 'X-Remote-User',
email: 'X-Remote-Email',
name: 'X-Remote-Name',
groups: 'X-Remote-Groups',
admin: 'X-Remote-Admin'
user: "X-Remote-User",
email: "X-Remote-Email",
name: "X-Remote-Name",
groups: "X-Remote-Groups",
admin: "X-Remote-Admin"
}.freeze
# Scopes
@@ -135,8 +135,8 @@ class Application < ApplicationRecord
def matches_domain?(domain)
return false if domain.blank? || !forward_auth?
pattern = domain_pattern.gsub('.', '\.')
pattern = pattern.gsub('*', '[^.]*')
pattern = domain_pattern.gsub(".", '\.')
pattern = pattern.gsub("*", "[^.]*")
regex = Regexp.new("^#{pattern}$", Regexp::IGNORECASE)
regex.match?(domain.downcase)
@@ -144,18 +144,18 @@ class Application < ApplicationRecord
# Policy determination based on user status (for ForwardAuth)
def policy_for_user(user)
return 'deny' unless active?
return 'deny' unless user.active?
return "deny" unless active?
return "deny" unless user.active?
# If no groups specified, bypass authentication
return 'bypass' if allowed_groups.empty?
return "bypass" if allowed_groups.empty?
# If user is in allowed groups, determine auth level
if user_allowed?(user)
# Require 2FA if user has TOTP configured, otherwise one factor
user.totp_enabled? ? 'two_factor' : 'one_factor'
user.totp_enabled? ? "two_factor" : "one_factor"
else
'deny'
"deny"
end
end
@@ -197,7 +197,7 @@ class Application < ApplicationRecord
def generate_new_client_secret!
secret = SecureRandom.urlsafe_base64(48)
self.client_secret = secret
self.save!
save!
secret
end
@@ -260,14 +260,14 @@ class Application < ApplicationRecord
return unless icon.attached?
# Check content type
allowed_types = ['image/png', 'image/jpg', 'image/jpeg', 'image/gif', 'image/svg+xml']
allowed_types = ["image/png", "image/jpg", "image/jpeg", "image/gif", "image/svg+xml"]
unless allowed_types.include?(icon.content_type)
errors.add(:icon, 'must be a PNG, JPG, GIF, or SVG image')
errors.add(:icon, "must be a PNG, JPG, GIF, or SVG image")
end
# Check file size (2MB limit)
if icon.blob.byte_size > 2.megabytes
errors.add(:icon, 'must be less than 2MB')
errors.add(:icon, "must be less than 2MB")
end
end
@@ -302,8 +302,8 @@ class Application < ApplicationRecord
begin
uri = URI.parse(backchannel_logout_uri)
unless uri.scheme == 'https'
errors.add(:backchannel_logout_uri, 'must use HTTPS in production')
unless uri.scheme == "https"
errors.add(:backchannel_logout_uri, "must use HTTPS in production")
end
rescue URI::InvalidURIError
# Let the format validator handle invalid URIs

View File

@@ -25,7 +25,7 @@ class ApplicationUserClaim < ApplicationRecord
reserved_used = parsed_custom_claims.keys.map(&:to_s) & RESERVED_CLAIMS
if reserved_used.any?
errors.add(:custom_claims, "cannot override reserved OIDC claims: #{reserved_used.join(', ')}")
errors.add(:custom_claims, "cannot override reserved OIDC claims: #{reserved_used.join(", ")}")
end
end
end

View File

@@ -28,7 +28,7 @@ class Group < ApplicationRecord
reserved_used = parsed_custom_claims.keys.map(&:to_s) & RESERVED_CLAIMS
if reserved_used.any?
errors.add(:custom_claims, "cannot override reserved OIDC claims: #{reserved_used.join(', ')}")
errors.add(:custom_claims, "cannot override reserved OIDC claims: #{reserved_used.join(", ")}")
end
end
end

View File

@@ -25,7 +25,7 @@ class OidcAccessToken < ApplicationRecord
# Compute HMAC for token lookup
def self.compute_token_hmac(plaintext_token)
OpenSSL::HMAC.hexdigest('SHA256', TokenHmac::KEY, plaintext_token)
OpenSSL::HMAC.hexdigest("SHA256", TokenHmac::KEY, plaintext_token)
end
def expired?

View File

@@ -25,7 +25,7 @@ class OidcAuthorizationCode < ApplicationRecord
# Compute HMAC for code lookup
def self.compute_code_hmac(plaintext_code)
OpenSSL::HMAC.hexdigest('SHA256', TokenHmac::KEY, plaintext_code)
OpenSSL::HMAC.hexdigest("SHA256", TokenHmac::KEY, plaintext_code)
end
def expired?

View File

@@ -29,7 +29,7 @@ class OidcRefreshToken < ApplicationRecord
# Compute HMAC for token lookup
def self.compute_token_hmac(plaintext_token)
OpenSSL::HMAC.hexdigest('SHA256', TokenHmac::KEY, plaintext_token)
OpenSSL::HMAC.hexdigest("SHA256", TokenHmac::KEY, plaintext_token)
end
def expired?

View File

@@ -10,12 +10,12 @@ class OidcUserConsent < ApplicationRecord
# Parse scopes_granted into an array
def scopes
scopes_granted.split(' ')
scopes_granted.split(" ")
end
# Set scopes from an array
def scopes=(scope_array)
self.scopes_granted = Array(scope_array).uniq.join(' ')
self.scopes_granted = Array(scope_array).uniq.join(" ")
end
# Check if this consent covers the requested scopes
@@ -31,18 +31,18 @@ class OidcUserConsent < ApplicationRecord
def formatted_scopes
scopes.map do |scope|
case scope
when 'openid'
'Basic authentication'
when 'profile'
'Profile information'
when 'email'
'Email address'
when 'groups'
'Group membership'
when "openid"
"Basic authentication"
when "profile"
"Profile information"
when "email"
"Email address"
when "groups"
"Group membership"
else
scope.humanize
end
end.join(', ')
end.join(", ")
end
# Find consent by SID

View File

@@ -122,12 +122,7 @@ class User < ApplicationRecord
cache_key = "backup_code_failed_attempts_#{id}"
attempts = Rails.cache.read(cache_key) || 0
if attempts >= 5 # Allow max 5 failed attempts per hour
true
else
# Don't increment here - increment only on failed attempts
false
end
attempts >= 5
end
# Increment failed attempt counter
@@ -231,7 +226,7 @@ class User < ApplicationRecord
reserved_used = parsed_custom_claims.keys.map(&:to_s) & RESERVED_CLAIMS
if reserved_used.any?
errors.add(:custom_claims, "cannot override reserved OIDC claims: #{reserved_used.join(', ')}")
errors.add(:custom_claims, "cannot override reserved OIDC claims: #{reserved_used.join(", ")}")
end
end

View File

@@ -84,11 +84,11 @@ class WebauthnCredential < ApplicationRecord
days = hours / 24
if days > 0
"#{days.floor} day#{'s' if days > 1} ago"
"#{days.floor} day#{"s" if days > 1} ago"
elsif hours > 0
"#{hours.floor} hour#{'s' if hours > 1} ago"
"#{hours.floor} hour#{"s" if hours > 1} ago"
elsif minutes > 0
"#{minutes.floor} minute#{'s' if minutes > 1} ago"
"#{minutes.floor} minute#{"s" if minutes > 1} ago"
else
"Just now"
end

View File

@@ -13,20 +13,20 @@ module ClaimsMerger
result = base.dup
incoming.each do |key, value|
if result.key?(key)
result[key] = if result.key?(key)
# If both values are arrays, combine them (union to avoid duplicates)
if result[key].is_a?(Array) && value.is_a?(Array)
result[key] = (result[key] + value).uniq
(result[key] + value).uniq
# If both values are hashes, recursively merge them
elsif result[key].is_a?(Hash) && value.is_a?(Hash)
result[key] = deep_merge_claims(result[key], value)
deep_merge_claims(result[key], value)
else
# Otherwise, incoming value wins (override)
result[key] = value
value
end
else
# New key, just add it
result[key] = value
value
end
end

View File

@@ -27,13 +27,13 @@ module Clinch
# Configure SMTP settings using environment variables
config.action_mailer.delivery_method = :smtp
config.action_mailer.smtp_settings = {
address: ENV.fetch('SMTP_ADDRESS', 'localhost'),
port: ENV.fetch('SMTP_PORT', 587),
domain: ENV.fetch('SMTP_DOMAIN', 'localhost'),
user_name: ENV.fetch('SMTP_USERNAME', nil),
password: ENV.fetch('SMTP_PASSWORD', nil),
authentication: ENV.fetch('SMTP_AUTHENTICATION', 'plain').to_sym,
enable_starttls_auto: ENV.fetch('SMTP_STARTTLS_AUTO', 'true') == 'true',
address: ENV.fetch("SMTP_ADDRESS", "localhost"),
port: ENV.fetch("SMTP_PORT", 587),
domain: ENV.fetch("SMTP_DOMAIN", "localhost"),
user_name: ENV.fetch("SMTP_USERNAME", nil),
password: ENV.fetch("SMTP_PASSWORD", nil),
authentication: ENV.fetch("SMTP_AUTHENTICATION", "plain").to_sym,
enable_starttls_auto: ENV.fetch("SMTP_STARTTLS_AUTO", "true") == "true",
openssl_verify_mode: OpenSSL::SSL::VERIFY_PEER
}
end

View File

@@ -62,7 +62,6 @@ Rails.application.configure do
# Use async processor for background jobs in development
config.active_job.queue_adapter = :async
# Highlight code that triggered redirect in logs.
config.action_dispatch.verbose_redirect_logs = true

View File

@@ -34,8 +34,8 @@ Rails.application.configure do
# 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
"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.
@@ -43,7 +43,7 @@ Rails.application.configure do
# Log to STDOUT with the current request id as a default log tag.
config.log_tags = [:request_id]
config.logger = ActiveSupport::TaggedLogging.logger(STDOUT)
config.logger = ActiveSupport::TaggedLogging.logger($stdout)
# Change to "debug" to log everything (including potentially personally-identifiable information!).
config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info")
@@ -66,7 +66,7 @@ Rails.application.configure do
# Set host to be used by links generated in mailer templates.
config.action_mailer.default_url_options = {
host: ENV.fetch('CLINCH_HOST', 'example.com')
host: ENV.fetch("CLINCH_HOST", "example.com")
}
# Specify outgoing SMTP server. Remember to add smtp/* credentials via bin/rails credentials:edit.
@@ -92,7 +92,7 @@ Rails.application.configure do
def self.extract_domain(host)
return host if host.blank?
# Remove protocol (http:// or https://) if present
host.gsub(/^https?:\/\//, '')
host.gsub(/^https?:\/\//, "")
end
# Helper method to ensure URL has https:// protocol
@@ -105,11 +105,11 @@ Rails.application.configure do
# Enable DNS rebinding protection and other `Host` header attacks.
# Configure allowed hosts based on deployment scenario
allowed_hosts = [
extract_domain(ENV.fetch('CLINCH_HOST', 'auth.example.com')), # External domain (auth service itself)
extract_domain(ENV.fetch("CLINCH_HOST", "auth.example.com")) # External domain (auth service itself)
]
# Use PublicSuffix to extract registrable domain and allow all subdomains
host_domain = extract_domain(ENV.fetch('CLINCH_HOST', 'auth.example.com'))
host_domain = extract_domain(ENV.fetch("CLINCH_HOST", "auth.example.com"))
if host_domain.present?
begin
# Use PublicSuffix to properly extract the domain
@@ -123,20 +123,20 @@ Rails.application.configure do
rescue PublicSuffix::DomainInvalid
# Fallback to simple domain extraction if PublicSuffix fails
Rails.logger.warn "Could not parse domain '#{host_domain}' with PublicSuffix, using fallback"
base_domain = host_domain.split('.').last(2).join('.')
base_domain = host_domain.split(".").last(2).join(".")
allowed_hosts << /.*#{Regexp.escape(base_domain)}/
end
end
# Allow Docker service names if running in same compose
if ENV['CLINCH_DOCKER_SERVICE_NAME']
allowed_hosts << ENV['CLINCH_DOCKER_SERVICE_NAME']
if ENV["CLINCH_DOCKER_SERVICE_NAME"]
allowed_hosts << ENV["CLINCH_DOCKER_SERVICE_NAME"]
end
# Allow internal IP access for cross-compose or host networking
if ENV['CLINCH_ALLOW_INTERNAL_IPS'] == 'true'
if ENV["CLINCH_ALLOW_INTERNAL_IPS"] == "true"
# Specific host IP
allowed_hosts << '192.168.2.246'
allowed_hosts << "192.168.2.246"
# Private IP ranges for internal network access
allowed_hosts += [
@@ -147,8 +147,8 @@ Rails.application.configure do
end
# Local development fallbacks
if ENV['CLINCH_ALLOW_LOCALHOST'] == 'true'
allowed_hosts += ['localhost', '127.0.0.1', '0.0.0.0']
if ENV["CLINCH_ALLOW_LOCALHOST"] == "true"
allowed_hosts += ["localhost", "127.0.0.1", "0.0.0.0"]
end
config.hosts = allowed_hosts

View File

@@ -8,14 +8,14 @@
# - ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT
# Use env vars if set, otherwise derive from SECRET_KEY_BASE (deterministic)
primary_key = ENV.fetch('ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY') do
Rails.application.key_generator.generate_key('active_record_encryption_primary', 32)
primary_key = ENV.fetch("ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY") do
Rails.application.key_generator.generate_key("active_record_encryption_primary", 32)
end
deterministic_key = ENV.fetch('ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY') do
Rails.application.key_generator.generate_key('active_record_encryption_deterministic', 32)
deterministic_key = ENV.fetch("ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY") do
Rails.application.key_generator.generate_key("active_record_encryption_deterministic", 32)
end
key_derivation_salt = ENV.fetch('ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT') do
Rails.application.key_generator.generate_key('active_record_encryption_salt', 32)
key_derivation_salt = ENV.fetch("ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT") do
Rails.application.key_generator.generate_key("active_record_encryption_salt", 32)
end
# Configure Rails 7.1+ ActiveRecord encryption

View File

@@ -59,7 +59,6 @@ Rails.application.configure do
policy.report_uri "/api/csp-violation-report"
end
# Start with CSP in report-only mode for testing
# Set to false after verifying everything works in production
config.content_security_policy_report_only = Rails.env.development?

View File

@@ -8,7 +8,7 @@ Rails.application.config.after_initialize do
# Configure log rotation
csp_logger = Logger.new(
csp_log_path,
'daily', # Rotate daily
"daily", # Rotate daily
30 # Keep 30 old log files
)
@@ -16,7 +16,7 @@ Rails.application.config.after_initialize do
# Format: [TIMESTAMP] LEVEL MESSAGE
csp_logger.formatter = proc do |severity, datetime, progname, msg|
"[#{datetime.strftime('%Y-%m-%d %H:%M:%S')}] #{severity} #{msg}\n"
"[#{datetime.strftime("%Y-%m-%d %H:%M:%S")}] #{severity} #{msg}\n"
end
module CspViolationLocalLogger
@@ -69,7 +69,6 @@ Rails.application.config.after_initialize do
# Also log to main Rails logger for visibility
Rails.logger.info "CSP violation logged to csp_violations.log: #{violated_directive} - #{blocked_uri}"
rescue => e
# Ensure logger errors don't break the CSP reporting flow
Rails.logger.error "Failed to log CSP violation to file: #{e.message}"
@@ -81,12 +80,12 @@ Rails.application.config.after_initialize do
csp_log_path = Rails.root.join("log", "csp_violations.log")
logger = Logger.new(
csp_log_path,
'daily', # Rotate daily
"daily", # Rotate daily
30 # Keep 30 old log files
)
logger.level = Logger::INFO
logger.formatter = proc do |severity, datetime, progname, msg|
"[#{datetime.strftime('%Y-%m-%d %H:%M:%S')}] #{severity} #{msg}\n"
"[#{datetime.strftime("%Y-%m-%d %H:%M:%S")}] #{severity} #{msg}\n"
end
logger
end
@@ -120,7 +119,6 @@ Rails.application.config.after_initialize do
# Test write to ensure permissions are correct
csp_logger.info "CSP Logger initialized at #{Time.current}"
rescue => e
Rails.logger.error "Failed to initialize CSP local logger: #{e.message}"
Rails.logger.error "CSP violations will only be sent to Sentry (if configured)"

View File

@@ -69,10 +69,10 @@ Rails.application.config.after_initialize do
parsed.host
rescue URI::InvalidURIError
# Handle cases where URI might be malformed or just a path
if uri.start_with?('/')
if uri.start_with?("/")
nil # It's a relative path, no domain
else
uri.split('/').first # Best effort extraction
uri.split("/").first # Best effort extraction
end
end
end

View File

@@ -3,5 +3,5 @@
# Derived from SECRET_KEY_BASE - no storage needed, deterministic output
# Optional: Set OIDC_TOKEN_PREFIX_HMAC env var to override with explicit key
module TokenHmac
KEY = ENV['OIDC_TOKEN_PREFIX_HMAC'] || Rails.application.key_generator.generate_key('oidc_token_prefix', 32)
KEY = ENV["OIDC_TOKEN_PREFIX_HMAC"] || Rails.application.key_generator.generate_key("oidc_token_prefix", 32)
end

View File

@@ -31,7 +31,6 @@ threads threads_count, threads_count
# Specifies the `port` that Puma will listen on to receive requests; default is 3000.
port ENV.fetch("PORT", 3000)
# Allow puma to be restarted by `bin/rails restart` command.
plugin :tmp_restart

View File

@@ -8,7 +8,7 @@ Rails.application.routes.draw do
# Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.
# Can be used by load balancers and uptime monitors to verify that the app is live.
get "up" => "rails/health#show", as: :rails_health_check
get "up" => "rails/health#show", :as => :rails_health_check
# Authentication routes
get "/signup", to: "users#new", as: :signup
@@ -61,21 +61,21 @@ Rails.application.routes.draw do
end
# TOTP (2FA) routes
get '/totp/new', to: 'totp#new', as: :new_totp
post '/totp', to: 'totp#create', as: :totp
delete '/totp', to: 'totp#destroy'
get '/totp/backup_codes', to: 'totp#backup_codes', as: :backup_codes_totp
post '/totp/verify_password', to: 'totp#verify_password', as: :verify_password_totp
get '/totp/regenerate_backup_codes', to: 'totp#regenerate_backup_codes', as: :regenerate_backup_codes_totp
post '/totp/regenerate_backup_codes', to: 'totp#create_new_backup_codes', as: :create_new_backup_codes_totp
post '/totp/complete_setup', to: 'totp#complete_setup', as: :complete_totp_setup
get "/totp/new", to: "totp#new", as: :new_totp
post "/totp", to: "totp#create", as: :totp
delete "/totp", to: "totp#destroy"
get "/totp/backup_codes", to: "totp#backup_codes", as: :backup_codes_totp
post "/totp/verify_password", to: "totp#verify_password", as: :verify_password_totp
get "/totp/regenerate_backup_codes", to: "totp#regenerate_backup_codes", as: :regenerate_backup_codes_totp
post "/totp/regenerate_backup_codes", to: "totp#create_new_backup_codes", as: :create_new_backup_codes_totp
post "/totp/complete_setup", to: "totp#complete_setup", as: :complete_totp_setup
# WebAuthn (Passkeys) routes
get '/webauthn/new', to: 'webauthn#new', as: :new_webauthn
post '/webauthn/challenge', to: 'webauthn#challenge'
post '/webauthn/create', to: 'webauthn#create'
delete '/webauthn/:id', to: 'webauthn#destroy', as: :webauthn_credential
get '/webauthn/check', to: 'webauthn#check'
get "/webauthn/new", to: "webauthn#new", as: :new_webauthn
post "/webauthn/challenge", to: "webauthn#challenge"
post "/webauthn/create", to: "webauthn#create"
delete "/webauthn/:id", to: "webauthn#destroy", as: :webauthn_credential
get "/webauthn/check", to: "webauthn#check"
# Admin routes
namespace :admin do

View File

@@ -1,9 +1,9 @@
class AddRoleMappingToApplications < ActiveRecord::Migration[8.1]
def change
add_column :applications, :role_mapping_mode, :string, default: 'disabled', null: false
add_column :applications, :role_mapping_mode, :string, default: "disabled", null: false
add_column :applications, :role_prefix, :string
add_column :applications, :managed_permissions, :json, default: {}
add_column :applications, :role_claim_name, :string, default: 'roles'
add_column :applications, :role_claim_name, :string, default: "roles"
create_table :application_roles do |t|
t.references :application, null: false, foreign_key: true
@@ -21,7 +21,7 @@ class AddRoleMappingToApplications < ActiveRecord::Migration[8.1]
create_table :user_role_assignments do |t|
t.references :user, null: false, foreign_key: true
t.references :application_role, null: false, foreign_key: true
t.string :source, default: 'oidc' # 'oidc', 'manual', 'group_sync'
t.string :source, default: "oidc" # 'oidc', 'manual', 'group_sync'
t.json :metadata, default: {}
t.timestamps

View File

@@ -41,7 +41,7 @@ class MigrateForwardAuthRulesToApplications < ActiveRecord::Migration[8.1]
app = application_class.create!(
name: rule.domain_pattern.titleize,
slug: rule.domain_pattern.parameterize.presence || "forward-auth-#{rule.id}",
app_type: 'forward_auth',
app_type: "forward_auth",
domain_pattern: rule.domain_pattern,
headers_config: rule.headers_config || {},
active: rule.active
@@ -59,7 +59,7 @@ class MigrateForwardAuthRulesToApplications < ActiveRecord::Migration[8.1]
def down
# Remove all forward_auth applications created by this migration
Application.where(app_type: 'forward_auth').destroy_all
Application.where(app_type: "forward_auth").destroy_all
end
private

View File

@@ -8,6 +8,6 @@ class CreateApplicationUserClaims < ActiveRecord::Migration[8.1]
t.timestamps
end
add_index :application_user_claims, [:application_id, :user_id], unique: true, name: 'index_app_user_claims_unique'
add_index :application_user_claims, [:application_id, :user_id], unique: true, name: "index_app_user_claims_unique"
end
end

View File

@@ -47,6 +47,7 @@ class CreateActiveStorageTables < ActiveRecord::Migration[7.0]
end
private
def primary_and_foreign_key_types
config = Rails.configuration.generators
setting = config.options[config.orm][:primary_key_type]

View File

@@ -86,7 +86,7 @@ module Api
# Domain Pattern Tests
test "should match wildcard domains correctly" do
wildcard_rule = Application.create!(name: "Wildcard App", slug: "wildcard-app", app_type: "forward_auth", domain_pattern: "*.example.com", active: true)
Application.create!(name: "Wildcard App", slug: "wildcard-app", app_type: "forward_auth", domain_pattern: "*.example.com", active: true)
sign_in_as(@user)
get "/api/verify", headers: {"X-Forwarded-Host" => "app.example.com"}
@@ -101,7 +101,7 @@ module Api
end
test "should match exact domains correctly" do
exact_rule = Application.create!(name: "Exact App", slug: "exact-app", app_type: "forward_auth", domain_pattern: "api.example.com", active: true)
Application.create!(name: "Exact App", slug: "exact-app", app_type: "forward_auth", domain_pattern: "api.example.com", active: true)
sign_in_as(@user)
get "/api/verify", headers: {"X-Forwarded-Host" => "api.example.com"}
@@ -126,7 +126,7 @@ module Api
end
test "should return custom headers when configured" do
custom_rule = Application.create!(
Application.create!(
name: "Custom App",
slug: "custom-app",
app_type: "forward_auth",
@@ -151,7 +151,7 @@ module Api
end
test "should return no headers when all headers disabled" do
no_headers_rule = Application.create!(
Application.create!(
name: "No Headers App",
slug: "no-headers-app",
app_type: "forward_auth",

View File

@@ -10,10 +10,14 @@ class AuthenticationTest < ActiveSupport::TestCase
return nil if host.blank? || host.match?(/^(localhost|127\.0\.0\.1|::1)$/)
# Strip port number for domain parsing
host_without_port = host.split(':').first
host_without_port = host.split(":").first
# Check if it's an IP address (IPv4 or IPv6) - if so, don't set domain cookie
return nil if IPAddr.new(host_without_port) rescue false
begin
return nil if IPAddr.new(host_without_port)
rescue
false
end
# Use Public Suffix List for accurate domain parsing
domain = PublicSuffix.parse(host_without_port)

View File

@@ -100,7 +100,7 @@ class InvitationsControllerTest < ActionDispatch::IntegrationTest
test "should destroy existing sessions when accepting invitation" do
# Create an existing session for the user
existing_session = @user.sessions.create!
@user.sessions.create!
put invitation_path(@token), params: {
password: "newpassword123",

View File

@@ -35,7 +35,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
test "prevents authorization code reuse - sequential attempts" do
# Create consent
consent = OidcUserConsent.create!(
OidcUserConsent.create!(
user: @user,
application: @application,
scopes_granted: "openid profile",
@@ -81,7 +81,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
test "revokes existing tokens when authorization code is reused" do
# Create consent
consent = OidcUserConsent.create!(
OidcUserConsent.create!(
user: @user,
application: @application,
scopes_granted: "openid profile",
@@ -135,7 +135,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
test "rejects already used authorization code" do
# Create consent
consent = OidcUserConsent.create!(
OidcUserConsent.create!(
user: @user,
application: @application,
scopes_granted: "openid profile",
@@ -171,7 +171,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
test "rejects expired authorization code" do
# Create consent
consent = OidcUserConsent.create!(
OidcUserConsent.create!(
user: @user,
application: @application,
scopes_granted: "openid profile",
@@ -206,7 +206,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
test "rejects authorization code with mismatched redirect_uri" do
# Create consent
consent = OidcUserConsent.create!(
OidcUserConsent.create!(
user: @user,
application: @application,
scopes_granted: "openid profile",
@@ -256,7 +256,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
test "rejects authorization code for different application" do
# Create consent for the first application
consent = OidcUserConsent.create!(
OidcUserConsent.create!(
user: @user,
application: @application,
scopes_granted: "openid profile",
@@ -308,7 +308,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
test "rejects invalid client_id in Basic auth" do
# Create consent
consent = OidcUserConsent.create!(
OidcUserConsent.create!(
user: @user,
application: @application,
scopes_granted: "openid profile",
@@ -341,7 +341,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
test "rejects invalid client_secret in Basic auth" do
# Create consent
consent = OidcUserConsent.create!(
OidcUserConsent.create!(
user: @user,
application: @application,
scopes_granted: "openid profile",
@@ -374,7 +374,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
test "accepts client credentials in POST body" do
# Create consent
consent = OidcUserConsent.create!(
OidcUserConsent.create!(
user: @user,
application: @application,
scopes_granted: "openid profile",
@@ -408,7 +408,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
test "rejects request with no client authentication" do
# Create consent
consent = OidcUserConsent.create!(
OidcUserConsent.create!(
user: @user,
application: @application,
scopes_granted: "openid profile",
@@ -474,7 +474,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
test "client authentication uses constant-time comparison" do
# Create consent
consent = OidcUserConsent.create!(
OidcUserConsent.create!(
user: @user,
application: @application,
scopes_granted: "openid profile",
@@ -593,7 +593,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
test "nonce parameter is included in ID token" do
# Create consent
consent = OidcUserConsent.create!(
OidcUserConsent.create!(
user: @user,
application: @application,
scopes_granted: "openid profile",
@@ -637,7 +637,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
test "access tokens are not exposed in referer header" do
# Create consent and authorization code
consent = OidcUserConsent.create!(
OidcUserConsent.create!(
user: @user,
application: @application,
scopes_granted: "openid profile",
@@ -664,7 +664,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
assert_response :success
response_body = JSON.parse(@response.body)
access_token = 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"
@@ -677,7 +677,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
test "PKCE code_verifier is required when code_challenge was provided" do
# Create consent
consent = OidcUserConsent.create!(
OidcUserConsent.create!(
user: @user,
application: @application,
scopes_granted: "openid profile",
@@ -716,7 +716,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
test "PKCE with S256 method validates correctly" do
# Create consent
consent = OidcUserConsent.create!(
OidcUserConsent.create!(
user: @user,
application: @application,
scopes_granted: "openid profile",
@@ -755,7 +755,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
test "PKCE rejects invalid code_verifier" do
# Create consent
consent = OidcUserConsent.create!(
OidcUserConsent.create!(
user: @user,
application: @application,
scopes_granted: "openid profile",
@@ -798,7 +798,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
test "refresh token rotation is enforced" do
# Create consent for the refresh token endpoint
consent = OidcUserConsent.create!(
OidcUserConsent.create!(
user: @user,
application: @application,
scopes_granted: "openid profile",

View File

@@ -38,7 +38,6 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
end
test "authorization endpoint accepts PKCE parameters (S256)" do
code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
code_challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
auth_params = {
@@ -56,7 +55,7 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
# Should show consent page (user is already authenticated)
assert_response :success
assert_match /consent/, @response.body.downcase
assert_match(/consent/, @response.body.downcase)
end
test "authorization endpoint accepts PKCE parameters (plain)" do
@@ -77,7 +76,7 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
# Should show consent page (user is already authenticated)
assert_response :success
assert_match /consent/, @response.body.downcase
assert_match(/consent/, @response.body.downcase)
end
test "authorization endpoint rejects invalid code_challenge_method" do
@@ -478,7 +477,7 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
assert_response :bad_request
error = JSON.parse(@response.body)
assert_equal "invalid_request", error["error"]
assert_match /PKCE is required for public clients/, error["error_description"]
assert_match(/PKCE is required for public clients/, error["error_description"])
# Cleanup
OidcRefreshToken.where(application: public_app).delete_all
@@ -525,7 +524,7 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
assert_response :bad_request
error = JSON.parse(@response.body)
assert_equal "invalid_request", error["error"]
assert_match /PKCE is required/, error["error_description"]
assert_match(/PKCE is required/, error["error_description"])
end
# ====================

View File

@@ -61,6 +61,7 @@ class PasswordsControllerTest < ActionDispatch::IntegrationTest
end
private
def assert_notice(text)
assert_select "div", /#{text}/
end

View File

@@ -91,13 +91,13 @@ class TotpSecurityTest < ActionDispatch::IntegrationTest
# Generate backup codes
user.totp_secret = ROTP::Base32.random
backup_codes = user.send(:generate_backup_codes) # Call private method
user.send(:generate_backup_codes) # Call private method
user.save!
# Check that stored codes are BCrypt hashes (start with $2a$)
# backup_codes is already an Array (JSON column), no need to parse
user.backup_codes.each do |code|
assert_match /^\$2[aby]\$/, code, "Backup codes should be BCrypt hashed"
assert_match(/^\$2[aby]\$/, code, "Backup codes should be BCrypt hashed")
end
user.destroy
@@ -145,7 +145,7 @@ class TotpSecurityTest < ActionDispatch::IntegrationTest
# Verify the TOTP secret exists (sanity check)
assert user.totp_secret.present?
totp_secret = user.totp_secret
user.totp_secret
# Sign in with TOTP
post signin_path, params: {email_address: "totp_secret_test@example.com", password: "password123"}

View File

@@ -56,8 +56,8 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
# Domain and Rule Integration Tests
test "different domain patterns with same session" do
# Create test rules
wildcard_rule = Application.create!(name: "Wildcard App", slug: "wildcard-app", app_type: "forward_auth", domain_pattern: "*.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)
Application.create!(name: "Wildcard App", slug: "wildcard-app", app_type: "forward_auth", domain_pattern: "*.example.com", active: true)
Application.create!(name: "Exact App", slug: "exact-app", app_type: "forward_auth", domain_pattern: "api.example.com", active: true)
# Sign in
post "/signin", params: {email_address: @user.email_address, password: "password"}
@@ -103,14 +103,14 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
# Header Configuration Integration Tests
test "different header configurations with same user" do
# Create applications with different configs
default_rule = Application.create!(name: "Default App", slug: "default-app", app_type: "forward_auth", domain_pattern: "default.example.com", active: true)
custom_rule = Application.create!(
Application.create!(name: "Default App", slug: "default-app", app_type: "forward_auth", domain_pattern: "default.example.com", active: true)
Application.create!(
name: "Custom App", slug: "custom-app", app_type: "forward_auth",
domain_pattern: "custom.example.com",
active: true,
headers_config: {user: "X-WEBAUTH-USER", groups: "X-WEBAUTH-ROLES"}
)
no_headers_rule = Application.create!(
Application.create!(
name: "No Headers App", slug: "no-headers-app", app_type: "forward_auth",
domain_pattern: "noheaders.example.com",
active: true,
@@ -194,7 +194,7 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
admin_user = users(:two)
# Create restricted rule
admin_rule = Application.create!(
Application.create!(
name: "Admin App", slug: "admin-app", app_type: "forward_auth",
domain_pattern: "admin.example.com",
active: true,
@@ -245,5 +245,4 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
assert Session.exists?(user_a_session_id), "User A's session should still exist"
assert Session.exists?(user_b_session_id), "User B's session should still exist"
end
end

View File

@@ -94,7 +94,7 @@ class InvitationFlowTest < ActionDispatch::IntegrationTest
end
test "expired invitation token flow" do
user = User.create!(
User.create!(
email_address: "expired@example.com",
password: "temppassword",
status: :pending_invitation

View File

@@ -92,21 +92,21 @@ class SessionSecurityTest < ActionDispatch::IntegrationTest
user = User.create!(email_address: "concurrent_session_test@example.com", password: "password123")
# Create multiple sessions from different devices
session1 = user.sessions.create!(
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!(
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!(
user.sessions.create!(
ip_address: "192.168.1.3",
user_agent: "Mozilla/5.0 (Macintosh)",
device_name: "MacBook",
@@ -157,14 +157,14 @@ class SessionSecurityTest < ActionDispatch::IntegrationTest
user = User.create!(email_address: "logout_test@example.com", password: "password123")
# Create multiple sessions
session1 = user.sessions.create!(
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!(
user.sessions.create!(
ip_address: "192.168.1.2",
user_agent: "Mozilla/5.0 (iPhone)",
device_name: "iPhone",
@@ -204,7 +204,7 @@ class SessionSecurityTest < ActionDispatch::IntegrationTest
)
# Create consent with backchannel logout enabled
consent = OidcUserConsent.create!(
OidcUserConsent.create!(
user: user,
application: application,
scopes_granted: "openid profile",

View File

@@ -10,7 +10,7 @@ class WebauthnCredentialEnumerationTest < ActionDispatch::IntegrationTest
user2 = User.create!(email_address: "user2@example.com", password: "password123")
# Create a credential for user1
credential1 = user1.webauthn_credentials.create!(
user1.webauthn_credentials.create!(
external_id: Base64.urlsafe_encode64("user1_credential"),
public_key: Base64.urlsafe_encode64("public_key_1"),
sign_count: 0,

View File

@@ -77,7 +77,7 @@ class ApplicationJobTest < ActiveJob::TestCase
args = enqueued_jobs.last[:args]
if args.is_a?(Array) && args.first.is_a?(Hash)
# GlobalID serialization format
assert_equal user.to_global_id.to_s, args.first['_aj_globalid']
assert_equal user.to_global_id.to_s, args.first["_aj_globalid"]
else
# Direct object serialization
assert_equal user.id, args.first.id

View File

@@ -166,7 +166,7 @@ class PasswordsMailerTest < ActionMailer::TestCase
# Should not include sensitive data in headers (except Subject which legitimately mentions password)
email.header.fields.each do |field|
next if field.name =~ /^subject$/i
next if /^subject$/i.match?(field.name)
# Check for actual tokens (not just the word "token" which is common in emails)
refute_includes field.value.to_s.downcase, "password"
end

View File

@@ -10,7 +10,7 @@ class ApplicationUserClaimTest < ActiveSupport::TestCase
claim = ApplicationUserClaim.new(
user: @user,
application: @application,
custom_claims: { "role": "admin" }
custom_claims: {role: "admin"}
)
assert claim.valid?
assert claim.save
@@ -20,13 +20,13 @@ class ApplicationUserClaimTest < ActiveSupport::TestCase
ApplicationUserClaim.create!(
user: @user,
application: @application,
custom_claims: { "role": "admin" }
custom_claims: {role: "admin"}
)
duplicate = ApplicationUserClaim.new(
user: @user,
application: @application,
custom_claims: { "role": "user" }
custom_claims: {role: "user"}
)
assert_not duplicate.valid?
@@ -37,7 +37,7 @@ class ApplicationUserClaimTest < ActiveSupport::TestCase
claim = ApplicationUserClaim.new(
user: @user,
application: @application,
custom_claims: { "role": "admin", "level": 5 }
custom_claims: {role: "admin", level: 5}
)
parsed = claim.parsed_custom_claims
@@ -59,7 +59,7 @@ class ApplicationUserClaimTest < ActiveSupport::TestCase
claim = ApplicationUserClaim.new(
user: @user,
application: @application,
custom_claims: { "groups": ["admin"], "role": "user" }
custom_claims: {groups: ["admin"], role: "user"}
)
assert_not claim.valid?
@@ -70,7 +70,7 @@ class ApplicationUserClaimTest < ActiveSupport::TestCase
claim = ApplicationUserClaim.new(
user: @user,
application: @application,
custom_claims: { "kavita_groups": ["admin"], "role": "user" }
custom_claims: {kavita_groups: ["admin"], role: "user"}
)
assert claim.valid?

View File

@@ -27,7 +27,7 @@ class OidcAccessTokenTest < ActiveSupport::TestCase
assert_nil new_token.plaintext_token
assert new_token.save
assert_not_nil new_token.plaintext_token
assert_match /^[A-Za-z0-9_-]+$/, new_token.plaintext_token
assert_match(/^[A-Za-z0-9_-]+$/, new_token.plaintext_token)
end
test "should set expiry before validation on create" do
@@ -144,7 +144,7 @@ class OidcAccessTokenTest < ActiveSupport::TestCase
# All tokens should match the expected pattern
tokens.each do |token|
assert_match /^[A-Za-z0-9_-]+$/, token
assert_match(/^[A-Za-z0-9_-]+$/, token)
# Base64 token length may vary due to padding, just ensure it's reasonable
assert token.length >= 43, "Token should be at least 43 characters"
assert token.length <= 64, "Token should not exceed 64 characters"

View File

@@ -28,7 +28,7 @@ class OidcAuthorizationCodeTest < ActiveSupport::TestCase
assert_nil new_code.code_hmac
assert new_code.save
assert_not_nil new_code.code_hmac
assert_match /^[a-f0-9]{64}$/, new_code.code_hmac # SHA256 hex digest
assert_match(/^[a-f0-9]{64}$/, new_code.code_hmac) # SHA256 hex digest
end
test "should set expiry before validation on create" do
@@ -186,7 +186,7 @@ class OidcAuthorizationCodeTest < ActiveSupport::TestCase
# All codes should be SHA256 hex digests
codes.each do |code|
assert_match /^[a-f0-9]{64}$/, code
assert_match(/^[a-f0-9]{64}$/, code)
assert_equal 64, code.length # SHA256 hex digest
end
end

View File

@@ -73,7 +73,7 @@ class UserPasswordManagementTest < ActiveSupport::TestCase
assert_not authenticated_user.authenticate("WrongPassword"), "Should not authenticate with wrong password"
# Test password changes invalidate old sessions
old_password_digest = @user.password_digest
@user.password_digest
@user.password = "NewPassword123!"
@user.save!
@@ -102,7 +102,7 @@ class UserPasswordManagementTest < ActiveSupport::TestCase
assert new_user.password_digest.length > 50, "Password digest should be substantial"
# Test digest format (bcrypt hashes start with $2a$)
assert_match /^\$2a\$/, new_user.password_digest, "Password digest should be bcrypt format"
assert_match(/^\$2a\$/, new_user.password_digest, "Password digest should be bcrypt format")
# Test authentication against digest
authenticated_user = User.find(new_user.id)

View File

@@ -33,7 +33,7 @@ class UserTest < ActiveSupport::TestCase
end
test "does not find user with invalid invitation token" do
user = User.create!(
User.create!(
email_address: "test@example.com",
password: "password123",
status: :pending_invitation
@@ -222,7 +222,7 @@ class UserTest < ActiveSupport::TestCase
# Should store 10 BCrypt hashes
assert_equal 10, stored_hashes.length
stored_hashes.each do |hash|
assert hash.start_with?('$2a$'), "Should be BCrypt hash"
assert hash.start_with?("$2a$"), "Should be BCrypt hash"
end
# Verify each plain code matches its corresponding hash

View File

@@ -61,18 +61,18 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
assert_not_nil token, "Should generate token"
assert token.length > 100, "Token should be substantial"
assert token.include?('.')
assert token.include?(".")
# Decode without verification for testing the payload
decoded = JWT.decode(token, nil, false).first
assert_equal @application.client_id, decoded['aud'], "Should have correct audience"
assert_equal @user.id.to_s, decoded['sub'], "Should have correct subject"
assert_equal @user.email_address, decoded['email'], "Should have correct email"
assert_equal true, decoded['email_verified'], "Should have email verified"
assert_equal @user.email_address, decoded['preferred_username'], "Should have preferred username"
assert_equal @user.email_address, decoded['name'], "Should have name"
assert_equal @service.issuer_url, decoded['iss'], "Should have correct issuer"
assert_in_delta Time.current.to_i + 3600, decoded['exp'], 5, "Should have correct expiration"
assert_equal @application.client_id, decoded["aud"], "Should have correct audience"
assert_equal @user.id.to_s, decoded["sub"], "Should have correct subject"
assert_equal @user.email_address, decoded["email"], "Should have correct email"
assert_equal true, decoded["email_verified"], "Should have email verified"
assert_equal @user.email_address, decoded["preferred_username"], "Should have preferred username"
assert_equal @user.email_address, decoded["name"], "Should have name"
assert_equal @service.issuer_url, decoded["iss"], "Should have correct issuer"
assert_in_delta Time.current.to_i + 3600, decoded["exp"], 5, "Should have correct expiration"
end
test "should handle nonce in id token" do
@@ -80,8 +80,8 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
token = @service.generate_id_token(@user, @application, nonce: nonce)
decoded = JWT.decode(token, nil, false).first
assert_equal nonce, decoded['nonce'], "Should preserve nonce in token"
assert_in_delta Time.current.to_i + 3600, decoded['exp'], 5, "Should have correct expiration with nonce"
assert_equal nonce, decoded["nonce"], "Should preserve nonce in token"
assert_in_delta Time.current.to_i + 3600, decoded["exp"], 5, "Should have correct expiration with nonce"
end
test "should include groups in token when user has groups" do
@@ -91,7 +91,7 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
token = @service.generate_id_token(@user, @application)
decoded = JWT.decode(token, nil, false).first
assert_includes decoded['groups'], "Administrators", "Should include user's groups"
assert_includes decoded["groups"], "Administrators", "Should include user's groups"
end
test "admin claim should not be included in token" do
@@ -100,14 +100,14 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
token = @service.generate_id_token(@user, @application)
decoded = JWT.decode(token, nil, false).first
refute decoded.key?('admin'), "Admin claim should not be included in ID tokens (use groups instead)"
refute decoded.key?("admin"), "Admin claim should not be included in ID tokens (use groups instead)"
end
test "should handle missing roles gracefully" do
token = @service.generate_id_token(@user, @application)
decoded = JWT.decode(token, nil, false).first
refute_includes decoded, 'roles', "Should not have roles when not configured"
refute_includes decoded, "roles", "Should not have roles when not configured"
end
test "should load RSA private key from environment with escaped newlines" do
@@ -168,7 +168,7 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
OidcJwtService.send(:private_key)
end
assert_match /Invalid OIDC private key format/, error.message
assert_match(/Invalid OIDC private key format/, error.message)
ensure
# Restore original value and clear cached key
ENV["OIDC_PRIVATE_KEY"] = original_value
@@ -193,7 +193,7 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
OidcJwtService.send(:private_key)
end
assert_match /OIDC private key not configured/, error.message
assert_match(/OIDC private key not configured/, error.message)
ensure
# Restore original environment and clear cached key
ENV["OIDC_PRIVATE_KEY"] = original_value if original_value
@@ -214,9 +214,9 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
assert_not_nil decoded_array, "Should decode valid token"
decoded = decoded_array.first # JWT.decode returns an array
assert_equal @user.id.to_s, decoded['sub'], "Should decode subject correctly"
assert_equal @application.client_id, decoded['aud'], "Should decode audience correctly"
assert decoded['exp'] > Time.current.to_i, "Token should not be expired"
assert_equal @user.id.to_s, decoded["sub"], "Should decode subject correctly"
assert_equal @application.client_id, decoded["aud"], "Should decode audience correctly"
assert decoded["exp"] > Time.current.to_i, "Token should not be expired"
end
test "should reject invalid id tokens" do
@@ -252,9 +252,9 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
decoded = JWT.decode(token, nil, false).first
# ID tokens always include email_verified
assert_includes decoded.keys, 'email_verified'
assert_equal @user.id.to_s, decoded['sub'], "Should decode subject correctly"
assert_equal @application.client_id, decoded['aud'], "Should decode audience correctly"
assert_includes decoded.keys, "email_verified"
assert_equal @user.id.to_s, decoded["sub"], "Should decode subject correctly"
assert_equal @application.client_id, decoded["aud"], "Should decode audience correctly"
end
test "should validate JWT configuration" do
@@ -275,7 +275,7 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
ApplicationUserClaim.create!(
user: user,
application: app,
custom_claims: { "app_groups": ["admin"], "library_access": "all" }
custom_claims: {app_groups: ["admin"], library_access: "all"}
)
token = @service.generate_id_token(user, app)
@@ -292,17 +292,17 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
# Add user to group with claims
group = groups(:admin_group)
group.update!(custom_claims: { "role": "viewer", "max_items": 10 })
group.update!(custom_claims: {role: "viewer", max_items: 10})
user.groups << group
# Add user custom claims
user.update!(custom_claims: { "role": "editor", "theme": "dark" })
user.update!(custom_claims: {role: "editor", theme: "dark"})
# Add app-specific claims (should override both)
ApplicationUserClaim.create!(
user: user,
application: app,
custom_claims: { "role": "admin", "app_specific": true }
custom_claims: {role: "admin", app_specific: true}
)
token = @service.generate_id_token(user, app)

View File

@@ -13,7 +13,7 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
# End-to-End Authentication Flow Tests
test "complete forward auth flow with default headers" do
# Create an application with default headers
rule = Application.create!(name: "App", slug: "app-system-test", app_type: "forward_auth", domain_pattern: "app.example.com", active: true)
Application.create!(name: "App", slug: "app-system-test", app_type: "forward_auth", domain_pattern: "app.example.com", active: true)
# Step 1: Unauthenticated request to protected resource
get "/api/verify", headers: {
@@ -46,14 +46,14 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
test "multiple domain access with single session" do
# Create applications for different domains
app_rule = Application.create!(name: "App Domain", slug: "app-domain", app_type: "forward_auth", domain_pattern: "app.example.com", active: true)
grafana_rule = Application.create!(
Application.create!(name: "App Domain", slug: "app-domain", app_type: "forward_auth", domain_pattern: "app.example.com", active: true)
Application.create!(
name: "Grafana", slug: "grafana-system-test", app_type: "forward_auth",
domain_pattern: "grafana.example.com",
active: true,
headers_config: {user: "X-WEBAUTH-USER", email: "X-WEBAUTH-EMAIL"}
)
metube_rule = Application.create!(
Application.create!(
name: "Metube", slug: "metube-system-test", app_type: "forward_auth",
domain_pattern: "metube.example.com",
active: true,
@@ -126,7 +126,7 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
test "bypass mode when no groups assigned to rule" do
# Create bypass application (no groups)
bypass_rule = Application.create!(
Application.create!(
name: "Public", slug: "public-system-test", app_type: "forward_auth",
domain_pattern: "public.example.com",
active: true
@@ -260,7 +260,7 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
]
# Create applications for each app
rules = apps.map.with_index do |app, idx|
apps.map.with_index do |app, idx|
rule = Application.create!(
name: "Multi App #{idx}", slug: "multi-app-#{idx}", app_type: "forward_auth",
domain_pattern: app[:domain],
@@ -306,7 +306,7 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
]
patterns.each_with_index do |pattern_config, idx|
rule = Application.create!(
Application.create!(
name: "Pattern Test #{idx}", slug: "pattern-test-#{idx}", app_type: "forward_auth",
domain_pattern: pattern_config[:pattern],
active: true
@@ -330,7 +330,7 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
# Performance System Tests
test "system performance under load" do
# Create test application
rule = Application.create!(name: "Load Test", slug: "loadtest", app_type: "forward_auth", domain_pattern: "loadtest.example.com", active: true)
Application.create!(name: "Load Test", slug: "loadtest", app_type: "forward_auth", domain_pattern: "loadtest.example.com", active: true)
# Sign in
post "/signin", params: {email_address: @user.email_address, password: "password"}

View File

@@ -78,7 +78,7 @@ class WebauthnSecurityTest < ActionDispatch::SystemTestCase
user = User.create!(email_address: "webauthn_handle_auth_test@example.com", password: "password123")
user_handle = SecureRandom.uuid
credential = user.webauthn_credentials.create!(
user.webauthn_credentials.create!(
external_id: Base64.urlsafe_encode64("fake_credential_id"),
public_key: Base64.urlsafe_encode64("fake_public_key"),
sign_count: 0,
@@ -99,7 +99,7 @@ class WebauthnSecurityTest < ActionDispatch::SystemTestCase
test "WebAuthn request validates origin" do
user = User.create!(email_address: "webauthn_origin_test@example.com", password: "password123")
credential = user.webauthn_credentials.create!(
user.webauthn_credentials.create!(
external_id: Base64.urlsafe_encode64("fake_credential_id"),
public_key: Base64.urlsafe_encode64("fake_public_key"),
sign_count: 0,
@@ -108,13 +108,13 @@ class WebauthnSecurityTest < ActionDispatch::SystemTestCase
# Test WebAuthn challenge from valid origin
post webauthn_challenge_path, params: {email: "webauthn_origin_test@example.com"},
headers: { "HTTP_ORIGIN": "http://localhost:3000" }
headers: {HTTP_ORIGIN: "http://localhost:3000"}
# Should succeed for valid origin
# Test WebAuthn challenge from invalid origin
post webauthn_challenge_path, params: {email: "webauthn_origin_test@example.com"},
headers: { "HTTP_ORIGIN": "http://evil.com" }
headers: {HTTP_ORIGIN: "http://evil.com"}
# Should reject invalid origin
@@ -125,7 +125,7 @@ class WebauthnSecurityTest < ActionDispatch::SystemTestCase
user = User.create!(email_address: "webauthn_verify_origin_test@example.com", password: "password123")
user.update!(webauthn_id: SecureRandom.uuid)
credential = user.webauthn_credentials.create!(
user.webauthn_credentials.create!(
external_id: Base64.urlsafe_encode64("fake_credential_id"),
public_key: Base64.urlsafe_encode64("fake_public_key"),
sign_count: 0,
@@ -136,7 +136,7 @@ class WebauthnSecurityTest < ActionDispatch::SystemTestCase
post webauthn_challenge_path, params: {email: "webauthn_verify_origin_test@example.com"}
assert_response :success
challenge = JSON.parse(@response.body)["challenge"]
JSON.parse(@response.body)["challenge"]
# Simulate WebAuthn verification with wrong origin
# This should fail
@@ -155,7 +155,7 @@ class WebauthnSecurityTest < ActionDispatch::SystemTestCase
# Standard attestation formats: none, packed, tpm, android-key, android-safetynet, fido-u2f, etc.
# Test with 'none' attestation (most common for privacy)
attestation_object = {
{
fmt: "none",
attStmt: {},
authData: Base64.strict_encode64("fake_auth_data")
@@ -170,7 +170,7 @@ class WebauthnSecurityTest < ActionDispatch::SystemTestCase
user = User.create!(email_address: "webauthn_invalid_attestation_test@example.com", password: "password123")
# Try to register with invalid attestation format
invalid_attestation = {
{
fmt: "invalid_format",
attStmt: {},
authData: Base64.strict_encode64("fake_auth_data")
@@ -263,7 +263,7 @@ class WebauthnSecurityTest < ActionDispatch::SystemTestCase
test "WebAuthn requires user presence for authentication" do
user = User.create!(email_address: "webauthn_presence_test@example.com", password: "password123")
credential = user.webauthn_credentials.create!(
user.webauthn_credentials.create!(
external_id: Base64.urlsafe_encode64("fake_credential_id"),
public_key: Base64.urlsafe_encode64("fake_public_key"),
sign_count: 0,
@@ -291,7 +291,7 @@ class WebauthnSecurityTest < ActionDispatch::SystemTestCase
nickname: "USB Key"
)
credential2 = user.webauthn_credentials.create!(
user.webauthn_credentials.create!(
external_id: Base64.urlsafe_encode64("credential_2"),
public_key: Base64.urlsafe_encode64("public_key_2"),
sign_count: 0,
@@ -329,7 +329,7 @@ class WebauthnSecurityTest < ActionDispatch::SystemTestCase
user = User.create!(email_address: "webauthn_passwordless_test@example.com", password: "password123")
user.update!(webauthn_enabled: true)
credential = user.webauthn_credentials.create!(
user.webauthn_credentials.create!(
external_id: Base64.urlsafe_encode64("passwordless_credential"),
public_key: Base64.urlsafe_encode64("public_key"),
sign_count: 0,