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! client_secret = @application.generate_new_client_secret!
end end
if @application.oidc?
flash[:notice] = "Application created successfully." flash[:notice] = "Application created successfully."
if @application.oidc?
flash[:client_id] = @application.client_id flash[:client_id] = @application.client_id
flash[:client_secret] = client_secret if client_secret flash[:client_secret] = client_secret if client_secret
flash[:public_client] = true if @application.public_client? flash[:public_client] = true if @application.public_client?
else
flash[:notice] = "Application created successfully."
end end
redirect_to admin_application_path(@application) redirect_to admin_application_path(@application)

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
require 'uri' require "uri"
require 'public_suffix' require "public_suffix"
require 'ipaddr' require "ipaddr"
module Authentication module Authentication
extend ActiveSupport::Concern extend ActiveSupport::Concern
@@ -17,6 +17,7 @@ module Authentication
end end
private private
def authenticated? def authenticated?
resume_session resume_session
end end
@@ -39,9 +40,8 @@ module Authentication
end end
def after_authentication_url def after_authentication_url
return_url = session[:return_to_after_authenticating] session[:return_to_after_authenticating]
final_url = session.delete(:return_to_after_authenticating) || root_url session.delete(:return_to_after_authenticating) || root_url
final_url
end end
def start_new_session_for(user, acr: "1") 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)$/) return nil if host.blank? || host.match?(/^(localhost|127\.0\.0\.1|::1)$/)
# Strip port number for domain parsing # 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 # 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 # Use Public Suffix List for accurate domain parsing
domain = PublicSuffix.parse(host_without_port) domain = PublicSuffix.parse(host_without_port)
@@ -138,7 +142,7 @@ module Authentication
unless uri.path&.start_with?("/oauth/") unless uri.path&.start_with?("/oauth/")
# Add token as query parameter # Add token as query parameter
query_params = URI.decode_www_form(uri.query || "").to_h 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) uri.query = URI.encode_www_form(query_params)
# Update the session with the tokenized URL # Update the session with the tokenized URL

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
class ApplicationMailer < ActionMailer::Base 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" layout "mailer"
end end

View File

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

View File

@@ -28,7 +28,7 @@ class Group < ApplicationRecord
reserved_used = parsed_custom_claims.keys.map(&:to_s) & RESERVED_CLAIMS reserved_used = parsed_custom_claims.keys.map(&:to_s) & RESERVED_CLAIMS
if reserved_used.any? 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 end
end end

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -62,7 +62,6 @@ Rails.application.configure do
# Use async processor for background jobs in development # Use async processor for background jobs in development
config.active_job.queue_adapter = :async config.active_job.queue_adapter = :async
# Highlight code that triggered redirect in logs. # Highlight code that triggered redirect in logs.
config.action_dispatch.verbose_redirect_logs = true 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: Rails already sets X-Content-Type-Options: nosniff by default
# Note: Permissions-Policy is configured in config/initializers/permissions_policy.rb # Note: Permissions-Policy is configured in config/initializers/permissions_policy.rb
config.action_dispatch.default_headers.merge!( config.action_dispatch.default_headers.merge!(
'X-Frame-Options' => 'DENY', # Override default SAMEORIGIN to prevent clickjacking "X-Frame-Options" => "DENY", # Override default SAMEORIGIN to prevent clickjacking
'Referrer-Policy' => 'strict-origin-when-cross-origin' # Control referrer information "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.
@@ -43,7 +43,7 @@ Rails.application.configure do
# Log to STDOUT with the current request id as a default log tag. # Log to STDOUT with the current request id as a default log tag.
config.log_tags = [:request_id] 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!). # Change to "debug" to log everything (including potentially personally-identifiable information!).
config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info") 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. # Set host to be used by links generated in mailer templates.
config.action_mailer.default_url_options = { 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. # 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) def self.extract_domain(host)
return host if host.blank? return host if host.blank?
# Remove protocol (http:// or https://) if present # Remove protocol (http:// or https://) if present
host.gsub(/^https?:\/\//, '') host.gsub(/^https?:\/\//, "")
end end
# Helper method to ensure URL has https:// protocol # 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. # Enable DNS rebinding protection and other `Host` header attacks.
# Configure allowed hosts based on deployment scenario # Configure allowed hosts based on deployment scenario
allowed_hosts = [ 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 # 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? if host_domain.present?
begin begin
# Use PublicSuffix to properly extract the domain # Use PublicSuffix to properly extract the domain
@@ -123,20 +123,20 @@ Rails.application.configure do
rescue PublicSuffix::DomainInvalid rescue PublicSuffix::DomainInvalid
# Fallback to simple domain extraction if PublicSuffix fails # Fallback to simple domain extraction if PublicSuffix fails
Rails.logger.warn "Could not parse domain '#{host_domain}' with PublicSuffix, using fallback" 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)}/ allowed_hosts << /.*#{Regexp.escape(base_domain)}/
end end
end end
# Allow Docker service names if running in same compose # Allow Docker service names if running in same compose
if ENV['CLINCH_DOCKER_SERVICE_NAME'] if ENV["CLINCH_DOCKER_SERVICE_NAME"]
allowed_hosts << ENV['CLINCH_DOCKER_SERVICE_NAME'] allowed_hosts << ENV["CLINCH_DOCKER_SERVICE_NAME"]
end end
# Allow internal IP access for cross-compose or host networking # 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 # Specific host IP
allowed_hosts << '192.168.2.246' allowed_hosts << "192.168.2.246"
# Private IP ranges for internal network access # Private IP ranges for internal network access
allowed_hosts += [ allowed_hosts += [
@@ -147,8 +147,8 @@ Rails.application.configure do
end end
# Local development fallbacks # Local development fallbacks
if ENV['CLINCH_ALLOW_LOCALHOST'] == 'true' if ENV["CLINCH_ALLOW_LOCALHOST"] == "true"
allowed_hosts += ['localhost', '127.0.0.1', '0.0.0.0'] allowed_hosts += ["localhost", "127.0.0.1", "0.0.0.0"]
end end
config.hosts = allowed_hosts config.hosts = allowed_hosts

View File

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

View File

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

View File

@@ -8,7 +8,7 @@ Rails.application.config.after_initialize do
# Configure log rotation # Configure log rotation
csp_logger = Logger.new( csp_logger = Logger.new(
csp_log_path, csp_log_path,
'daily', # Rotate daily "daily", # Rotate daily
30 # Keep 30 old log files 30 # Keep 30 old log files
) )
@@ -16,7 +16,7 @@ Rails.application.config.after_initialize do
# Format: [TIMESTAMP] LEVEL MESSAGE # Format: [TIMESTAMP] LEVEL MESSAGE
csp_logger.formatter = proc do |severity, datetime, progname, msg| 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 end
module CspViolationLocalLogger module CspViolationLocalLogger
@@ -69,7 +69,6 @@ Rails.application.config.after_initialize do
# Also log to main Rails logger for visibility # Also log to main Rails logger for visibility
Rails.logger.info "CSP violation logged to csp_violations.log: #{violated_directive} - #{blocked_uri}" Rails.logger.info "CSP violation logged to csp_violations.log: #{violated_directive} - #{blocked_uri}"
rescue => e rescue => e
# Ensure logger errors don't break the CSP reporting flow # Ensure logger errors don't break the CSP reporting flow
Rails.logger.error "Failed to log CSP violation to file: #{e.message}" 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") csp_log_path = Rails.root.join("log", "csp_violations.log")
logger = Logger.new( logger = Logger.new(
csp_log_path, csp_log_path,
'daily', # Rotate daily "daily", # Rotate daily
30 # Keep 30 old log files 30 # Keep 30 old log files
) )
logger.level = Logger::INFO logger.level = Logger::INFO
logger.formatter = proc do |severity, datetime, progname, msg| 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 end
logger logger
end end
@@ -120,7 +119,6 @@ Rails.application.config.after_initialize do
# Test write to ensure permissions are correct # Test write to ensure permissions are correct
csp_logger.info "CSP Logger initialized at #{Time.current}" csp_logger.info "CSP Logger initialized at #{Time.current}"
rescue => e rescue => e
Rails.logger.error "Failed to initialize CSP local logger: #{e.message}" Rails.logger.error "Failed to initialize CSP local logger: #{e.message}"
Rails.logger.error "CSP violations will only be sent to Sentry (if configured)" 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 parsed.host
rescue URI::InvalidURIError rescue URI::InvalidURIError
# Handle cases where URI might be malformed or just a path # 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 nil # It's a relative path, no domain
else else
uri.split('/').first # Best effort extraction uri.split("/").first # Best effort extraction
end end
end end
end end

View File

@@ -3,5 +3,5 @@
# Derived from SECRET_KEY_BASE - no storage needed, deterministic output # Derived from SECRET_KEY_BASE - no storage needed, deterministic output
# Optional: Set OIDC_TOKEN_PREFIX_HMAC env var to override with explicit key # Optional: Set OIDC_TOKEN_PREFIX_HMAC env var to override with explicit key
module TokenHmac 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 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. # Specifies the `port` that Puma will listen on to receive requests; default is 3000.
port ENV.fetch("PORT", 3000) port ENV.fetch("PORT", 3000)
# Allow puma to be restarted by `bin/rails restart` command. # Allow puma to be restarted by `bin/rails restart` command.
plugin :tmp_restart 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. # 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. # 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 # Authentication routes
get "/signup", to: "users#new", as: :signup get "/signup", to: "users#new", as: :signup
@@ -61,21 +61,21 @@ Rails.application.routes.draw do
end end
# TOTP (2FA) routes # TOTP (2FA) routes
get '/totp/new', to: 'totp#new', as: :new_totp get "/totp/new", to: "totp#new", as: :new_totp
post '/totp', to: 'totp#create', as: :totp post "/totp", to: "totp#create", as: :totp
delete '/totp', to: 'totp#destroy' delete "/totp", to: "totp#destroy"
get '/totp/backup_codes', to: 'totp#backup_codes', as: :backup_codes_totp get "/totp/backup_codes", to: "totp#backup_codes", as: :backup_codes_totp
post '/totp/verify_password', to: 'totp#verify_password', as: :verify_password_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 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/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 post "/totp/complete_setup", to: "totp#complete_setup", as: :complete_totp_setup
# WebAuthn (Passkeys) routes # WebAuthn (Passkeys) routes
get '/webauthn/new', to: 'webauthn#new', as: :new_webauthn get "/webauthn/new", to: "webauthn#new", as: :new_webauthn
post '/webauthn/challenge', to: 'webauthn#challenge' post "/webauthn/challenge", to: "webauthn#challenge"
post '/webauthn/create', to: 'webauthn#create' post "/webauthn/create", to: "webauthn#create"
delete '/webauthn/:id', to: 'webauthn#destroy', as: :webauthn_credential delete "/webauthn/:id", to: "webauthn#destroy", as: :webauthn_credential
get '/webauthn/check', to: 'webauthn#check' get "/webauthn/check", to: "webauthn#check"
# Admin routes # Admin routes
namespace :admin do namespace :admin do

View File

@@ -1,9 +1,9 @@
class AddRoleMappingToApplications < ActiveRecord::Migration[8.1] class AddRoleMappingToApplications < ActiveRecord::Migration[8.1]
def change 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, :role_prefix, :string
add_column :applications, :managed_permissions, :json, default: {} 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| create_table :application_roles do |t|
t.references :application, null: false, foreign_key: true 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| create_table :user_role_assignments do |t|
t.references :user, null: false, foreign_key: true t.references :user, null: false, foreign_key: true
t.references :application_role, 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.json :metadata, default: {}
t.timestamps t.timestamps

View File

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

View File

@@ -8,6 +8,6 @@ class CreateApplicationUserClaims < ActiveRecord::Migration[8.1]
t.timestamps t.timestamps
end 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
end end

View File

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

View File

@@ -86,7 +86,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 = 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) sign_in_as(@user)
get "/api/verify", headers: {"X-Forwarded-Host" => "app.example.com"} get "/api/verify", headers: {"X-Forwarded-Host" => "app.example.com"}
@@ -101,7 +101,7 @@ module Api
end end
test "should match exact domains correctly" do 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) sign_in_as(@user)
get "/api/verify", headers: {"X-Forwarded-Host" => "api.example.com"} get "/api/verify", headers: {"X-Forwarded-Host" => "api.example.com"}
@@ -126,7 +126,7 @@ module Api
end end
test "should return custom headers when configured" do test "should return custom headers when configured" do
custom_rule = Application.create!( Application.create!(
name: "Custom App", name: "Custom App",
slug: "custom-app", slug: "custom-app",
app_type: "forward_auth", app_type: "forward_auth",
@@ -151,7 +151,7 @@ module Api
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 = Application.create!( Application.create!(
name: "No Headers App", name: "No Headers App",
slug: "no-headers-app", slug: "no-headers-app",
app_type: "forward_auth", 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)$/) return nil if host.blank? || host.match?(/^(localhost|127\.0\.0\.1|::1)$/)
# Strip port number for domain parsing # 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 # 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 # Use Public Suffix List for accurate domain parsing
domain = PublicSuffix.parse(host_without_port) 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 test "should destroy existing sessions when accepting invitation" do
# Create an existing session for the user # Create an existing session for the user
existing_session = @user.sessions.create! @user.sessions.create!
put invitation_path(@token), params: { put invitation_path(@token), params: {
password: "newpassword123", password: "newpassword123",

View File

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

View File

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

View File

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

View File

@@ -91,13 +91,13 @@ class TotpSecurityTest < ActionDispatch::IntegrationTest
# Generate backup codes # Generate backup codes
user.totp_secret = ROTP::Base32.random 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! user.save!
# Check that stored codes are BCrypt hashes (start with $2a$) # Check that stored codes are BCrypt hashes (start with $2a$)
# backup_codes is already an Array (JSON column), no need to parse # backup_codes is already an Array (JSON column), no need to parse
user.backup_codes.each do |code| 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 end
user.destroy user.destroy
@@ -145,7 +145,7 @@ class TotpSecurityTest < ActionDispatch::IntegrationTest
# Verify the TOTP secret exists (sanity check) # Verify the TOTP secret exists (sanity check)
assert user.totp_secret.present? assert user.totp_secret.present?
totp_secret = user.totp_secret user.totp_secret
# Sign in with TOTP # Sign in with TOTP
post signin_path, params: {email_address: "totp_secret_test@example.com", password: "password123"} 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 # Domain and Rule Integration Tests
test "different domain patterns with same session" do test "different domain patterns with same session" do
# Create test rules # Create test rules
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)
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 # Sign in
post "/signin", params: {email_address: @user.email_address, password: "password"} post "/signin", params: {email_address: @user.email_address, password: "password"}
@@ -103,14 +103,14 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
# Header Configuration Integration Tests # Header Configuration Integration Tests
test "different header configurations with same user" do test "different header configurations with same user" do
# Create applications with different configs # 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) 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: "Custom App", slug: "custom-app", app_type: "forward_auth", 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: {user: "X-WEBAUTH-USER", groups: "X-WEBAUTH-ROLES"} 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", 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,
@@ -194,7 +194,7 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
admin_user = users(:two) admin_user = users(:two)
# Create restricted rule # Create restricted rule
admin_rule = Application.create!( Application.create!(
name: "Admin App", slug: "admin-app", app_type: "forward_auth", name: "Admin App", slug: "admin-app", app_type: "forward_auth",
domain_pattern: "admin.example.com", domain_pattern: "admin.example.com",
active: true, 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_a_session_id), "User A's session should still exist"
assert Session.exists?(user_b_session_id), "User B's session should still exist" assert Session.exists?(user_b_session_id), "User B's session should still exist"
end end
end end

View File

@@ -94,7 +94,7 @@ class InvitationFlowTest < ActionDispatch::IntegrationTest
end end
test "expired invitation token flow" do test "expired invitation token flow" do
user = User.create!( User.create!(
email_address: "expired@example.com", email_address: "expired@example.com",
password: "temppassword", password: "temppassword",
status: :pending_invitation 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") user = User.create!(email_address: "concurrent_session_test@example.com", password: "password123")
# Create multiple sessions from different devices # Create multiple sessions from different devices
session1 = user.sessions.create!( user.sessions.create!(
ip_address: "192.168.1.1", ip_address: "192.168.1.1",
user_agent: "Mozilla/5.0 (Windows)", user_agent: "Mozilla/5.0 (Windows)",
device_name: "Windows PC", device_name: "Windows PC",
last_activity_at: Time.current last_activity_at: Time.current
) )
session2 = user.sessions.create!( user.sessions.create!(
ip_address: "192.168.1.2", ip_address: "192.168.1.2",
user_agent: "Mozilla/5.0 (iPhone)", user_agent: "Mozilla/5.0 (iPhone)",
device_name: "iPhone", device_name: "iPhone",
last_activity_at: Time.current last_activity_at: Time.current
) )
session3 = user.sessions.create!( user.sessions.create!(
ip_address: "192.168.1.3", ip_address: "192.168.1.3",
user_agent: "Mozilla/5.0 (Macintosh)", user_agent: "Mozilla/5.0 (Macintosh)",
device_name: "MacBook", device_name: "MacBook",
@@ -157,14 +157,14 @@ class SessionSecurityTest < ActionDispatch::IntegrationTest
user = User.create!(email_address: "logout_test@example.com", password: "password123") user = User.create!(email_address: "logout_test@example.com", password: "password123")
# Create multiple sessions # Create multiple sessions
session1 = user.sessions.create!( user.sessions.create!(
ip_address: "192.168.1.1", ip_address: "192.168.1.1",
user_agent: "Mozilla/5.0 (Windows)", user_agent: "Mozilla/5.0 (Windows)",
device_name: "Windows PC", device_name: "Windows PC",
last_activity_at: Time.current last_activity_at: Time.current
) )
session2 = user.sessions.create!( user.sessions.create!(
ip_address: "192.168.1.2", ip_address: "192.168.1.2",
user_agent: "Mozilla/5.0 (iPhone)", user_agent: "Mozilla/5.0 (iPhone)",
device_name: "iPhone", device_name: "iPhone",
@@ -204,7 +204,7 @@ class SessionSecurityTest < ActionDispatch::IntegrationTest
) )
# Create consent with backchannel logout enabled # Create consent with backchannel logout enabled
consent = OidcUserConsent.create!( OidcUserConsent.create!(
user: user, user: user,
application: application, application: application,
scopes_granted: "openid profile", scopes_granted: "openid profile",

View File

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

View File

@@ -77,7 +77,7 @@ class ApplicationJobTest < ActiveJob::TestCase
args = enqueued_jobs.last[:args] args = enqueued_jobs.last[:args]
if args.is_a?(Array) && args.first.is_a?(Hash) if args.is_a?(Array) && args.first.is_a?(Hash)
# GlobalID serialization format # 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 else
# Direct object serialization # Direct object serialization
assert_equal user.id, args.first.id 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) # Should not include sensitive data in headers (except Subject which legitimately mentions password)
email.header.fields.each do |field| 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) # Check for actual tokens (not just the word "token" which is common in emails)
refute_includes field.value.to_s.downcase, "password" refute_includes field.value.to_s.downcase, "password"
end end

View File

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

View File

@@ -27,7 +27,7 @@ class OidcAccessTokenTest < ActiveSupport::TestCase
assert_nil new_token.plaintext_token assert_nil new_token.plaintext_token
assert new_token.save assert new_token.save
assert_not_nil new_token.plaintext_token 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 end
test "should set expiry before validation on create" do test "should set expiry before validation on create" do
@@ -144,7 +144,7 @@ class OidcAccessTokenTest < ActiveSupport::TestCase
# All tokens should match the expected pattern # All tokens should match the expected pattern
tokens.each do |token| 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 # 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 >= 43, "Token should be at least 43 characters"
assert token.length <= 64, "Token should not exceed 64 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_nil new_code.code_hmac
assert new_code.save assert new_code.save
assert_not_nil new_code.code_hmac 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 end
test "should set expiry before validation on create" do test "should set expiry before validation on create" do
@@ -186,7 +186,7 @@ class OidcAuthorizationCodeTest < ActiveSupport::TestCase
# All codes should be SHA256 hex digests # All codes should be SHA256 hex digests
codes.each do |code| 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 assert_equal 64, code.length # SHA256 hex digest
end end
end end

View File

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

View File

@@ -33,7 +33,7 @@ class UserTest < ActiveSupport::TestCase
end end
test "does not find user with invalid invitation token" do test "does not find user with invalid invitation token" do
user = User.create!( User.create!(
email_address: "test@example.com", email_address: "test@example.com",
password: "password123", password: "password123",
status: :pending_invitation status: :pending_invitation
@@ -222,7 +222,7 @@ class UserTest < ActiveSupport::TestCase
# Should store 10 BCrypt hashes # Should store 10 BCrypt hashes
assert_equal 10, stored_hashes.length assert_equal 10, stored_hashes.length
stored_hashes.each do |hash| stored_hashes.each do |hash|
assert hash.start_with?('$2a$'), "Should be BCrypt hash" assert hash.start_with?("$2a$"), "Should be BCrypt hash"
end end
# Verify each plain code matches its corresponding hash # 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_not_nil token, "Should generate token"
assert token.length > 100, "Token should be substantial" assert token.length > 100, "Token should be substantial"
assert token.include?('.') assert token.include?(".")
# Decode without verification for testing the payload # Decode without verification for testing the payload
decoded = JWT.decode(token, nil, false).first decoded = JWT.decode(token, nil, false).first
assert_equal @application.client_id, decoded['aud'], "Should have correct audience" 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.id.to_s, decoded["sub"], "Should have correct subject"
assert_equal @user.email_address, decoded['email'], "Should have correct email" assert_equal @user.email_address, decoded["email"], "Should have correct email"
assert_equal true, decoded['email_verified'], "Should have email verified" 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["preferred_username"], "Should have preferred username"
assert_equal @user.email_address, decoded['name'], "Should have name" assert_equal @user.email_address, decoded["name"], "Should have name"
assert_equal @service.issuer_url, decoded['iss'], "Should have correct issuer" 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_in_delta Time.current.to_i + 3600, decoded["exp"], 5, "Should have correct expiration"
end end
test "should handle nonce in id token" do 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) token = @service.generate_id_token(@user, @application, nonce: nonce)
decoded = JWT.decode(token, nil, false).first decoded = JWT.decode(token, nil, false).first
assert_equal nonce, decoded['nonce'], "Should preserve nonce in token" 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_in_delta Time.current.to_i + 3600, decoded["exp"], 5, "Should have correct expiration with nonce"
end end
test "should include groups in token when user has groups" do 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) token = @service.generate_id_token(@user, @application)
decoded = JWT.decode(token, nil, false).first 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 end
test "admin claim should not be included in token" do 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) token = @service.generate_id_token(@user, @application)
decoded = JWT.decode(token, nil, false).first 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 end
test "should handle missing roles gracefully" do test "should handle missing roles gracefully" do
token = @service.generate_id_token(@user, @application) token = @service.generate_id_token(@user, @application)
decoded = JWT.decode(token, nil, false).first 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 end
test "should load RSA private key from environment with escaped newlines" do test "should load RSA private key from environment with escaped newlines" do
@@ -168,7 +168,7 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
OidcJwtService.send(:private_key) OidcJwtService.send(:private_key)
end end
assert_match /Invalid OIDC private key format/, error.message assert_match(/Invalid OIDC private key format/, error.message)
ensure ensure
# Restore original value and clear cached key # Restore original value and clear cached key
ENV["OIDC_PRIVATE_KEY"] = original_value ENV["OIDC_PRIVATE_KEY"] = original_value
@@ -193,7 +193,7 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
OidcJwtService.send(:private_key) OidcJwtService.send(:private_key)
end end
assert_match /OIDC private key not configured/, error.message assert_match(/OIDC private key not configured/, error.message)
ensure ensure
# Restore original environment and clear cached key # Restore original environment and clear cached key
ENV["OIDC_PRIVATE_KEY"] = original_value if original_value 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" assert_not_nil decoded_array, "Should decode valid token"
decoded = decoded_array.first # JWT.decode returns an array decoded = decoded_array.first # JWT.decode returns an array
assert_equal @user.id.to_s, decoded['sub'], "Should decode subject correctly" assert_equal @user.id.to_s, decoded["sub"], "Should decode subject correctly"
assert_equal @application.client_id, decoded['aud'], "Should decode audience 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 decoded["exp"] > Time.current.to_i, "Token should not be expired"
end end
test "should reject invalid id tokens" do test "should reject invalid id tokens" do
@@ -252,9 +252,9 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
decoded = JWT.decode(token, nil, false).first decoded = JWT.decode(token, nil, false).first
# ID tokens always include email_verified # ID tokens always include email_verified
assert_includes decoded.keys, 'email_verified' assert_includes decoded.keys, "email_verified"
assert_equal @user.id.to_s, decoded['sub'], "Should decode subject correctly" assert_equal @user.id.to_s, decoded["sub"], "Should decode subject correctly"
assert_equal @application.client_id, decoded['aud'], "Should decode audience correctly" assert_equal @application.client_id, decoded["aud"], "Should decode audience correctly"
end end
test "should validate JWT configuration" do test "should validate JWT configuration" do
@@ -275,7 +275,7 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
ApplicationUserClaim.create!( ApplicationUserClaim.create!(
user: user, user: user,
application: app, 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) token = @service.generate_id_token(user, app)
@@ -292,17 +292,17 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
# Add user to group with claims # Add user to group with claims
group = groups(:admin_group) 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 user.groups << group
# Add user custom claims # 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) # Add app-specific claims (should override both)
ApplicationUserClaim.create!( ApplicationUserClaim.create!(
user: user, user: user,
application: app, application: app,
custom_claims: { "role": "admin", "app_specific": true } custom_claims: {role: "admin", app_specific: true}
) )
token = @service.generate_id_token(user, app) token = @service.generate_id_token(user, app)

View File

@@ -13,7 +13,7 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
# End-to-End Authentication Flow Tests # End-to-End Authentication Flow Tests
test "complete forward auth flow with default headers" do test "complete forward auth flow with default headers" do
# Create an application with default headers # 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 # Step 1: Unauthenticated request to protected resource
get "/api/verify", headers: { get "/api/verify", headers: {
@@ -46,14 +46,14 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
test "multiple domain access with single session" do test "multiple domain access with single session" do
# Create applications for different domains # 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) 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: "Grafana", slug: "grafana-system-test", app_type: "forward_auth", name: "Grafana", slug: "grafana-system-test", app_type: "forward_auth",
domain_pattern: "grafana.example.com", domain_pattern: "grafana.example.com",
active: true, active: true,
headers_config: {user: "X-WEBAUTH-USER", email: "X-WEBAUTH-EMAIL"} 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", name: "Metube", slug: "metube-system-test", app_type: "forward_auth",
domain_pattern: "metube.example.com", domain_pattern: "metube.example.com",
active: true, active: true,
@@ -126,7 +126,7 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
test "bypass mode when no groups assigned to rule" do test "bypass mode when no groups assigned to rule" do
# Create bypass application (no groups) # Create bypass application (no groups)
bypass_rule = Application.create!( Application.create!(
name: "Public", slug: "public-system-test", app_type: "forward_auth", name: "Public", slug: "public-system-test", app_type: "forward_auth",
domain_pattern: "public.example.com", domain_pattern: "public.example.com",
active: true active: true
@@ -260,7 +260,7 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
] ]
# Create applications for each app # Create applications for each app
rules = apps.map.with_index do |app, idx| apps.map.with_index do |app, idx|
rule = Application.create!( rule = Application.create!(
name: "Multi App #{idx}", slug: "multi-app-#{idx}", app_type: "forward_auth", name: "Multi App #{idx}", slug: "multi-app-#{idx}", app_type: "forward_auth",
domain_pattern: app[:domain], domain_pattern: app[:domain],
@@ -306,7 +306,7 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
] ]
patterns.each_with_index do |pattern_config, idx| 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", name: "Pattern Test #{idx}", slug: "pattern-test-#{idx}", app_type: "forward_auth",
domain_pattern: pattern_config[:pattern], domain_pattern: pattern_config[:pattern],
active: true active: true
@@ -330,7 +330,7 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
# Performance System Tests # Performance System Tests
test "system performance under load" do test "system performance under load" do
# Create test application # 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 # Sign in
post "/signin", params: {email_address: @user.email_address, password: "password"} 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 = User.create!(email_address: "webauthn_handle_auth_test@example.com", password: "password123")
user_handle = SecureRandom.uuid user_handle = SecureRandom.uuid
credential = user.webauthn_credentials.create!( user.webauthn_credentials.create!(
external_id: Base64.urlsafe_encode64("fake_credential_id"), external_id: Base64.urlsafe_encode64("fake_credential_id"),
public_key: Base64.urlsafe_encode64("fake_public_key"), public_key: Base64.urlsafe_encode64("fake_public_key"),
sign_count: 0, sign_count: 0,
@@ -99,7 +99,7 @@ class WebauthnSecurityTest < ActionDispatch::SystemTestCase
test "WebAuthn request validates origin" do test "WebAuthn request validates origin" do
user = User.create!(email_address: "webauthn_origin_test@example.com", password: "password123") 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"), external_id: Base64.urlsafe_encode64("fake_credential_id"),
public_key: Base64.urlsafe_encode64("fake_public_key"), public_key: Base64.urlsafe_encode64("fake_public_key"),
sign_count: 0, sign_count: 0,
@@ -108,13 +108,13 @@ class WebauthnSecurityTest < ActionDispatch::SystemTestCase
# Test WebAuthn challenge from valid origin # Test WebAuthn challenge from valid origin
post webauthn_challenge_path, params: {email: "webauthn_origin_test@example.com"}, 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 # Should succeed for valid origin
# Test WebAuthn challenge from invalid origin # Test WebAuthn challenge from invalid origin
post webauthn_challenge_path, params: {email: "webauthn_origin_test@example.com"}, 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 # 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 = User.create!(email_address: "webauthn_verify_origin_test@example.com", password: "password123")
user.update!(webauthn_id: SecureRandom.uuid) user.update!(webauthn_id: SecureRandom.uuid)
credential = user.webauthn_credentials.create!( user.webauthn_credentials.create!(
external_id: Base64.urlsafe_encode64("fake_credential_id"), external_id: Base64.urlsafe_encode64("fake_credential_id"),
public_key: Base64.urlsafe_encode64("fake_public_key"), public_key: Base64.urlsafe_encode64("fake_public_key"),
sign_count: 0, sign_count: 0,
@@ -136,7 +136,7 @@ class WebauthnSecurityTest < ActionDispatch::SystemTestCase
post webauthn_challenge_path, params: {email: "webauthn_verify_origin_test@example.com"} post webauthn_challenge_path, params: {email: "webauthn_verify_origin_test@example.com"}
assert_response :success assert_response :success
challenge = JSON.parse(@response.body)["challenge"] JSON.parse(@response.body)["challenge"]
# Simulate WebAuthn verification with wrong origin # Simulate WebAuthn verification with wrong origin
# This should fail # This should fail
@@ -155,7 +155,7 @@ class WebauthnSecurityTest < ActionDispatch::SystemTestCase
# Standard attestation formats: none, packed, tpm, android-key, android-safetynet, fido-u2f, etc. # Standard attestation formats: none, packed, tpm, android-key, android-safetynet, fido-u2f, etc.
# Test with 'none' attestation (most common for privacy) # Test with 'none' attestation (most common for privacy)
attestation_object = { {
fmt: "none", fmt: "none",
attStmt: {}, attStmt: {},
authData: Base64.strict_encode64("fake_auth_data") 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") user = User.create!(email_address: "webauthn_invalid_attestation_test@example.com", password: "password123")
# Try to register with invalid attestation format # Try to register with invalid attestation format
invalid_attestation = { {
fmt: "invalid_format", fmt: "invalid_format",
attStmt: {}, attStmt: {},
authData: Base64.strict_encode64("fake_auth_data") authData: Base64.strict_encode64("fake_auth_data")
@@ -263,7 +263,7 @@ class WebauthnSecurityTest < ActionDispatch::SystemTestCase
test "WebAuthn requires user presence for authentication" do test "WebAuthn requires user presence for authentication" do
user = User.create!(email_address: "webauthn_presence_test@example.com", password: "password123") 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"), external_id: Base64.urlsafe_encode64("fake_credential_id"),
public_key: Base64.urlsafe_encode64("fake_public_key"), public_key: Base64.urlsafe_encode64("fake_public_key"),
sign_count: 0, sign_count: 0,
@@ -291,7 +291,7 @@ class WebauthnSecurityTest < ActionDispatch::SystemTestCase
nickname: "USB Key" nickname: "USB Key"
) )
credential2 = user.webauthn_credentials.create!( user.webauthn_credentials.create!(
external_id: Base64.urlsafe_encode64("credential_2"), external_id: Base64.urlsafe_encode64("credential_2"),
public_key: Base64.urlsafe_encode64("public_key_2"), public_key: Base64.urlsafe_encode64("public_key_2"),
sign_count: 0, sign_count: 0,
@@ -329,7 +329,7 @@ class WebauthnSecurityTest < ActionDispatch::SystemTestCase
user = User.create!(email_address: "webauthn_passwordless_test@example.com", password: "password123") user = User.create!(email_address: "webauthn_passwordless_test@example.com", password: "password123")
user.update!(webauthn_enabled: true) user.update!(webauthn_enabled: true)
credential = user.webauthn_credentials.create!( user.webauthn_credentials.create!(
external_id: Base64.urlsafe_encode64("passwordless_credential"), external_id: Base64.urlsafe_encode64("passwordless_credential"),
public_key: Base64.urlsafe_encode64("public_key"), public_key: Base64.urlsafe_encode64("public_key"),
sign_count: 0, sign_count: 0,