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
flash[:notice] = "Application created successfully."
if @application.oidc? if @application.oidc?
flash[:notice] = "Application created successfully."
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,22 +81,26 @@ 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
case key app.headers_for_user(user)
when :user, :email, :name else
[header_name, user.email_address] Application::DEFAULT_HEADERS.map { |key, header_name|
when :groups case key
user.groups.any? ? [header_name, user.groups.pluck(:name).join(",")] : nil when :user, :email, :name
when :admin [header_name, user.email_address]
[header_name, user.admin? ? "true" : "false"] when :groups
end user.groups.any? ? [header_name, user.groups.pluck(:name).join(",")] : nil
}.compact.to_h when :admin
[header_name, user.admin? ? "true" : "false"]
end
}.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
@@ -123,14 +127,13 @@ module Api
# Delete the token immediately (one-time use) # Delete the token immediately (one-time use)
Rails.cache.delete("forward_auth_token:#{token}") Rails.cache.delete("forward_auth_token:#{token}")
session_id session_id
end end
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,133 +17,137 @@ module Authentication
end end
private private
def authenticated?
resume_session def authenticated?
resume_session
end
def require_authentication
resume_session || request_authentication
end
def resume_session
Current.session ||= find_session_by_cookie
end
def find_session_by_cookie
Session.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id]
end
def request_authentication
session[:return_to_after_authenticating] = request.url
redirect_to signin_path
end
def after_authentication_url
session[:return_to_after_authenticating]
session.delete(:return_to_after_authenticating) || root_url
end
def start_new_session_for(user, acr: "1")
user.update!(last_sign_in_at: Time.current)
user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip, acr: acr).tap do |session|
Current.session = session
# Extract root domain for cross-subdomain cookies (required for forward auth)
domain = extract_root_domain(request.host)
cookie_options = {
value: session.id,
httponly: true,
same_site: :lax,
secure: Rails.env.production?
}
# Set domain for cross-subdomain authentication if we can extract it
cookie_options[:domain] = domain if domain.present?
cookies.signed.permanent[:session_id] = cookie_options
# Create a one-time token for immediate forward auth after authentication
# This solves the race condition where browser hasn't processed cookie yet
create_forward_auth_token(session)
end
end
def terminate_session
Current.session.destroy
cookies.delete(:session_id)
end
# Extract root domain for cross-subdomain cookies in SSO forward_auth system.
#
# PURPOSE: Enables a single authentication session to work across multiple subdomains
# by setting cookies with the domain parameter (e.g., .example.com allows access from
# both app.example.com and api.example.com).
#
# CRITICAL: Returns nil for IP addresses (IPv4 and IPv6) and localhost - this is intentional!
# When accessing services by IP, there are no subdomains to share cookies with,
# and setting a domain cookie would break authentication.
#
# Uses the Public Suffix List (industry standard maintained by Mozilla) to
# correctly handle complex domain patterns like co.uk, com.au, appspot.com, etc.
#
# Examples:
# - app.example.com -> .example.com (enables cross-subdomain SSO)
# - api.example.co.uk -> .example.co.uk (handles complex TLDs)
# - myapp.appspot.com -> .myapp.appspot.com (handles platform domains)
# - localhost -> nil (local development, no domain cookie)
# - 192.168.1.1 -> nil (IP access, no domain cookie - prevents SSO breakage)
#
# @param host [String] The request host (may include port)
# @return [String, nil] Root domain with leading dot for cookies, or nil for no domain setting
def extract_root_domain(host)
return nil if host.blank? || host.match?(/^(localhost|127\.0\.0\.1|::1)$/)
# Strip port number for domain parsing
host_without_port = host.split(":").first
# Check if it's an IP address (IPv4 or IPv6) - if so, don't set domain cookie
begin
return nil if IPAddr.new(host_without_port)
rescue
false
end end
def require_authentication # Use Public Suffix List for accurate domain parsing
resume_session || request_authentication domain = PublicSuffix.parse(host_without_port)
end ".#{domain.domain}"
rescue PublicSuffix::DomainInvalid
# Fallback for invalid domains or IPs
nil
end
def resume_session # Create a one-time token for forward auth to handle the race condition
Current.session ||= find_session_by_cookie # where the browser hasn't processed the session cookie yet
end def create_forward_auth_token(session_obj)
# Generate a secure random token
token = SecureRandom.urlsafe_base64(32)
def find_session_by_cookie # Store it with an expiry of 60 seconds
Session.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id] Rails.cache.write(
end "forward_auth_token:#{token}",
session_obj.id,
expires_in: 60.seconds
)
def request_authentication # Set the token as a query parameter on the redirect URL
session[:return_to_after_authenticating] = request.url # We need to store this in the controller's session
redirect_to signin_path controller_session = session
end if controller_session[:return_to_after_authenticating].present?
original_url = controller_session[:return_to_after_authenticating]
uri = URI.parse(original_url)
def after_authentication_url # Skip adding fa_token for OAuth URLs (OAuth flow should not have forward auth tokens)
return_url = session[:return_to_after_authenticating] unless uri.path&.start_with?("/oauth/")
final_url = session.delete(:return_to_after_authenticating) || root_url # Add token as query parameter
final_url query_params = URI.decode_www_form(uri.query || "").to_h
end query_params["fa_token"] = token
uri.query = URI.encode_www_form(query_params)
def start_new_session_for(user, acr: "1") # Update the session with the tokenized URL
user.update!(last_sign_in_at: Time.current) controller_session[:return_to_after_authenticating] = uri.to_s
user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip, acr: acr).tap do |session|
Current.session = session
# Extract root domain for cross-subdomain cookies (required for forward auth)
domain = extract_root_domain(request.host)
cookie_options = {
value: session.id,
httponly: true,
same_site: :lax,
secure: Rails.env.production?
}
# Set domain for cross-subdomain authentication if we can extract it
cookie_options[:domain] = domain if domain.present?
cookies.signed.permanent[:session_id] = cookie_options
# Create a one-time token for immediate forward auth after authentication
# This solves the race condition where browser hasn't processed cookie yet
create_forward_auth_token(session)
end
end
def terminate_session
Current.session.destroy
cookies.delete(:session_id)
end
# Extract root domain for cross-subdomain cookies in SSO forward_auth system.
#
# PURPOSE: Enables a single authentication session to work across multiple subdomains
# by setting cookies with the domain parameter (e.g., .example.com allows access from
# both app.example.com and api.example.com).
#
# CRITICAL: Returns nil for IP addresses (IPv4 and IPv6) and localhost - this is intentional!
# When accessing services by IP, there are no subdomains to share cookies with,
# and setting a domain cookie would break authentication.
#
# Uses the Public Suffix List (industry standard maintained by Mozilla) to
# correctly handle complex domain patterns like co.uk, com.au, appspot.com, etc.
#
# Examples:
# - app.example.com -> .example.com (enables cross-subdomain SSO)
# - api.example.co.uk -> .example.co.uk (handles complex TLDs)
# - myapp.appspot.com -> .myapp.appspot.com (handles platform domains)
# - localhost -> nil (local development, no domain cookie)
# - 192.168.1.1 -> nil (IP access, no domain cookie - prevents SSO breakage)
#
# @param host [String] The request host (may include port)
# @return [String, nil] Root domain with leading dot for cookies, or nil for no domain setting
def extract_root_domain(host)
return nil if host.blank? || host.match?(/^(localhost|127\.0\.0\.1|::1)$/)
# Strip port number for domain parsing
host_without_port = host.split(':').first
# 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
# Use Public Suffix List for accurate domain parsing
domain = PublicSuffix.parse(host_without_port)
".#{domain.domain}"
rescue PublicSuffix::DomainInvalid
# Fallback for invalid domains or IPs
nil
end
# Create a one-time token for forward auth to handle the race condition
# where the browser hasn't processed the session cookie yet
def create_forward_auth_token(session_obj)
# Generate a secure random token
token = SecureRandom.urlsafe_base64(32)
# Store it with an expiry of 60 seconds
Rails.cache.write(
"forward_auth_token:#{token}",
session_obj.id,
expires_in: 60.seconds
)
# Set the token as a query parameter on the redirect URL
# We need to store this in the controller's session
controller_session = session
if controller_session[:return_to_after_authenticating].present?
original_url = controller_session[:return_to_after_authenticating]
uri = URI.parse(original_url)
# Skip adding fa_token for OAuth URLs (OAuth flow should not have forward auth tokens)
unless uri.path&.start_with?("/oauth/")
# Add token as query parameter
query_params = URI.decode_www_form(uri.query || "").to_h
query_params['fa_token'] = token
uri.query = URI.encode_www_form(query_params)
# Update the session with the tokenized URL
controller_session[:return_to_after_authenticating] = uri.to_s
end
end end
end end
end
end end

View File

@@ -1,7 +1,8 @@
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]
def show def show
# Show the password setup form # Show the password setup form
@@ -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

@@ -5,7 +5,7 @@ class OidcController < ApplicationController
# Rate limiting to prevent brute force and abuse # Rate limiting to prevent brute force and abuse
rate_limit to: 60, within: 1.minute, only: [:token, :revoke], with: -> { rate_limit to: 60, within: 1.minute, only: [:token, :revoke], with: -> {
render json: { error: "too_many_requests", error_description: "Rate limit exceeded. Try again later." }, status: :too_many_requests render json: {error: "too_many_requests", error_description: "Rate limit exceeded. Try again later."}, status: :too_many_requests
} }
rate_limit to: 30, within: 1.minute, only: [:authorize, :consent], with: -> { rate_limit to: 30, within: 1.minute, only: [:authorize, :consent], with: -> {
render plain: "Too many authorization attempts. Try again later.", status: :too_many_requests render plain: "Too many authorization attempts. Try again later.", status: :too_many_requests
@@ -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
@@ -286,7 +286,7 @@ class OidcController < ApplicationController
when "refresh_token" when "refresh_token"
handle_refresh_token_grant handle_refresh_token_grant
else else
render json: { error: "unsupported_grant_type" }, status: :bad_request render json: {error: "unsupported_grant_type"}, status: :bad_request
end end
end end
@@ -295,14 +295,14 @@ class OidcController < ApplicationController
client_id, client_secret = extract_client_credentials client_id, client_secret = extract_client_credentials
unless client_id unless client_id
render json: { error: "invalid_client", error_description: "client_id is required" }, status: :unauthorized render json: {error: "invalid_client", error_description: "client_id is required"}, status: :unauthorized
return return
end end
# Find the application # Find the application
application = Application.find_by(client_id: client_id) application = Application.find_by(client_id: client_id)
unless application unless application
render json: { error: "invalid_client", error_description: "Unknown client" }, status: :unauthorized render json: {error: "invalid_client", error_description: "Unknown client"}, status: :unauthorized
return return
end end
@@ -313,7 +313,7 @@ class OidcController < ApplicationController
else else
# Confidential clients MUST provide valid client_secret # Confidential clients MUST provide valid client_secret
unless client_secret.present? && application.authenticate_client_secret(client_secret) unless client_secret.present? && application.authenticate_client_secret(client_secret)
render json: { error: "invalid_client", error_description: "Invalid client credentials" }, status: :unauthorized render json: {error: "invalid_client", error_description: "Invalid client credentials"}, status: :unauthorized
return return
end end
end end
@@ -321,7 +321,7 @@ class OidcController < ApplicationController
# Check if application is active # Check if application is active
unless application.active? unless application.active?
Rails.logger.error "OAuth: Token request for inactive application: #{application.name}" Rails.logger.error "OAuth: Token request for inactive application: #{application.name}"
render json: { error: "invalid_client", error_description: "Application is not active" }, status: :forbidden render json: {error: "invalid_client", error_description: "Application is not active"}, status: :forbidden
return return
end end
@@ -334,7 +334,7 @@ class OidcController < ApplicationController
auth_code = OidcAuthorizationCode.find_by_plaintext(code) auth_code = OidcAuthorizationCode.find_by_plaintext(code)
unless auth_code && auth_code.application == application unless auth_code && auth_code.application == application
render json: { error: "invalid_grant" }, status: :bad_request render json: {error: "invalid_grant"}, status: :bad_request
return return
end end
@@ -365,13 +365,13 @@ class OidcController < ApplicationController
# Check if code is expired # Check if code is expired
if auth_code.expires_at < Time.current if auth_code.expires_at < Time.current
render json: { error: "invalid_grant", error_description: "Authorization code expired" }, status: :bad_request render json: {error: "invalid_grant", error_description: "Authorization code expired"}, status: :bad_request
return return
end end
# Validate redirect URI matches # Validate redirect URI matches
unless auth_code.redirect_uri == redirect_uri unless auth_code.redirect_uri == redirect_uri
render json: { error: "invalid_grant", error_description: "Redirect URI mismatch" }, status: :bad_request render json: {error: "invalid_grant", error_description: "Redirect URI mismatch"}, status: :bad_request
return return
end end
@@ -413,7 +413,7 @@ class OidcController < ApplicationController
unless consent unless consent
Rails.logger.error "OIDC Security: Token requested without consent record (user: #{user.id}, app: #{application.id})" Rails.logger.error "OIDC Security: Token requested without consent record (user: #{user.id}, app: #{application.id})"
render json: { error: "invalid_grant", error_description: "Authorization consent not found" }, status: :bad_request render json: {error: "invalid_grant", error_description: "Authorization consent not found"}, status: :bad_request
return return
end end
@@ -440,7 +440,7 @@ class OidcController < ApplicationController
} }
end end
rescue ActiveRecord::RecordNotFound rescue ActiveRecord::RecordNotFound
render json: { error: "invalid_grant" }, status: :bad_request render json: {error: "invalid_grant"}, status: :bad_request
end end
end end
@@ -449,14 +449,14 @@ class OidcController < ApplicationController
client_id, client_secret = extract_client_credentials client_id, client_secret = extract_client_credentials
unless client_id unless client_id
render json: { error: "invalid_client", error_description: "client_id is required" }, status: :unauthorized render json: {error: "invalid_client", error_description: "client_id is required"}, status: :unauthorized
return return
end end
# Find the application # Find the application
application = Application.find_by(client_id: client_id) application = Application.find_by(client_id: client_id)
unless application unless application
render json: { error: "invalid_client", error_description: "Unknown client" }, status: :unauthorized render json: {error: "invalid_client", error_description: "Unknown client"}, status: :unauthorized
return return
end end
@@ -467,7 +467,7 @@ class OidcController < ApplicationController
else else
# Confidential clients MUST provide valid client_secret # Confidential clients MUST provide valid client_secret
unless client_secret.present? && application.authenticate_client_secret(client_secret) unless client_secret.present? && application.authenticate_client_secret(client_secret)
render json: { error: "invalid_client", error_description: "Invalid client credentials" }, status: :unauthorized render json: {error: "invalid_client", error_description: "Invalid client credentials"}, status: :unauthorized
return return
end end
end end
@@ -475,14 +475,14 @@ class OidcController < ApplicationController
# Check if application is active # Check if application is active
unless application.active? unless application.active?
Rails.logger.error "OAuth: Refresh token request for inactive application: #{application.name}" Rails.logger.error "OAuth: Refresh token request for inactive application: #{application.name}"
render json: { error: "invalid_client", error_description: "Application is not active" }, status: :forbidden render json: {error: "invalid_client", error_description: "Application is not active"}, status: :forbidden
return return
end end
# Get the refresh token # Get the refresh token
refresh_token = params[:refresh_token] refresh_token = params[:refresh_token]
unless refresh_token.present? unless refresh_token.present?
render json: { error: "invalid_request", error_description: "refresh_token is required" }, status: :bad_request render json: {error: "invalid_request", error_description: "refresh_token is required"}, status: :bad_request
return return
end end
@@ -491,13 +491,13 @@ class OidcController < ApplicationController
# Verify the token belongs to the correct application # Verify the token belongs to the correct application
unless refresh_token_record && refresh_token_record.application == application unless refresh_token_record && refresh_token_record.application == application
render json: { error: "invalid_grant", error_description: "Invalid refresh token" }, status: :bad_request render json: {error: "invalid_grant", error_description: "Invalid refresh token"}, status: :bad_request
return return
end end
# Check if refresh token is expired # Check if refresh token is expired
if refresh_token_record.expired? if refresh_token_record.expired?
render json: { error: "invalid_grant", error_description: "Refresh token expired" }, status: :bad_request render json: {error: "invalid_grant", error_description: "Refresh token expired"}, status: :bad_request
return return
end end
@@ -508,7 +508,7 @@ class OidcController < ApplicationController
Rails.logger.warn "OAuth Security: Revoked refresh token reuse detected for token family #{refresh_token_record.token_family_id}" Rails.logger.warn "OAuth Security: Revoked refresh token reuse detected for token family #{refresh_token_record.token_family_id}"
refresh_token_record.revoke_family! refresh_token_record.revoke_family!
render json: { error: "invalid_grant", error_description: "Refresh token has been revoked" }, status: :bad_request render json: {error: "invalid_grant", error_description: "Refresh token has been revoked"}, status: :bad_request
return return
end end
@@ -541,7 +541,7 @@ class OidcController < ApplicationController
unless consent unless consent
Rails.logger.error "OIDC Security: Refresh token used without consent record (user: #{user.id}, app: #{application.id})" Rails.logger.error "OIDC Security: Refresh token used without consent record (user: #{user.id}, app: #{application.id})"
render json: { error: "invalid_grant", error_description: "Authorization consent not found" }, status: :bad_request render json: {error: "invalid_grant", error_description: "Authorization consent not found"}, status: :bad_request
return return
end end
@@ -566,7 +566,7 @@ class OidcController < ApplicationController
scope: refresh_token_record.scope scope: refresh_token_record.scope
} }
rescue ActiveRecord::RecordNotFound rescue ActiveRecord::RecordNotFound
render json: { error: "invalid_grant" }, status: :bad_request render json: {error: "invalid_grant"}, status: :bad_request
end end
# GET /oauth/userinfo # GET /oauth/userinfo
@@ -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
@@ -669,7 +669,7 @@ class OidcController < ApplicationController
unless token.present? unless token.present?
# RFC 7009: Missing token parameter is an error # RFC 7009: Missing token parameter is an error
render json: { error: "invalid_request", error_description: "token parameter is required" }, status: :bad_request render json: {error: "invalid_request", error_description: "token parameter is required"}, status: :bad_request
return return
end end
@@ -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]
@@ -763,7 +763,7 @@ class OidcController < ApplicationController
end end
# Skip validation if no code challenge was stored (legacy clients without PKCE requirement) # Skip validation if no code challenge was stored (legacy clients without PKCE requirement)
return { valid: true } unless pkce_provided return {valid: true} unless pkce_provided
# PKCE was provided during authorization but no verifier sent with token request # PKCE was provided during authorization but no verifier sent with token request
unless code_verifier.present? unless code_verifier.present?
@@ -787,18 +787,18 @@ class OidcController < ApplicationController
# Recreate code challenge based on method # Recreate code challenge based on method
expected_challenge = case auth_code.code_challenge_method expected_challenge = case auth_code.code_challenge_method
when "plain" when "plain"
code_verifier code_verifier
when "S256" when "S256"
Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false) Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false)
else else
return { return {
valid: false, valid: false,
error: "server_error", error: "server_error",
error_description: "Unsupported code challenge method", error_description: "Unsupported code challenge method",
status: :internal_server_error status: :internal_server_error
} }
end end
# Validate the code challenge # Validate the code challenge
unless auth_code.code_challenge == expected_challenge unless auth_code.code_challenge == expected_challenge
@@ -810,7 +810,7 @@ class OidcController < ApplicationController
} }
end end
{ valid: true } {valid: true}
end end
def extract_client_credentials def extract_client_credentials
@@ -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

@@ -1,22 +1,22 @@
class SessionsController < ApplicationController class SessionsController < ApplicationController
allow_unauthenticated_access only: %i[ new create verify_totp webauthn_challenge webauthn_verify ] allow_unauthenticated_access only: %i[new create verify_totp webauthn_challenge webauthn_verify]
rate_limit to: 20, within: 3.minutes, only: :create, with: -> { redirect_to signin_path, alert: "Too many attempts. Try again later." } rate_limit to: 20, within: 3.minutes, only: :create, with: -> { redirect_to signin_path, alert: "Too many attempts. Try again later." }
rate_limit to: 10, within: 3.minutes, only: :verify_totp, with: -> { redirect_to totp_verification_path, alert: "Too many attempts. Try again later." } rate_limit to: 10, within: 3.minutes, only: :verify_totp, with: -> { redirect_to totp_verification_path, alert: "Too many attempts. Try again later." }
rate_limit to: 10, within: 3.minutes, only: [:webauthn_challenge, :webauthn_verify], with: -> { render json: { error: "Too many attempts. Try again later." }, status: :too_many_requests } rate_limit to: 10, within: 3.minutes, only: [:webauthn_challenge, :webauthn_verify], with: -> { render json: {error: "Too many attempts. Try again later."}, status: :too_many_requests }
def new def new
# Redirect to signup if this is first run # Redirect to signup if this is first run
if User.count.zero? if User.count.zero?
respond_to do |format| respond_to do |format|
format.html { redirect_to signup_path } format.html { redirect_to signup_path }
format.json { render json: { error: "No users exist. Please complete initial setup." }, status: :service_unavailable } format.json { render json: {error: "No users exist. Please complete initial setup."}, status: :service_unavailable }
end end
return return
end end
respond_to do |format| respond_to do |format|
format.html # render HTML login page format.html # render HTML login page
format.json { render json: { error: "Authentication required" }, status: :unauthorized } format.json { render json: {error: "Authentication required"}, status: :unauthorized }
end end
end end
@@ -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
@@ -155,14 +155,14 @@ class SessionsController < ApplicationController
email = params[:email]&.strip&.downcase email = params[:email]&.strip&.downcase
if email.blank? if email.blank?
render json: { error: "Email is required" }, status: :unprocessable_entity render json: {error: "Email is required"}, status: :unprocessable_entity
return return
end end
user = User.find_by(email_address: email) user = User.find_by(email_address: email)
if user.nil? || !user.can_authenticate_with_webauthn? if user.nil? || !user.can_authenticate_with_webauthn?
render json: { error: "User not found or WebAuthn not available" }, status: :unprocessable_entity render json: {error: "User not found or WebAuthn not available"}, status: :unprocessable_entity
return return
end end
@@ -191,10 +191,9 @@ 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
end end
end end
@@ -202,21 +201,21 @@ class SessionsController < ApplicationController
# Get pending user from session # Get pending user from session
user_id = session[:pending_webauthn_user_id] user_id = session[:pending_webauthn_user_id]
unless user_id unless user_id
render json: { error: "Session expired. Please try again." }, status: :unprocessable_entity render json: {error: "Session expired. Please try again."}, status: :unprocessable_entity
return return
end end
user = User.find_by(id: user_id) user = User.find_by(id: user_id)
unless user unless user
session.delete(:pending_webauthn_user_id) session.delete(:pending_webauthn_user_id)
render json: { error: "Session expired. Please try again." }, status: :unprocessable_entity render json: {error: "Session expired. Please try again."}, status: :unprocessable_entity
return return
end end
# Get the credential and assertion from params # Get the credential and assertion from params
credential_data = params[:credential] credential_data = params[:credential]
if credential_data.blank? if credential_data.blank?
render json: { error: "Credential data is required" }, status: :unprocessable_entity render json: {error: "Credential data is required"}, status: :unprocessable_entity
return return
end end
@@ -224,7 +223,7 @@ class SessionsController < ApplicationController
challenge = session.delete(:webauthn_challenge) challenge = session.delete(:webauthn_challenge)
if challenge.blank? if challenge.blank?
render json: { error: "Invalid or expired session" }, status: :unprocessable_entity render json: {error: "Invalid or expired session"}, status: :unprocessable_entity
return return
end end
@@ -237,7 +236,7 @@ class SessionsController < ApplicationController
stored_credential = user.webauthn_credential_for(external_id) stored_credential = user.webauthn_credential_for(external_id)
if stored_credential.nil? if stored_credential.nil?
render json: { error: "Credential not found" }, status: :unprocessable_entity render json: {error: "Credential not found"}, status: :unprocessable_entity
return return
end end
@@ -276,16 +275,15 @@ 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
rescue JSON::ParserError => e rescue JSON::ParserError => e
Rails.logger.error "WebAuthn JSON parsing error: #{e.message}" Rails.logger.error "WebAuthn JSON parsing error: #{e.message}"
render json: { error: "Invalid credential format" }, status: :unprocessable_entity render json: {error: "Invalid credential format"}, status: :unprocessable_entity
rescue => e rescue => e
Rails.logger.error "Unexpected WebAuthn verification error: #{e.class} - #{e.message}" Rails.logger.error "Unexpected WebAuthn verification error: #{e.class} - #{e.message}"
render json: { error: "An unexpected error occurred" }, status: :internal_server_error render json: {error: "An unexpected error occurred"}, status: :internal_server_error
end end
end end
@@ -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

@@ -1,6 +1,6 @@
class UsersController < ApplicationController class UsersController < ApplicationController
allow_unauthenticated_access only: %i[ new create ] allow_unauthenticated_access only: %i[new create]
before_action :ensure_first_run, only: %i[ new create ] before_action :ensure_first_run, only: %i[new create]
def new def new
@user = User.new @user = User.new

View File

@@ -4,7 +4,7 @@ class WebauthnController < ApplicationController
# Rate limit check endpoint to prevent enumeration attacks # Rate limit check endpoint to prevent enumeration attacks
rate_limit to: 10, within: 1.minute, only: [:check], with: -> { rate_limit to: 10, within: 1.minute, only: [:check], with: -> {
render json: { error: "Too many requests. Try again later." }, status: :too_many_requests render json: {error: "Too many requests. Try again later."}, status: :too_many_requests
} }
# GET /webauthn/new # GET /webauthn/new
@@ -16,7 +16,7 @@ class WebauthnController < ApplicationController
# Generate registration challenge for creating a new passkey # Generate registration challenge for creating a new passkey
def challenge def challenge
user = Current.session&.user user = Current.session&.user
return render json: { error: "Not authenticated" }, status: :unauthorized unless user return render json: {error: "Not authenticated"}, status: :unauthorized unless user
registration_options = WebAuthn::Credential.options_for_create( registration_options = WebAuthn::Credential.options_for_create(
user: { user: {
@@ -44,7 +44,7 @@ class WebauthnController < ApplicationController
credential_data, nickname = extract_credential_params credential_data, nickname = extract_credential_params
if credential_data.blank? || nickname.blank? if credential_data.blank? || nickname.blank?
render json: { error: "Credential and nickname are required" }, status: :unprocessable_entity render json: {error: "Credential and nickname are required"}, status: :unprocessable_entity
return return
end end
@@ -52,7 +52,7 @@ class WebauthnController < ApplicationController
challenge = session.delete(:webauthn_challenge) challenge = session.delete(:webauthn_challenge)
if challenge.blank? if challenge.blank?
render json: { error: "Invalid or expired session" }, status: :unprocessable_entity render json: {error: "Invalid or expired session"}, status: :unprocessable_entity
return return
end end
@@ -68,10 +68,10 @@ class WebauthnController < ApplicationController
client_extension_results = response["clientExtensionResults"] || {} client_extension_results = response["clientExtensionResults"] || {}
authenticator_type = if response["response"]["authenticatorAttachment"] == "cross-platform" authenticator_type = if response["response"]["authenticatorAttachment"] == "cross-platform"
"cross-platform" "cross-platform"
else else
"platform" "platform"
end end
# Determine if this is a backup/synced credential # Determine if this is a backup/synced credential
backup_eligible = client_extension_results["credProps"]&.dig("rk") || false backup_eligible = client_extension_results["credProps"]&.dig("rk") || false
@@ -79,7 +79,7 @@ class WebauthnController < ApplicationController
# Store the credential # Store the credential
user = Current.session&.user user = Current.session&.user
return render json: { error: "Not authenticated" }, status: :unauthorized unless user return render json: {error: "Not authenticated"}, status: :unauthorized unless user
@webauthn_credential = user.webauthn_credentials.create!( @webauthn_credential = user.webauthn_credentials.create!(
external_id: Base64.urlsafe_encode64(webauthn_credential.id), external_id: Base64.urlsafe_encode64(webauthn_credential.id),
@@ -96,13 +96,12 @@ 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
rescue => e rescue => e
Rails.logger.error "Unexpected WebAuthn registration error: #{e.class} - #{e.message}" Rails.logger.error "Unexpected WebAuthn registration error: #{e.class} - #{e.message}"
render json: { error: "An unexpected error occurred" }, status: :internal_server_error render json: {error: "An unexpected error occurred"}, status: :internal_server_error
end end
end end
@@ -115,7 +114,7 @@ class WebauthnController < ApplicationController
respond_to do |format| respond_to do |format|
format.html { format.html {
redirect_to profile_path, redirect_to profile_path,
notice: "Passkey '#{nickname}' has been removed" notice: "Passkey '#{nickname}' has been removed"
} }
format.json { format.json {
render json: { render json: {
@@ -133,7 +132,7 @@ class WebauthnController < ApplicationController
email = params[:email]&.strip&.downcase email = params[:email]&.strip&.downcase
if email.blank? if email.blank?
render json: { has_webauthn: false, requires_webauthn: false } render json: {has_webauthn: false, requires_webauthn: false}
return return
end end
@@ -142,7 +141,7 @@ class WebauthnController < ApplicationController
# Security: Return identical response for non-existent users # Security: Return identical response for non-existent users
# Combined with rate limiting (10/min), this prevents account enumeration # Combined with rate limiting (10/min), this prevents account enumeration
if user.nil? if user.nil?
render json: { has_webauthn: false, requires_webauthn: false } render json: {has_webauthn: false, requires_webauthn: false}
return return
end end
@@ -158,37 +157,36 @@ 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)
[credential_params, nickname] [credential_params, nickname]
rescue ActionController::ParameterMissing rescue ActionController::ParameterMissing
Rails.logger.error("Using the fallback parameters") Rails.logger.error("Using the fallback parameters")
# Fallback to webauthn-wrapped parameters # Fallback to webauthn-wrapped parameters
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
return render json: { error: "Not authenticated" }, status: :unauthorized unless user return render json: {error: "Not authenticated"}, status: :unauthorized unless user
@webauthn_credential = user.webauthn_credentials.find(params[:id]) @webauthn_credential = user.webauthn_credentials.find(params[:id])
rescue ActiveRecord::RecordNotFound rescue ActiveRecord::RecordNotFound
respond_to do |format| respond_to do |format|
format.html { redirect_to profile_path, alert: "Passkey not found" } format.html { redirect_to profile_path, alert: "Passkey not found" }
format.json { render json: { error: "Passkey not found" }, status: :not_found } format.json { render json: {error: "Passkey not found"}, status: :not_found }
end end
end end
# 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

@@ -6,10 +6,10 @@ module ApplicationHelper
smtp_port = ENV["SMTP_PORT"] smtp_port = ENV["SMTP_PORT"]
smtp_address.present? && smtp_address.present? &&
smtp_port.present? && smtp_port.present? &&
smtp_address != "localhost" && smtp_address != "localhost" &&
!smtp_address.start_with?("127.0.0.1") && !smtp_address.start_with?("127.0.0.1") &&
!smtp_address.start_with?("localhost") !smtp_address.start_with?("localhost")
end end
def email_delivery_method def email_delivery_method
@@ -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,10 +29,10 @@ 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

@@ -19,16 +19,16 @@ class Application < ApplicationRecord
has_many :oidc_user_consents, dependent: :destroy has_many :oidc_user_consents, dependent: :destroy
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"
} }
@@ -38,9 +38,9 @@ class Application < ApplicationRecord
validate :icon_validation, if: -> { icon.attached? } validate :icon_validation, if: -> { icon.attached? }
# Token TTL validations (for OIDC apps) # Token TTL validations (for OIDC apps)
validates :access_token_ttl, numericality: { greater_than_or_equal_to: 300, less_than_or_equal_to: 86400 }, if: :oidc? # 5 min - 24 hours validates :access_token_ttl, numericality: {greater_than_or_equal_to: 300, less_than_or_equal_to: 86400}, if: :oidc? # 5 min - 24 hours
validates :refresh_token_ttl, numericality: { greater_than_or_equal_to: 86400, less_than_or_equal_to: 7776000 }, if: :oidc? # 1 day - 90 days validates :refresh_token_ttl, numericality: {greater_than_or_equal_to: 86400, less_than_or_equal_to: 7776000}, if: :oidc? # 1 day - 90 days
validates :id_token_ttl, numericality: { greater_than_or_equal_to: 300, less_than_or_equal_to: 86400 }, if: :oidc? # 5 min - 24 hours validates :id_token_ttl, numericality: {greater_than_or_equal_to: 300, less_than_or_equal_to: 86400}, if: :oidc? # 5 min - 24 hours
normalizes :slug, with: ->(slug) { slug.strip.downcase } normalizes :slug, with: ->(slug) { slug.strip.downcase }
normalizes :domain_pattern, with: ->(pattern) { normalizes :domain_pattern, with: ->(pattern) {
@@ -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
@@ -242,7 +242,7 @@ class Application < ApplicationRecord
# (i.e., has valid, non-revoked tokens) # (i.e., has valid, non-revoked tokens)
def user_has_active_session?(user) def user_has_active_session?(user)
oidc_access_tokens.where(user: user).valid.exists? || oidc_access_tokens.where(user: user).valid.exists? ||
oidc_refresh_tokens.where(user: user).valid.exists? oidc_refresh_tokens.where(user: user).valid.exists?
end end
private private
@@ -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

@@ -2,5 +2,5 @@ class ApplicationGroup < ApplicationRecord
belongs_to :application belongs_to :application
belongs_to :group belongs_to :group
validates :application_id, uniqueness: { scope: :group_id } validates :application_id, uniqueness: {scope: :group_id}
end end

View File

@@ -9,7 +9,7 @@ class ApplicationUserClaim < ApplicationRecord
groups groups
].freeze ].freeze
validates :user_id, uniqueness: { scope: :application_id } validates :user_id, uniqueness: {scope: :application_id}
validate :no_reserved_claim_names validate :no_reserved_claim_names
# Parse custom_claims JSON field # Parse custom_claims JSON field
@@ -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

@@ -11,7 +11,7 @@ class Group < ApplicationRecord
groups groups
].freeze ].freeze
validates :name, presence: true, uniqueness: { case_sensitive: false } validates :name, presence: true, uniqueness: {case_sensitive: false}
normalizes :name, with: ->(name) { name.strip.downcase } normalizes :name, with: ->(name) { name.strip.downcase }
validate :no_reserved_claim_names validate :no_reserved_claim_names
@@ -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

@@ -9,7 +9,7 @@ class OidcAuthorizationCode < ApplicationRecord
validates :code_hmac, presence: true, uniqueness: true validates :code_hmac, presence: true, uniqueness: true
validates :redirect_uri, presence: true validates :redirect_uri, presence: true
validates :code_challenge_method, inclusion: { in: %w[plain S256], allow_nil: true } validates :code_challenge_method, inclusion: {in: %w[plain S256], allow_nil: true}
validate :validate_code_challenge_format, if: -> { code_challenge.present? } validate :validate_code_challenge_format, if: -> { code_challenge.present? }
scope :valid, -> { where(used: false).where("expires_at > ?", Time.current) } scope :valid, -> { where(used: false).where("expires_at > ?", Time.current) }
@@ -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

@@ -3,19 +3,19 @@ class OidcUserConsent < ApplicationRecord
belongs_to :application belongs_to :application
validates :user, :application, :scopes_granted, :granted_at, presence: true validates :user, :application, :scopes_granted, :granted_at, presence: true
validates :user_id, uniqueness: { scope: :application_id } validates :user_id, uniqueness: {scope: :application_id}
before_validation :set_granted_at, on: :create before_validation :set_granted_at, on: :create
before_validation :set_sid, on: :create before_validation :set_sid, on: :create
# 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

@@ -29,16 +29,16 @@ class User < ApplicationRecord
groups groups
].freeze ].freeze
validates :email_address, presence: true, uniqueness: { case_sensitive: false }, validates :email_address, presence: true, uniqueness: {case_sensitive: false},
format: { with: URI::MailTo::EMAIL_REGEXP } format: {with: URI::MailTo::EMAIL_REGEXP}
validates :username, uniqueness: { case_sensitive: false }, allow_nil: true, validates :username, uniqueness: {case_sensitive: false}, allow_nil: true,
format: { with: /\A[a-zA-Z0-9_-]+\z/, message: "can only contain letters, numbers, underscores, and hyphens" }, format: {with: /\A[a-zA-Z0-9_-]+\z/, message: "can only contain letters, numbers, underscores, and hyphens"},
length: { minimum: 2, maximum: 30 } length: {minimum: 2, maximum: 30}
validates :password, length: { minimum: 8 }, allow_nil: true validates :password, length: {minimum: 8}, allow_nil: true
validate :no_reserved_claim_names validate :no_reserved_claim_names
# Enum - automatically creates scopes (User.active, User.disabled, etc.) # Enum - automatically creates scopes (User.active, User.disabled, etc.)
enum :status, { active: 0, disabled: 1, pending_invitation: 2 } enum :status, {active: 0, disabled: 1, pending_invitation: 2}
# Scopes # Scopes
scope :admins, -> { where(admin: true) } scope :admins, -> { where(admin: true) }
@@ -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

@@ -2,5 +2,5 @@ class UserGroup < ApplicationRecord
belongs_to :user belongs_to :user
belongs_to :group belongs_to :group
validates :user_id, uniqueness: { scope: :group_id } validates :user_id, uniqueness: {scope: :group_id}
end end

View File

@@ -4,9 +4,9 @@ class WebauthnCredential < ApplicationRecord
# Validations # Validations
validates :external_id, presence: true, uniqueness: true validates :external_id, presence: true, uniqueness: true
validates :public_key, presence: true validates :public_key, presence: true
validates :sign_count, presence: true, numericality: { greater_than_or_equal_to: 0, only_integer: true } validates :sign_count, presence: true, numericality: {greater_than_or_equal_to: 0, only_integer: true}
validates :nickname, presence: true validates :nickname, presence: true
validates :authenticator_type, inclusion: { in: %w[platform cross-platform] } validates :authenticator_type, inclusion: {in: %w[platform cross-platform]}
# Scopes for querying # Scopes for querying
scope :active, -> { where(nil) } # All credentials are active (we can add revoked_at later if needed) scope :active, -> { where(nil) } # All credentials are active (we can add revoked_at later if needed)
@@ -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

@@ -60,7 +60,7 @@ class OidcJwtService
# Merge app-specific custom claims (highest priority, arrays are combined) # Merge app-specific custom claims (highest priority, arrays are combined)
payload = deep_merge_claims(payload, application.custom_claims_for_user(user)) payload = deep_merge_claims(payload, application.custom_claims_for_user(user))
JWT.encode(payload, private_key, "RS256", { kid: key_id, typ: "JWT" }) JWT.encode(payload, private_key, "RS256", {kid: key_id, typ: "JWT"})
end end
# Generate a backchannel logout token (JWT) # Generate a backchannel logout token (JWT)
@@ -84,12 +84,12 @@ class OidcJwtService
} }
# Important: Do NOT include nonce in logout tokens (spec requirement) # Important: Do NOT include nonce in logout tokens (spec requirement)
JWT.encode(payload, private_key, "RS256", { kid: key_id, typ: "JWT" }) JWT.encode(payload, private_key, "RS256", {kid: key_id, typ: "JWT"})
end end
# Decode and verify an ID token # Decode and verify an ID token
def decode_id_token(token) def decode_id_token(token)
JWT.decode(token, public_key, true, { algorithm: "RS256" }) JWT.decode(token, public_key, true, {algorithm: "RS256"})
end end
# Get the public key in JWK format for the JWKS endpoint # Get the public key in JWK format for the JWKS endpoint

View File

@@ -24,16 +24,16 @@ module Clinch
# config.time_zone = "Central Time (US & Canada)" # config.time_zone = "Central Time (US & Canada)"
# config.eager_load_paths << Rails.root.join("extras") # config.eager_load_paths << Rails.root.join("extras")
# 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

@@ -20,7 +20,7 @@ Rails.application.configure do
if Rails.root.join("tmp/caching-dev.txt").exist? if Rails.root.join("tmp/caching-dev.txt").exist?
config.action_controller.perform_caching = true config.action_controller.perform_caching = true
config.action_controller.enable_fragment_cache_logging = true config.action_controller.enable_fragment_cache_logging = true
config.public_file_server.headers = { "cache-control" => "public, max-age=#{2.days.to_i}" } config.public_file_server.headers = {"cache-control" => "public, max-age=#{2.days.to_i}"}
else else
config.action_controller.perform_caching = false config.action_controller.perform_caching = false
end end
@@ -39,10 +39,10 @@ Rails.application.configure do
config.action_mailer.perform_caching = false config.action_mailer.perform_caching = false
# Set localhost to be used by links generated in mailer templates. # Set localhost to be used by links generated in mailer templates.
config.action_mailer.default_url_options = { host: "localhost", port: 3000 } config.action_mailer.default_url_options = {host: "localhost", port: 3000}
# Log with request_id as a tag (same as production). # Log with request_id as a tag (same as production).
config.log_tags = [ :request_id ] config.log_tags = [:request_id]
# Print deprecation notices to the Rails logger. # Print deprecation notices to the Rails logger.
config.active_support.deprecation = :log config.active_support.deprecation = :log
@@ -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

@@ -16,7 +16,7 @@ Rails.application.configure do
config.action_controller.perform_caching = true config.action_controller.perform_caching = true
# Cache assets for far-future expiry since they are all digest stamped. # Cache assets for far-future expiry since they are all digest stamped.
config.public_file_server.headers = { "cache-control" => "public, max-age=#{1.year.to_i}" } config.public_file_server.headers = {"cache-control" => "public, max-age=#{1.year.to_i}"}
# Enable serving of images, stylesheets, and JavaScripts from an asset server. # Enable serving of images, stylesheets, and JavaScripts from an asset server.
# config.asset_host = "http://assets.example.com" # config.asset_host = "http://assets.example.com"
@@ -34,16 +34,16 @@ 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.
# config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } } # config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } }
# 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.
@@ -86,13 +86,13 @@ Rails.application.configure do
config.active_record.dump_schema_after_migration = false config.active_record.dump_schema_after_migration = false
# Only use :id for inspections in production. # Only use :id for inspections in production.
config.active_record.attributes_for_inspect = [ :id ] config.active_record.attributes_for_inspect = [:id]
# Helper method to extract domain from CLINCH_HOST (removes protocol if present) # Helper method to extract domain from CLINCH_HOST (removes protocol if present)
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,14 +147,14 @@ 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
# Skip DNS rebinding protection for the default health check endpoint. # Skip DNS rebinding protection for the default health check endpoint.
config.host_authorization = { exclude: ->(request) { request.path == "/up" } } config.host_authorization = {exclude: ->(request) { request.path == "/up" }}
# Sentry configuration for production # Sentry configuration for production
# Only enabled if SENTRY_DSN environment variable is set # Only enabled if SENTRY_DSN environment variable is set

View File

@@ -16,7 +16,7 @@ Rails.application.configure do
config.eager_load = ENV["CI"].present? config.eager_load = ENV["CI"].present?
# Configure public file server for tests with cache-control for performance. # Configure public file server for tests with cache-control for performance.
config.public_file_server.headers = { "cache-control" => "public, max-age=3600" } config.public_file_server.headers = {"cache-control" => "public, max-age=3600"}
# Show full error reports. # Show full error reports.
config.consider_all_requests_local = true config.consider_all_requests_local = true
@@ -37,7 +37,7 @@ Rails.application.configure do
config.action_mailer.delivery_method = :test config.action_mailer.delivery_method = :test
# 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 = { host: "example.com" } config.action_mailer.default_url_options = {host: "example.com"}
# Print deprecation notices to the stderr. # Print deprecation notices to the stderr.
config.active_support.deprecation = :stderr config.active_support.deprecation = :stderr

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

@@ -56,10 +56,9 @@ Rails.application.configure do
policy.require_trusted_types_for :none policy.require_trusted_types_for :none
# CSP reporting using report_uri (supported method) # CSP reporting using report_uri (supported method)
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
@@ -25,9 +25,9 @@ Rails.application.config.after_initialize do
# Skip logging if there's no meaningful violation data # Skip logging if there's no meaningful violation data
return if csp_data.empty? || return if csp_data.empty? ||
(csp_data[:violated_directive].nil? && (csp_data[:violated_directive].nil? &&
csp_data[:blocked_uri].nil? && csp_data[:blocked_uri].nil? &&
csp_data[:document_uri].nil?) csp_data[:document_uri].nil?)
# Build a structured log message # Build a structured log message
violated_directive = csp_data[:violated_directive] || "unknown" violated_directive = csp_data[:violated_directive] || "unknown"
@@ -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

@@ -3,12 +3,12 @@
Rails.application.config.permissions_policy do |f| Rails.application.config.permissions_policy do |f|
# Disable sensitive browser features for security # Disable sensitive browser features for security
f.camera :none f.camera :none
f.gyroscope :none f.gyroscope :none
f.microphone :none f.microphone :none
f.payment :none f.payment :none
f.usb :none f.usb :none
f.magnetometer :none f.magnetometer :none
# You can enable specific features as needed: # You can enable specific features as needed:
# f.fullscreen :self # f.fullscreen :self

View File

@@ -74,7 +74,7 @@ Rails.application.configure do
app_environment: Rails.env, app_environment: Rails.env,
# Add CSP policy status # Add CSP policy status
csp_enabled: defined?(Rails.application.config.content_security_policy) && csp_enabled: defined?(Rails.application.config.content_security_policy) &&
Rails.application.config.content_security_policy.present? Rails.application.config.content_security_policy.present?
} }
end end
@@ -120,13 +120,13 @@ Rails.application.configure do
if breadcrumb[:data] if breadcrumb[:data]
breadcrumb[:data].reject! { |key, value| breadcrumb[:data].reject! { |key, value|
key.to_s.match?(/password|secret|token|key|authorization/i) || key.to_s.match?(/password|secret|token|key|authorization/i) ||
value.to_s.match?(/password|secret/i) value.to_s.match?(/password|secret/i)
} }
end end
# Mark CSP-related events # Mark CSP-related events
if breadcrumb[:message]&.include?("CSP Violation") || if breadcrumb[:message]&.include?("CSP Violation") ||
breadcrumb[:category]&.include?("csp") breadcrumb[:category]&.include?("csp")
breadcrumb[:data] ||= {} breadcrumb[:data] ||= {}
breadcrumb[:data][:security_event] = true breadcrumb[:data][:security_event] = true
breadcrumb[:data][:csp_violation] = true breadcrumb[:data][:csp_violation] = true

View File

@@ -47,7 +47,7 @@ Rails.application.config.after_initialize do
timestamp: csp_data[:timestamp] timestamp: csp_data[:timestamp]
} }
}, },
user: csp_data[:current_user_id] ? { id: csp_data[:current_user_id] } : nil user: csp_data[:current_user_id] ? {id: csp_data[:current_user_id]} : nil
) )
# Log to Rails logger for redundancy # Log to Rails logger for redundancy
@@ -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

@@ -7,6 +7,6 @@ class CreateUserGroups < ActiveRecord::Migration[8.1]
t.timestamps t.timestamps
end end
add_index :user_groups, [ :user_id, :group_id ], unique: true add_index :user_groups, [:user_id, :group_id], unique: true
end end
end end

View File

@@ -7,6 +7,6 @@ class CreateApplicationGroups < ActiveRecord::Migration[8.1]
t.timestamps t.timestamps
end end
add_index :application_groups, [ :application_id, :group_id ], unique: true add_index :application_groups, [:application_id, :group_id], unique: true
end end
end end

View File

@@ -13,6 +13,6 @@ class CreateOidcAuthorizationCodes < ActiveRecord::Migration[8.1]
end end
add_index :oidc_authorization_codes, :code, unique: true add_index :oidc_authorization_codes, :code, unique: true
add_index :oidc_authorization_codes, :expires_at add_index :oidc_authorization_codes, :expires_at
add_index :oidc_authorization_codes, [ :application_id, :user_id ] add_index :oidc_authorization_codes, [:application_id, :user_id]
end end
end end

View File

@@ -11,6 +11,6 @@ class CreateOidcAccessTokens < ActiveRecord::Migration[8.1]
end end
add_index :oidc_access_tokens, :token, unique: true add_index :oidc_access_tokens, :token, unique: true
add_index :oidc_access_tokens, :expires_at add_index :oidc_access_tokens, :expires_at
add_index :oidc_access_tokens, [ :application_id, :user_id ] add_index :oidc_access_tokens, [:application_id, :user_id]
end end
end end

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

@@ -5,7 +5,7 @@ class CreateWebauthnCredentials < ActiveRecord::Migration[8.1]
t.references :user, null: false, foreign_key: true, index: true t.references :user, null: false, foreign_key: true, index: true
# WebAuthn specification fields # WebAuthn specification fields
t.string :external_id, null: false, index: { unique: true } # credential ID (base64) t.string :external_id, null: false, index: {unique: true} # credential ID (base64)
t.string :public_key, null: false # public key (base64) t.string :public_key, null: false # public key (base64)
t.integer :sign_count, null: false, default: 0 # signature counter (clone detection) t.integer :sign_count, null: false, default: 0 # signature counter (clone detection)

View File

@@ -17,6 +17,6 @@ class CreateOidcRefreshTokens < ActiveRecord::Migration[8.1]
add_index :oidc_refresh_tokens, :expires_at add_index :oidc_refresh_tokens, :expires_at
add_index :oidc_refresh_tokens, :revoked_at add_index :oidc_refresh_tokens, :revoked_at
add_index :oidc_refresh_tokens, :token_family_id add_index :oidc_refresh_tokens, :token_family_id
add_index :oidc_refresh_tokens, [ :application_id, :user_id ] add_index :oidc_refresh_tokens, [:application_id, :user_id]
end end
end end

View File

@@ -1,13 +1,13 @@
class CreateApplicationUserClaims < ActiveRecord::Migration[8.1] class CreateApplicationUserClaims < ActiveRecord::Migration[8.1]
def change def change
create_table :application_user_claims do |t| create_table :application_user_claims do |t|
t.references :application, null: false, foreign_key: { on_delete: :cascade } t.references :application, null: false, foreign_key: {on_delete: :cascade}
t.references :user, null: false, foreign_key: { on_delete: :cascade } t.references :user, null: false, foreign_key: {on_delete: :cascade}
t.json :custom_claims, default: {}, null: false t.json :custom_claims, default: {}, null: false
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

@@ -5,13 +5,13 @@ class CreateActiveStorageTables < ActiveRecord::Migration[7.0]
primary_key_type, foreign_key_type = primary_and_foreign_key_types primary_key_type, foreign_key_type = primary_and_foreign_key_types
create_table :active_storage_blobs, id: primary_key_type do |t| create_table :active_storage_blobs, id: primary_key_type do |t|
t.string :key, null: false t.string :key, null: false
t.string :filename, null: false t.string :filename, null: false
t.string :content_type t.string :content_type
t.text :metadata t.text :metadata
t.string :service_name, null: false t.string :service_name, null: false
t.bigint :byte_size, null: false t.bigint :byte_size, null: false
t.string :checksum t.string :checksum
if connection.supports_datetime_with_precision? if connection.supports_datetime_with_precision?
t.datetime :created_at, precision: 6, null: false t.datetime :created_at, precision: 6, null: false
@@ -19,13 +19,13 @@ class CreateActiveStorageTables < ActiveRecord::Migration[7.0]
t.datetime :created_at, null: false t.datetime :created_at, null: false
end end
t.index [ :key ], unique: true t.index [:key], unique: true
end end
create_table :active_storage_attachments, id: primary_key_type do |t| create_table :active_storage_attachments, id: primary_key_type do |t|
t.string :name, null: false t.string :name, null: false
t.references :record, null: false, polymorphic: true, index: false, type: foreign_key_type t.references :record, null: false, polymorphic: true, index: false, type: foreign_key_type
t.references :blob, null: false, type: foreign_key_type t.references :blob, null: false, type: foreign_key_type
if connection.supports_datetime_with_precision? if connection.supports_datetime_with_precision?
t.datetime :created_at, precision: 6, null: false t.datetime :created_at, precision: 6, null: false
@@ -33,7 +33,7 @@ class CreateActiveStorageTables < ActiveRecord::Migration[7.0]
t.datetime :created_at, null: false t.datetime :created_at, null: false
end end
t.index [ :record_type, :record_id, :name, :blob_id ], name: :index_active_storage_attachments_uniqueness, unique: true t.index [:record_type, :record_id, :name, :blob_id], name: :index_active_storage_attachments_uniqueness, unique: true
t.foreign_key :active_storage_blobs, column: :blob_id t.foreign_key :active_storage_blobs, column: :blob_id
end end
@@ -41,17 +41,18 @@ class CreateActiveStorageTables < ActiveRecord::Migration[7.0]
t.belongs_to :blob, null: false, index: false, type: foreign_key_type t.belongs_to :blob, null: false, index: false, type: foreign_key_type
t.string :variation_digest, null: false t.string :variation_digest, null: false
t.index [ :blob_id, :variation_digest ], name: :index_active_storage_variant_records_uniqueness, unique: true t.index [:blob_id, :variation_digest], name: :index_active_storage_variant_records_uniqueness, unique: true
t.foreign_key :active_storage_blobs, column: :blob_id t.foreign_key :active_storage_blobs, column: :blob_id
end end
end end
private private
def primary_and_foreign_key_types
config = Rails.configuration.generators def primary_and_foreign_key_types
setting = config.options[config.orm][:primary_key_type] config = Rails.configuration.generators
primary_key_type = setting || :primary_key setting = config.options[config.orm][:primary_key_type]
foreign_key_type = setting || :bigint primary_key_type = setting || :primary_key
[ primary_key_type, foreign_key_type ] foreign_key_type = setting || :bigint
end [primary_key_type, foreign_key_type]
end
end end

View File

@@ -1,5 +1,5 @@
require "test_helper" require "test_helper"
class ApplicationSystemTestCase < ActionDispatch::SystemTestCase class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
driven_by :selenium, using: :headless_chrome, screen_size: [ 1400, 1400 ] driven_by :selenium, using: :headless_chrome, screen_size: [1400, 1400]
end end

View File

@@ -13,7 +13,7 @@ module Api
# Authentication Tests # Authentication Tests
test "should redirect to login when no session cookie" do test "should redirect to login when no session cookie" do
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" } get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
assert_response 302 assert_response 302
assert_match %r{/signin}, response.location assert_match %r{/signin}, response.location
@@ -23,7 +23,7 @@ module Api
test "should redirect when user is inactive" do test "should redirect when user is inactive" do
sign_in_as(@inactive_user) sign_in_as(@inactive_user)
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" } get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
assert_response 302 assert_response 302
assert_equal "User account is not active", response.headers["x-auth-reason"] assert_equal "User account is not active", response.headers["x-auth-reason"]
@@ -32,7 +32,7 @@ module Api
test "should return 200 when user is authenticated" do test "should return 200 when user is authenticated" do
sign_in_as(@user) sign_in_as(@user)
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" } get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
assert_response 200 assert_response 200
end end
@@ -41,7 +41,7 @@ module Api
test "should return 200 when matching rule exists" do test "should return 200 when matching rule exists" do
sign_in_as(@user) sign_in_as(@user)
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" } get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
assert_response 200 assert_response 200
end end
@@ -49,7 +49,7 @@ module Api
test "should return 403 when no rule matches (fail-closed security)" do test "should return 403 when no rule matches (fail-closed security)" do
sign_in_as(@user) sign_in_as(@user)
get "/api/verify", headers: { "X-Forwarded-Host" => "unknown.example.com" } get "/api/verify", headers: {"X-Forwarded-Host" => "unknown.example.com"}
assert_response 403 assert_response 403
assert_equal "No authentication rule configured for this domain", response.headers["x-auth-reason"] assert_equal "No authentication rule configured for this domain", response.headers["x-auth-reason"]
@@ -58,7 +58,7 @@ module Api
test "should return 403 when rule exists but is inactive" do test "should return 403 when rule exists but is inactive" do
sign_in_as(@user) sign_in_as(@user)
get "/api/verify", headers: { "X-Forwarded-Host" => "inactive.example.com" } get "/api/verify", headers: {"X-Forwarded-Host" => "inactive.example.com"}
assert_response 403 assert_response 403
assert_equal "No authentication rule configured for this domain", response.headers["x-auth-reason"] assert_equal "No authentication rule configured for this domain", response.headers["x-auth-reason"]
@@ -68,7 +68,7 @@ module Api
@rule.allowed_groups << @group @rule.allowed_groups << @group
sign_in_as(@user) # User not in group sign_in_as(@user) # User not in group
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" } get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
assert_response 403 assert_response 403
assert_match %r{permission to access this domain}, response.headers["x-auth-reason"] assert_match %r{permission to access this domain}, response.headers["x-auth-reason"]
@@ -79,35 +79,35 @@ module Api
@user.groups << @group @user.groups << @group
sign_in_as(@user) sign_in_as(@user)
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" } get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
assert_response 200 assert_response 200
end end
# 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"}
assert_response 200 assert_response 200
get "/api/verify", headers: { "X-Forwarded-Host" => "api.example.com" } get "/api/verify", headers: {"X-Forwarded-Host" => "api.example.com"}
assert_response 200 assert_response 200
get "/api/verify", headers: { "X-Forwarded-Host" => "other.com" } get "/api/verify", headers: {"X-Forwarded-Host" => "other.com"}
assert_response 403 # No rule configured - fail-closed assert_response 403 # No rule configured - fail-closed
assert_equal "No authentication rule configured for this domain", response.headers["x-auth-reason"] assert_equal "No authentication rule configured for this domain", response.headers["x-auth-reason"]
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"}
assert_response 200 assert_response 200
get "/api/verify", headers: { "X-Forwarded-Host" => "app.api.example.com" } get "/api/verify", headers: {"X-Forwarded-Host" => "app.api.example.com"}
assert_response 403 # No rule configured - fail-closed assert_response 403 # No rule configured - fail-closed
assert_equal "No authentication rule configured for this domain", response.headers["x-auth-reason"] assert_equal "No authentication rule configured for this domain", response.headers["x-auth-reason"]
end end
@@ -116,7 +116,7 @@ module Api
test "should return default headers when rule has no custom config" do test "should return default headers when rule has no custom config" do
sign_in_as(@user) sign_in_as(@user)
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" } get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
assert_response 200 assert_response 200
assert_equal @user.email_address, response.headers["x-remote-user"] assert_equal @user.email_address, response.headers["x-remote-user"]
@@ -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",
@@ -140,7 +140,7 @@ module Api
) )
sign_in_as(@user) sign_in_as(@user)
get "/api/verify", headers: { "X-Forwarded-Host" => "custom.example.com" } get "/api/verify", headers: {"X-Forwarded-Host" => "custom.example.com"}
assert_response 200 assert_response 200
assert_equal @user.email_address, response.headers["x-webauth-user"] assert_equal @user.email_address, response.headers["x-webauth-user"]
@@ -151,17 +151,17 @@ 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",
domain_pattern: "noheaders.example.com", domain_pattern: "noheaders.example.com",
active: true, active: true,
headers_config: { user: "", email: "", name: "", groups: "", admin: "" } headers_config: {user: "", email: "", name: "", groups: "", admin: ""}
) )
sign_in_as(@user) sign_in_as(@user)
get "/api/verify", headers: { "X-Forwarded-Host" => "noheaders.example.com" } get "/api/verify", headers: {"X-Forwarded-Host" => "noheaders.example.com"}
assert_response 200 assert_response 200
# Check that auth-specific headers are not present (exclude Rails security headers) # Check that auth-specific headers are not present (exclude Rails security headers)
@@ -173,7 +173,7 @@ module Api
@user.groups << @group @user.groups << @group
sign_in_as(@user) sign_in_as(@user)
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" } get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
assert_response 200 assert_response 200
groups_header = response.headers["x-remote-groups"] groups_header = response.headers["x-remote-groups"]
@@ -186,7 +186,7 @@ module Api
@user.groups.clear # Remove fixture groups @user.groups.clear # Remove fixture groups
sign_in_as(@user) sign_in_as(@user)
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" } get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
assert_response 200 assert_response 200
assert_nil response.headers["x-remote-groups"] assert_nil response.headers["x-remote-groups"]
@@ -195,7 +195,7 @@ module Api
test "should include admin header correctly" do test "should include admin header correctly" do
sign_in_as(@admin_user) # Assuming users(:two) is admin sign_in_as(@admin_user) # Assuming users(:two) is admin
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" } get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
assert_response 200 assert_response 200
assert_equal "true", response.headers["x-remote-admin"] assert_equal "true", response.headers["x-remote-admin"]
@@ -207,7 +207,7 @@ module Api
@user.groups << group2 @user.groups << group2
sign_in_as(@user) sign_in_as(@user)
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" } get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
assert_response 200 assert_response 200
groups_header = response.headers["x-remote-groups"] groups_header = response.headers["x-remote-groups"]
@@ -219,7 +219,7 @@ module Api
test "should fall back to Host header when X-Forwarded-Host is missing" do test "should fall back to Host header when X-Forwarded-Host is missing" do
sign_in_as(@user) sign_in_as(@user)
get "/api/verify", headers: { "Host" => "test.example.com" } get "/api/verify", headers: {"Host" => "test.example.com"}
assert_response 200 assert_response 200
end end
@@ -239,7 +239,7 @@ module Api
long_domain = "a" * 250 + ".example.com" long_domain = "a" * 250 + ".example.com"
sign_in_as(@user) sign_in_as(@user)
get "/api/verify", headers: { "X-Forwarded-Host" => long_domain } get "/api/verify", headers: {"X-Forwarded-Host" => long_domain}
assert_response 403 # No rule configured - fail-closed assert_response 403 # No rule configured - fail-closed
assert_equal "No authentication rule configured for this domain", response.headers["x-auth-reason"] assert_equal "No authentication rule configured for this domain", response.headers["x-auth-reason"]
@@ -248,7 +248,7 @@ module Api
test "should handle case insensitive domain matching" do test "should handle case insensitive domain matching" do
sign_in_as(@user) sign_in_as(@user)
get "/api/verify", headers: { "X-Forwarded-Host" => "TEST.Example.COM" } get "/api/verify", headers: {"X-Forwarded-Host" => "TEST.Example.COM"}
assert_response 200 assert_response 200
end end
@@ -262,7 +262,7 @@ module Api
get "/api/verify", headers: { get "/api/verify", headers: {
"X-Forwarded-Host" => "test.example.com", "X-Forwarded-Host" => "test.example.com",
"X-Forwarded-Uri" => "/admin" "X-Forwarded-Uri" => "/admin"
}, params: { rd: evil_url } }, params: {rd: evil_url}
assert_response 302 assert_response 302
assert_match %r{/signin}, response.location assert_match %r{/signin}, response.location
@@ -292,8 +292,8 @@ module Api
# This should be allowed (domain has ForwardAuthRule) # This should be allowed (domain has ForwardAuthRule)
allowed_url = "https://test.example.com/dashboard" allowed_url = "https://test.example.com/dashboard"
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }, get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"},
params: { rd: allowed_url } params: {rd: allowed_url}
assert_response 302 assert_response 302
assert_match allowed_url, response.location assert_match allowed_url, response.location
@@ -305,8 +305,8 @@ module Api
# This should be rejected (no ForwardAuthRule for evil-site.com) # This should be rejected (no ForwardAuthRule for evil-site.com)
evil_url = "https://evil-site.com/steal-credentials" evil_url = "https://evil-site.com/steal-credentials"
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }, get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"},
params: { rd: evil_url } params: {rd: evil_url}
assert_response 302 assert_response 302
# Should redirect to login page or default URL, NOT to evil_url # Should redirect to login page or default URL, NOT to evil_url
@@ -320,8 +320,8 @@ module Api
# This should be rejected (HTTP not HTTPS) # This should be rejected (HTTP not HTTPS)
http_url = "http://test.example.com/dashboard" http_url = "http://test.example.com/dashboard"
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }, get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"},
params: { rd: http_url } params: {rd: http_url}
assert_response 302 assert_response 302
# Should redirect to login page or default URL, NOT to HTTP URL # Should redirect to login page or default URL, NOT to HTTP URL
@@ -340,8 +340,8 @@ module Api
] ]
dangerous_schemes.each do |dangerous_url| dangerous_schemes.each do |dangerous_url|
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }, get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"},
params: { rd: dangerous_url } params: {rd: dangerous_url}
assert_response 302, "Should reject dangerous URL: #{dangerous_url}" assert_response 302, "Should reject dangerous URL: #{dangerous_url}"
# Should redirect to login page or default URL, NOT to dangerous URL # Should redirect to login page or default URL, NOT to dangerous URL
@@ -355,7 +355,7 @@ module Api
sign_in_as(@user) sign_in_as(@user)
# Authenticated GET requests should return 200 # Authenticated GET requests should return 200
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" } get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
assert_response 200 assert_response 200
end end
@@ -461,11 +461,11 @@ module Api
sign_in_as(@user) sign_in_as(@user)
# First request # First request
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" } get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
assert_response 200 assert_response 200
# Second request with same session # Second request with same session
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" } get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
assert_response 200 assert_response 200
# Should maintain user identity across requests # Should maintain user identity across requests
@@ -481,8 +481,8 @@ module Api
5.times do |i| 5.times do |i|
threads << Thread.new do threads << Thread.new do
get "/api/verify", headers: { "X-Forwarded-Host" => "app#{i}.example.com" } get "/api/verify", headers: {"X-Forwarded-Host" => "app#{i}.example.com"}
results << { status: response.status } results << {status: response.status}
end end
end end
@@ -524,7 +524,7 @@ module Api
request_count = 10 request_count = 10
request_count.times do |i| request_count.times do |i|
get "/api/verify", headers: { "X-Forwarded-Host" => "app#{i}.example.com" } get "/api/verify", headers: {"X-Forwarded-Host" => "app#{i}.example.com"}
assert_response 403 # No rules configured for these domains assert_response 403 # No rules configured for these domains
end end

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

@@ -31,7 +31,7 @@ class InputValidationTest < ActionDispatch::IntegrationTest
user = User.create!(email_address: "xss_test@example.com", password: "password123", name: xss_payload) user = User.create!(email_address: "xss_test@example.com", password: "password123", name: xss_payload)
# Sign in # Sign in
post signin_path, params: { email_address: "xss_test@example.com", password: "password123" } post signin_path, params: {email_address: "xss_test@example.com", password: "password123"}
assert_response :redirect assert_response :redirect
# Get a page that displays user name # Get a page that displays user name
@@ -59,7 +59,7 @@ class InputValidationTest < ActionDispatch::IntegrationTest
) )
# Sign in # Sign in
post signin_path, params: { email_address: "oauth_tamper_test@example.com", password: "password123" } post signin_path, params: {email_address: "oauth_tamper_test@example.com", password: "password123"}
assert_response :redirect assert_response :redirect
# Try to tamper with OAuth authorization parameters # Try to tamper with OAuth authorization parameters
@@ -112,7 +112,7 @@ class InputValidationTest < ActionDispatch::IntegrationTest
test "JSON input validation prevents malicious payloads" do test "JSON input validation prevents malicious payloads" do
# Try to send malformed JSON # Try to send malformed JSON
post "/oauth/token", params: '{"grant_type":"authorization_code",}'.to_json, post "/oauth/token", params: '{"grant_type":"authorization_code",}'.to_json,
headers: { "CONTENT_TYPE" => "application/json" } headers: {"CONTENT_TYPE" => "application/json"}
# Should handle malformed JSON gracefully # Should handle malformed JSON gracefully
assert_includes [400, 422], response.status assert_includes [400, 422], response.status
@@ -124,9 +124,9 @@ class InputValidationTest < ActionDispatch::IntegrationTest
grant_type: "authorization_code", grant_type: "authorization_code",
code: "test_code", code: "test_code",
redirect_uri: "http://localhost:4000/callback", redirect_uri: "http://localhost:4000/callback",
nested: { __proto__: "tampered", constructor: { prototype: "tampered" } } nested: {__proto__: "tampered", constructor: {prototype: "tampered"}}
}.to_json, }.to_json,
headers: { "CONTENT_TYPE" => "application/json" } headers: {"CONTENT_TYPE" => "application/json"}
# Should sanitize or reject prototype pollution attempts # Should sanitize or reject prototype pollution attempts
# The request should be handled (either accept or reject, not crash) # The request should be handled (either accept or reject, not crash)
@@ -165,7 +165,7 @@ class InputValidationTest < ActionDispatch::IntegrationTest
malicious_paths.each do |malicious_path| malicious_paths.each do |malicious_path|
# Try to access files with path traversal # Try to access files with path traversal
get root_path, params: { file: malicious_path } get root_path, params: {file: malicious_path}
# Should prevent access to files outside public directory # Should prevent access to files outside public directory
assert_response :redirect, "Should reject path traversal attempt" assert_response :redirect, "Should reject path traversal attempt"

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",
@@ -546,7 +546,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
) )
# Sign in first # Sign in first
post signin_path, params: { email_address: "security_test@example.com", password: "password123" } post signin_path, params: {email_address: "security_test@example.com", password: "password123"}
# Test authorization with state parameter # Test authorization with state parameter
get "/oauth/authorize", params: { get "/oauth/authorize", params: {
@@ -573,7 +573,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
) )
# Sign in first # Sign in first
post signin_path, params: { email_address: "security_test@example.com", password: "password123" } post signin_path, params: {email_address: "security_test@example.com", password: "password123"}
# Test authorization without state parameter # Test authorization without state parameter
get "/oauth/authorize", params: { get "/oauth/authorize", params: {
@@ -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

@@ -9,8 +9,8 @@ class PasswordsControllerTest < ActionDispatch::IntegrationTest
end end
test "create" do test "create" do
post passwords_path, params: { email_address: @user.email_address } post passwords_path, params: {email_address: @user.email_address}
assert_enqueued_email_with PasswordsMailer, :reset, args: [ @user ] assert_enqueued_email_with PasswordsMailer, :reset, args: [@user]
assert_redirected_to signin_path assert_redirected_to signin_path
follow_redirect! follow_redirect!
@@ -18,7 +18,7 @@ class PasswordsControllerTest < ActionDispatch::IntegrationTest
end end
test "create for an unknown user redirects but sends no mail" do test "create for an unknown user redirects but sends no mail" do
post passwords_path, params: { email_address: "missing-user@example.com" } post passwords_path, params: {email_address: "missing-user@example.com"}
assert_enqueued_emails 0 assert_enqueued_emails 0
assert_redirected_to signin_path assert_redirected_to signin_path
@@ -41,7 +41,7 @@ class PasswordsControllerTest < ActionDispatch::IntegrationTest
test "update" do test "update" do
assert_changes -> { @user.reload.password_digest } do assert_changes -> { @user.reload.password_digest } do
put password_path(@user.generate_token_for(:password_reset)), params: { password: "newpassword", password_confirmation: "newpassword" } put password_path(@user.generate_token_for(:password_reset)), params: {password: "newpassword", password_confirmation: "newpassword"}
assert_redirected_to signin_path assert_redirected_to signin_path
end end
@@ -52,7 +52,7 @@ class PasswordsControllerTest < ActionDispatch::IntegrationTest
test "update with non matching passwords" do test "update with non matching passwords" do
token = @user.password_reset_token token = @user.password_reset_token
assert_no_changes -> { @user.reload.password_digest } do assert_no_changes -> { @user.reload.password_digest } do
put password_path(token), params: { password: "no", password_confirmation: "match" } put password_path(token), params: {password: "no", password_confirmation: "match"}
assert_redirected_to edit_password_path(token) assert_redirected_to edit_password_path(token)
end end
@@ -61,7 +61,8 @@ class PasswordsControllerTest < ActionDispatch::IntegrationTest
end end
private private
def assert_notice(text)
assert_select "div", /#{text}/ def assert_notice(text)
end assert_select "div", /#{text}/
end
end end

View File

@@ -9,14 +9,14 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest
end end
test "create with valid credentials" do test "create with valid credentials" do
post session_path, params: { email_address: @user.email_address, password: "password" } post session_path, params: {email_address: @user.email_address, password: "password"}
assert_redirected_to root_path assert_redirected_to root_path
assert cookies[:session_id] assert cookies[:session_id]
end end
test "create with invalid credentials" do test "create with invalid credentials" do
post session_path, params: { email_address: @user.email_address, password: "wrong" } post session_path, params: {email_address: @user.email_address, password: "wrong"}
assert_redirected_to signin_path assert_redirected_to signin_path
assert_nil cookies[:session_id] assert_nil cookies[:session_id]

View File

@@ -14,11 +14,11 @@ class TotpSecurityTest < ActionDispatch::IntegrationTest
valid_code = totp.now valid_code = totp.now
# Set up pending TOTP session # Set up pending TOTP session
post signin_path, params: { email_address: "totp_replay_test@example.com", password: "password123" } post signin_path, params: {email_address: "totp_replay_test@example.com", password: "password123"}
assert_redirected_to totp_verification_path assert_redirected_to totp_verification_path
# First use of the code should succeed # First use of the code should succeed
post totp_verification_path, params: { code: valid_code } post totp_verification_path, params: {code: valid_code}
assert_response :redirect assert_response :redirect
assert_redirected_to root_path assert_redirected_to root_path
@@ -50,12 +50,12 @@ class TotpSecurityTest < ActionDispatch::IntegrationTest
original_codes = user.reload.backup_codes original_codes = user.reload.backup_codes
# Set up pending TOTP session # Set up pending TOTP session
post signin_path, params: { email_address: "backup_code_test@example.com", password: "password123" } post signin_path, params: {email_address: "backup_code_test@example.com", password: "password123"}
assert_redirected_to totp_verification_path assert_redirected_to totp_verification_path
# Use a backup code # Use a backup code
backup_code = backup_codes.first backup_code = backup_codes.first
post totp_verification_path, params: { code: backup_code } post totp_verification_path, params: {code: backup_code}
# Should successfully sign in # Should successfully sign in
assert_response :redirect assert_response :redirect
@@ -70,11 +70,11 @@ class TotpSecurityTest < ActionDispatch::IntegrationTest
assert_response :redirect assert_response :redirect
# Sign in again # Sign in again
post signin_path, params: { email_address: "backup_code_test@example.com", password: "password123" } post signin_path, params: {email_address: "backup_code_test@example.com", password: "password123"}
assert_redirected_to totp_verification_path assert_redirected_to totp_verification_path
# Try the same backup code # Try the same backup code
post totp_verification_path, params: { code: backup_code } post totp_verification_path, params: {code: backup_code}
# Should fail - backup code already used # Should fail - backup code already used
assert_response :redirect assert_response :redirect
@@ -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
@@ -116,7 +116,7 @@ class TotpSecurityTest < ActionDispatch::IntegrationTest
user.save! user.save!
# Set up pending TOTP session # Set up pending TOTP session
post signin_path, params: { email_address: "totp_time_test@example.com", password: "password123" } post signin_path, params: {email_address: "totp_time_test@example.com", password: "password123"}
assert_redirected_to totp_verification_path assert_redirected_to totp_verification_path
# Generate a TOTP code for a time far in the future (outside valid window) # Generate a TOTP code for a time far in the future (outside valid window)
@@ -124,7 +124,7 @@ class TotpSecurityTest < ActionDispatch::IntegrationTest
future_code = totp.at(Time.now.to_i + 300) # 5 minutes in the future future_code = totp.at(Time.now.to_i + 300) # 5 minutes in the future
# Try to use the future code # Try to use the future code
post totp_verification_path, params: { code: future_code } post totp_verification_path, params: {code: future_code}
# Should fail - code is outside valid time window # Should fail - code is outside valid time window
assert_response :redirect assert_response :redirect
@@ -145,16 +145,16 @@ 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"}
assert_redirected_to totp_verification_path assert_redirected_to totp_verification_path
# Complete TOTP verification # Complete TOTP verification
totp = ROTP::TOTP.new(user.totp_secret) totp = ROTP::TOTP.new(user.totp_secret)
valid_code = totp.now valid_code = totp.now
post totp_verification_path, params: { code: valid_code } post totp_verification_path, params: {code: valid_code}
assert_response :redirect assert_response :redirect
# The TOTP secret should never be exposed in the response body or headers # The TOTP secret should never be exposed in the response body or headers
@@ -210,7 +210,7 @@ class TotpSecurityTest < ActionDispatch::IntegrationTest
user.update!(totp_required: true, totp_secret: nil) user.update!(totp_required: true, totp_secret: nil)
# Sign in # Sign in
post signin_path, params: { email_address: "totp_setup_test@example.com", password: "password123" } post signin_path, params: {email_address: "totp_setup_test@example.com", password: "password123"}
# Should redirect to TOTP setup, not verification # Should redirect to TOTP setup, not verification
assert_response :redirect assert_response :redirect
@@ -232,7 +232,7 @@ class TotpSecurityTest < ActionDispatch::IntegrationTest
user.save! user.save!
# Set up pending TOTP session # Set up pending TOTP session
post signin_path, params: { email_address: "totp_format_test@example.com", password: "password123" } post signin_path, params: {email_address: "totp_format_test@example.com", password: "password123"}
assert_redirected_to totp_verification_path assert_redirected_to totp_verification_path
# Try invalid formats # Try invalid formats
@@ -245,7 +245,7 @@ class TotpSecurityTest < ActionDispatch::IntegrationTest
] ]
invalid_codes.each do |invalid_code| invalid_codes.each do |invalid_code|
post totp_verification_path, params: { code: invalid_code } post totp_verification_path, params: {code: invalid_code}
assert_response :redirect assert_response :redirect
assert_redirected_to totp_verification_path assert_redirected_to totp_verification_path
end end
@@ -266,11 +266,11 @@ class TotpSecurityTest < ActionDispatch::IntegrationTest
user.save! user.save!
# Sign in # Sign in
post signin_path, params: { email_address: "totp_recovery_test@example.com", password: "password123" } post signin_path, params: {email_address: "totp_recovery_test@example.com", password: "password123"}
assert_redirected_to totp_verification_path assert_redirected_to totp_verification_path
# Use backup code instead of TOTP # Use backup code instead of TOTP
post totp_verification_path, params: { code: backup_codes.first } post totp_verification_path, params: {code: backup_codes.first}
# Should successfully sign in # Should successfully sign in
assert_response :redirect assert_response :redirect

View File

@@ -20,27 +20,27 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
# Basic Authentication Flow Tests # Basic Authentication Flow Tests
test "complete authentication flow: unauthenticated to authenticated" do test "complete authentication flow: unauthenticated to authenticated" do
# Step 1: Unauthenticated request should redirect # Step 1: Unauthenticated request should redirect
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" } get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
assert_response 302 assert_response 302
assert_match %r{/signin}, response.location assert_match %r{/signin}, response.location
assert_equal "No session cookie", response.headers["x-auth-reason"] assert_equal "No session cookie", response.headers["x-auth-reason"]
# Step 2: Sign in # Step 2: Sign in
post "/signin", params: { email_address: @user.email_address, password: "password" } post "/signin", params: {email_address: @user.email_address, password: "password"}
assert_response 302 assert_response 302
# Signin now redirects back with fa_token parameter # Signin now redirects back with fa_token parameter
assert_match(/\?fa_token=/, response.location) assert_match(/\?fa_token=/, response.location)
assert cookies[:session_id] assert cookies[:session_id]
# Step 3: Authenticated request should succeed # Step 3: Authenticated request should succeed
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" } get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
assert_response 200 assert_response 200
assert_equal @user.email_address, response.headers["x-remote-user"] assert_equal @user.email_address, response.headers["x-remote-user"]
end end
test "session expiration handling" do test "session expiration handling" do
# Sign in # Sign in
post "/signin", params: { email_address: @user.email_address, password: "password" } post "/signin", params: {email_address: @user.email_address, password: "password"}
# Manually expire the session (get the most recent session for this user) # Manually expire the session (get the most recent session for this user)
session = Session.where(user: @user).order(created_at: :desc).first session = Session.where(user: @user).order(created_at: :desc).first
@@ -48,7 +48,7 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
session.update!(expires_at: 1.hour.ago) session.update!(expires_at: 1.hour.ago)
# Request should fail and redirect to login # Request should fail and redirect to login
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" } get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
assert_response 302 assert_response 302
assert_equal "Session expired", response.headers["x-auth-reason"] assert_equal "Session expired", response.headers["x-auth-reason"]
end end
@@ -56,24 +56,24 @@ 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"}
# Test wildcard domain # Test wildcard domain
get "/api/verify", headers: { "X-Forwarded-Host" => "app.example.com" } get "/api/verify", headers: {"X-Forwarded-Host" => "app.example.com"}
assert_response 200 assert_response 200
assert_equal @user.email_address, response.headers["x-remote-user"] assert_equal @user.email_address, response.headers["x-remote-user"]
# Test exact domain # Test exact domain
get "/api/verify", headers: { "X-Forwarded-Host" => "api.example.com" } get "/api/verify", headers: {"X-Forwarded-Host" => "api.example.com"}
assert_response 200 assert_response 200
assert_equal @user.email_address, response.headers["x-remote-user"] assert_equal @user.email_address, response.headers["x-remote-user"]
# Test non-matching domain (should use defaults) # Test non-matching domain (should use defaults)
get "/api/verify", headers: { "X-Forwarded-Host" => "other.example.com" } get "/api/verify", headers: {"X-Forwarded-Host" => "other.example.com"}
assert_response 200 assert_response 200
assert_equal @user.email_address, response.headers["x-remote-user"] assert_equal @user.email_address, response.headers["x-remote-user"]
end end
@@ -84,10 +84,10 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
restricted_rule.allowed_groups << @group restricted_rule.allowed_groups << @group
# Sign in user without group # Sign in user without group
post "/signin", params: { email_address: @user.email_address, password: "password" } post "/signin", params: {email_address: @user.email_address, password: "password"}
# Should be denied access # Should be denied access
get "/api/verify", headers: { "X-Forwarded-Host" => "restricted.example.com" } get "/api/verify", headers: {"X-Forwarded-Host" => "restricted.example.com"}
assert_response 403 assert_response 403
assert_match %r{permission to access this domain}, response.headers["x-auth-reason"] assert_match %r{permission to access this domain}, response.headers["x-auth-reason"]
@@ -95,7 +95,7 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
@user.groups << @group @user.groups << @group
# Should now be allowed # Should now be allowed
get "/api/verify", headers: { "X-Forwarded-Host" => "restricted.example.com" } get "/api/verify", headers: {"X-Forwarded-Host" => "restricted.example.com"}
assert_response 200 assert_response 200
assert_equal @user.email_address, response.headers["x-remote-user"] assert_equal @user.email_address, response.headers["x-remote-user"]
end end
@@ -103,18 +103,18 @@ 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,
headers_config: { user: "", email: "", name: "", groups: "", admin: "" } headers_config: {user: "", email: "", name: "", groups: "", admin: ""}
) )
# Add user to groups # Add user to groups
@@ -122,10 +122,10 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
@user.groups << @group2 @user.groups << @group2
# Sign in # Sign in
post "/signin", params: { email_address: @user.email_address, password: "password" } post "/signin", params: {email_address: @user.email_address, password: "password"}
# Test default headers # Test default headers
get "/api/verify", headers: { "X-Forwarded-Host" => "default.example.com" } get "/api/verify", headers: {"X-Forwarded-Host" => "default.example.com"}
assert_response 200 assert_response 200
# Rails normalizes header keys to lowercase # Rails normalizes header keys to lowercase
assert_equal @user.email_address, response.headers["x-remote-user"] assert_equal @user.email_address, response.headers["x-remote-user"]
@@ -133,7 +133,7 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
assert_equal "Group Two,Group One", response.headers["x-remote-groups"] assert_equal "Group Two,Group One", response.headers["x-remote-groups"]
# Test custom headers # Test custom headers
get "/api/verify", headers: { "X-Forwarded-Host" => "custom.example.com" } get "/api/verify", headers: {"X-Forwarded-Host" => "custom.example.com"}
assert_response 200 assert_response 200
# Custom headers are also normalized to lowercase # Custom headers are also normalized to lowercase
assert_equal @user.email_address, response.headers["x-webauth-user"] assert_equal @user.email_address, response.headers["x-webauth-user"]
@@ -141,7 +141,7 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
assert_equal "Group Two,Group One", response.headers["x-webauth-roles"] assert_equal "Group Two,Group One", response.headers["x-webauth-roles"]
# Test no headers # Test no headers
get "/api/verify", headers: { "X-Forwarded-Host" => "noheaders.example.com" } get "/api/verify", headers: {"X-Forwarded-Host" => "noheaders.example.com"}
assert_response 200 assert_response 200
# Check that no auth-related headers are present (excluding security headers) # Check that no auth-related headers are present (excluding security headers)
auth_headers = response.headers.select { |k, v| k.match?(/^x-remote-|^x-webauth-|^x-admin-/i) } auth_headers = response.headers.select { |k, v| k.match?(/^x-remote-|^x-webauth-|^x-admin-/i) }
@@ -174,7 +174,7 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
get "/api/verify", headers: { get "/api/verify", headers: {
"X-Forwarded-Host" => "app.example.com", "X-Forwarded-Host" => "app.example.com",
"X-Forwarded-Uri" => "/admin" "X-Forwarded-Uri" => "/admin"
}, params: { rd: "https://app.example.com/admin" } }, params: {rd: "https://app.example.com/admin"}
assert_response 302 assert_response 302
location = response.location location = response.location
@@ -194,16 +194,16 @@ 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,
headers_config: { user: "X-Admin-User", admin: "X-Admin-Flag" } headers_config: {user: "X-Admin-User", admin: "X-Admin-Flag"}
) )
# Test regular user # Test regular user
post "/signin", params: { email_address: regular_user.email_address, password: "password" } post "/signin", params: {email_address: regular_user.email_address, password: "password"}
get "/api/verify", headers: { "X-Forwarded-Host" => "admin.example.com" } get "/api/verify", headers: {"X-Forwarded-Host" => "admin.example.com"}
assert_response 200 assert_response 200
assert_equal regular_user.email_address, response.headers["x-admin-user"] assert_equal regular_user.email_address, response.headers["x-admin-user"]
@@ -211,8 +211,8 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
delete "/session" delete "/session"
# Test admin user # Test admin user
post "/signin", params: { email_address: admin_user.email_address, password: "password" } post "/signin", params: {email_address: admin_user.email_address, password: "password"}
get "/api/verify", headers: { "X-Forwarded-Host" => "admin.example.com" } get "/api/verify", headers: {"X-Forwarded-Host" => "admin.example.com"}
assert_response 200 assert_response 200
assert_equal admin_user.email_address, response.headers["x-admin-user"] assert_equal admin_user.email_address, response.headers["x-admin-user"]
assert_equal "true", response.headers["x-admin-flag"] assert_equal "true", response.headers["x-admin-flag"]
@@ -221,10 +221,10 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
# Security Integration Tests # Security Integration Tests
test "session hijacking prevention" do test "session hijacking prevention" do
# User A signs in # User A signs in
post "/signin", params: { email_address: @user.email_address, password: "password" } post "/signin", params: {email_address: @user.email_address, password: "password"}
# Verify User A can access protected resources # Verify User A can access protected resources
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" } get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
assert_response 200 assert_response 200
assert_equal @user.email_address, response.headers["x-remote-user"] assert_equal @user.email_address, response.headers["x-remote-user"]
user_a_session_id = Session.where(user: @user).last.id user_a_session_id = Session.where(user: @user).last.id
@@ -233,10 +233,10 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
reset! reset!
# User B signs in (creates a new session) # User B signs in (creates a new session)
post "/signin", params: { email_address: @admin_user.email_address, password: "password" } post "/signin", params: {email_address: @admin_user.email_address, password: "password"}
# Verify User B can access protected resources # Verify User B can access protected resources
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" } get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
assert_response 200 assert_response 200
assert_equal @admin_user.email_address, response.headers["x-remote-user"] assert_equal @admin_user.email_address, response.headers["x-remote-user"]
user_b_session_id = Session.where(user: @admin_user).last.id user_b_session_id = Session.where(user: @admin_user).last.id
@@ -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

@@ -9,7 +9,7 @@ class SessionSecurityTest < ActionDispatch::IntegrationTest
user = User.create!(email_address: "session_test@example.com", password: "password123") user = User.create!(email_address: "session_test@example.com", password: "password123")
# Sign in # Sign in
post signin_path, params: { email_address: "session_test@example.com", password: "password123" } post signin_path, params: {email_address: "session_test@example.com", password: "password123"}
assert_response :redirect assert_response :redirect
follow_redirect! follow_redirect!
assert_response :success assert_response :success
@@ -75,7 +75,7 @@ class SessionSecurityTest < ActionDispatch::IntegrationTest
user = User.create!(email_address: "session_fixation_test@example.com", password: "password123") user = User.create!(email_address: "session_fixation_test@example.com", password: "password123")
# Sign in creates a new session # Sign in creates a new session
post signin_path, params: { email_address: "session_fixation_test@example.com", password: "password123" } post signin_path, params: {email_address: "session_fixation_test@example.com", password: "password123"}
assert_response :redirect assert_response :redirect
# User should be authenticated after sign in # User should be authenticated after sign in
@@ -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",
@@ -172,7 +172,7 @@ class SessionSecurityTest < ActionDispatch::IntegrationTest
) )
# Sign in (creates a new session via the sign-in flow) # Sign in (creates a new session via the sign-in flow)
post signin_path, params: { email_address: "logout_test@example.com", password: "password123" } post signin_path, params: {email_address: "logout_test@example.com", password: "password123"}
assert_response :redirect assert_response :redirect
# Should have 3 sessions now # Should have 3 sessions now
@@ -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",
@@ -212,7 +212,7 @@ class SessionSecurityTest < ActionDispatch::IntegrationTest
) )
# Sign in # Sign in
post signin_path, params: { email_address: "logout_notification_test@example.com", password: "password123" } post signin_path, params: {email_address: "logout_notification_test@example.com", password: "password123"}
assert_response :redirect assert_response :redirect
# Sign out # Sign out
@@ -237,8 +237,8 @@ class SessionSecurityTest < ActionDispatch::IntegrationTest
user = User.create!(email_address: "hijacking_test@example.com", password: "password123") user = User.create!(email_address: "hijacking_test@example.com", password: "password123")
# Sign in # Sign in
post signin_path, params: { email_address: "hijacking_test@example.com", password: "password123" }, post signin_path, params: {email_address: "hijacking_test@example.com", password: "password123"},
headers: { "HTTP_USER_AGENT" => "TestBrowser/1.0" } headers: {"HTTP_USER_AGENT" => "TestBrowser/1.0"}
assert_response :redirect assert_response :redirect
# Check that session includes IP and user agent # Check that session includes IP and user agent
@@ -295,7 +295,7 @@ class SessionSecurityTest < ActionDispatch::IntegrationTest
# Test forward auth endpoint with valid session # Test forward auth endpoint with valid session
get api_verify_path(rd: "https://test.example.com/protected"), get api_verify_path(rd: "https://test.example.com/protected"),
headers: { cookie: "_session_id=#{user_session.id}" } headers: {cookie: "_session_id=#{user_session.id}"}
# Should accept the request and redirect back # Should accept the request and redirect back
assert_response :redirect assert_response :redirect

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,
@@ -28,7 +28,7 @@ class WebauthnCredentialEnumerationTest < ActionDispatch::IntegrationTest
) )
# Sign in as user1 # Sign in as user1
post signin_path, params: { email_address: "user1@example.com", password: "password123" } post signin_path, params: {email_address: "user1@example.com", password: "password123"}
assert_response :redirect assert_response :redirect
follow_redirect! follow_redirect!
@@ -66,7 +66,7 @@ class WebauthnCredentialEnumerationTest < ActionDispatch::IntegrationTest
) )
# Sign in # Sign in
post signin_path, params: { email_address: "user@example.com", password: "password123" } post signin_path, params: {email_address: "user@example.com", password: "password123"}
assert_response :redirect assert_response :redirect
follow_redirect! follow_redirect!

View File

@@ -37,7 +37,7 @@ class ApplicationJobTest < ActiveJob::TestCase
end end
assert_enqueued_jobs 1 do assert_enqueued_jobs 1 do
test_job.perform_later("arg1", "arg2", { "key" => "value" }) test_job.perform_later("arg1", "arg2", {"key" => "value"})
end end
# ActiveJob serializes all hash keys as strings # ActiveJob serializes all hash keys as strings
@@ -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"
@@ -164,7 +164,7 @@ class OidcAccessTokenTest < ActiveSupport::TestCase
) )
assert access_token.plaintext_token.length > auth_code.plaintext_code.length, assert access_token.plaintext_token.length > auth_code.plaintext_code.length,
"Access tokens should be longer than authorization codes" "Access tokens should be longer than authorization codes"
end end
test "should have appropriate expiry times" do test "should have appropriate expiry times" do
@@ -181,7 +181,7 @@ class OidcAccessTokenTest < ActiveSupport::TestCase
# Authorization codes expire in 10 minutes, access tokens in 1 hour # Authorization codes expire in 10 minutes, access tokens in 1 hour
assert access_token.expires_at > auth_code.expires_at, assert access_token.expires_at > auth_code.expires_at,
"Access tokens should have longer expiry than authorization codes" "Access tokens should have longer expiry than authorization codes"
end end
test "revoked tokens should not appear in valid scope" do test "revoked tokens should not appear in valid scope" do

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

@@ -218,7 +218,7 @@ class OidcUserConsentTest < ActiveSupport::TestCase
# Application requests more than granted # Application requests more than granted
assert_not @consent.covers_scopes?(["openid", "profile", "groups"]), assert_not @consent.covers_scopes?(["openid", "profile", "groups"]),
"Should not cover scopes not granted" "Should not cover scopes not granted"
# Application requests subset # Application requests subset
assert @consent.covers_scopes?(["email"]), "Should cover subset of granted scopes" assert @consent.covers_scopes?(["email"]), "Should cover subset of granted scopes"

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
@@ -298,7 +298,7 @@ class UserTest < ActiveSupport::TestCase
# Make 5 failed attempts to trigger rate limit # Make 5 failed attempts to trigger rate limit
5.times do |i| 5.times do |i|
result = user.verify_backup_code("INVALID123") result = user.verify_backup_code("INVALID123")
assert_not result, "Failed attempt #{i+1} should return false" assert_not result, "Failed attempt #{i + 1} should return false"
end end
# Check that the cache is tracking attempts # Check that the cache is tracking attempts

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)
@@ -324,11 +324,11 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
# Group has roles: ["user"] # Group has roles: ["user"]
group = groups(:admin_group) group = groups(:admin_group)
group.update!(custom_claims: { "roles" => ["user"], "permissions" => ["read"] }) group.update!(custom_claims: {"roles" => ["user"], "permissions" => ["read"]})
user.groups << group user.groups << group
# User adds roles: ["admin"] # User adds roles: ["admin"]
user.update!(custom_claims: { "roles" => ["admin"], "permissions" => ["write"] }) user.update!(custom_claims: {"roles" => ["admin"], "permissions" => ["write"]})
token = @service.generate_id_token(user, app) token = @service.generate_id_token(user, app)
decoded = JWT.decode(token, nil, false).first decoded = JWT.decode(token, nil, false).first
@@ -349,16 +349,16 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
# First group has roles: ["user"] # First group has roles: ["user"]
group1 = groups(:admin_group) group1 = groups(:admin_group)
group1.update!(custom_claims: { "roles" => ["user"] }) group1.update!(custom_claims: {"roles" => ["user"]})
user.groups << group1 user.groups << group1
# Second group has roles: ["moderator"] # Second group has roles: ["moderator"]
group2 = Group.create!(name: "moderators", description: "Moderators group") group2 = Group.create!(name: "moderators", description: "Moderators group")
group2.update!(custom_claims: { "roles" => ["moderator"] }) group2.update!(custom_claims: {"roles" => ["moderator"]})
user.groups << group2 user.groups << group2
# User adds roles: ["admin"] # User adds roles: ["admin"]
user.update!(custom_claims: { "roles" => ["admin"] }) user.update!(custom_claims: {"roles" => ["admin"]})
token = @service.generate_id_token(user, app) token = @service.generate_id_token(user, app)
decoded = JWT.decode(token, nil, false).first decoded = JWT.decode(token, nil, false).first
@@ -376,11 +376,11 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
# Group has roles: ["user", "reader"] # Group has roles: ["user", "reader"]
group = groups(:admin_group) group = groups(:admin_group)
group.update!(custom_claims: { "roles" => ["user", "reader"] }) group.update!(custom_claims: {"roles" => ["user", "reader"]})
user.groups << group user.groups << group
# User also has "user" role (duplicate) # User also has "user" role (duplicate)
user.update!(custom_claims: { "roles" => ["user", "admin"] }) user.update!(custom_claims: {"roles" => ["user", "admin"]})
token = @service.generate_id_token(user, app) token = @service.generate_id_token(user, app)
decoded = JWT.decode(token, nil, false).first decoded = JWT.decode(token, nil, false).first
@@ -398,11 +398,11 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
# Group has roles array and max_items scalar # Group has roles array and max_items scalar
group = groups(:admin_group) group = groups(:admin_group)
group.update!(custom_claims: { "roles" => ["user"], "max_items" => 10, "theme" => "light" }) group.update!(custom_claims: {"roles" => ["user"], "max_items" => 10, "theme" => "light"})
user.groups << group user.groups << group
# User overrides max_items and theme, adds to roles # User overrides max_items and theme, adds to roles
user.update!(custom_claims: { "roles" => ["admin"], "max_items" => 100, "theme" => "dark" }) user.update!(custom_claims: {"roles" => ["admin"], "max_items" => 100, "theme" => "dark"})
token = @service.generate_id_token(user, app) token = @service.generate_id_token(user, app)
decoded = JWT.decode(token, nil, false).first decoded = JWT.decode(token, nil, false).first
@@ -425,7 +425,7 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
group.update!(custom_claims: { group.update!(custom_claims: {
"config" => { "config" => {
"theme" => "light", "theme" => "light",
"notifications" => { "email" => true } "notifications" => {"email" => true}
} }
}) })
user.groups << group user.groups << group
@@ -434,7 +434,7 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
user.update!(custom_claims: { user.update!(custom_claims: {
"config" => { "config" => {
"language" => "en", "language" => "en",
"notifications" => { "sms" => true } "notifications" => {"sms" => true}
} }
}) })
@@ -454,17 +454,17 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
# Group has roles: ["user"] # Group has roles: ["user"]
group = groups(:admin_group) group = groups(:admin_group)
group.update!(custom_claims: { "roles" => ["user"] }) group.update!(custom_claims: {"roles" => ["user"]})
user.groups << group user.groups << group
# User has roles: ["moderator"] # User has roles: ["moderator"]
user.update!(custom_claims: { "roles" => ["moderator"] }) user.update!(custom_claims: {"roles" => ["moderator"]})
# App-specific has roles: ["app_admin"] # App-specific has roles: ["app_admin"]
ApplicationUserClaim.create!( ApplicationUserClaim.create!(
user: user, user: user,
application: app, application: app,
custom_claims: { "roles" => ["app_admin"] } custom_claims: {"roles" => ["app_admin"]}
) )
token = @service.generate_id_token(user, app) token = @service.generate_id_token(user, app)

View File

@@ -13,13 +13,13 @@ 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: {
"X-Forwarded-Host" => "app.example.com", "X-Forwarded-Host" => "app.example.com",
"X-Forwarded-Uri" => "/dashboard" "X-Forwarded-Uri" => "/dashboard"
}, params: { rd: "https://app.example.com/dashboard" } }, params: {rd: "https://app.example.com/dashboard"}
assert_response 302 assert_response 302
location = response.location location = response.location
@@ -30,13 +30,13 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
assert_equal "https://app.example.com/dashboard", session[:return_to_after_authenticating] assert_equal "https://app.example.com/dashboard", session[:return_to_after_authenticating]
# Step 3: Sign in # Step 3: Sign in
post "/signin", params: { email_address: @user.email_address, password: "password" } post "/signin", params: {email_address: @user.email_address, password: "password"}
assert_response 302 assert_response 302
assert_redirected_to "https://app.example.com/dashboard" assert_redirected_to "https://app.example.com/dashboard"
# Step 4: Authenticated request to protected resource # Step 4: Authenticated request to protected resource
get "/api/verify", headers: { "X-Forwarded-Host" => "app.example.com" } get "/api/verify", headers: {"X-Forwarded-Host" => "app.example.com"}
assert_response 200 assert_response 200
assert_equal @user.email_address, response.headers["x-remote-user"] assert_equal @user.email_address, response.headers["x-remote-user"]
@@ -46,38 +46,38 @@ 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,
headers_config: { user: "", email: "", name: "", groups: "", admin: "" } headers_config: {user: "", email: "", name: "", groups: "", admin: ""}
) )
# Sign in once # Sign in once
post "/signin", params: { email_address: @user.email_address, password: "password" } post "/signin", params: {email_address: @user.email_address, password: "password"}
assert_response 302 assert_response 302
assert_redirected_to "/" assert_redirected_to "/"
# Test access to different applications # Test access to different applications
# App with default headers # App with default headers
get "/api/verify", headers: { "X-Forwarded-Host" => "app.example.com" } get "/api/verify", headers: {"X-Forwarded-Host" => "app.example.com"}
assert_response 200 assert_response 200
assert response.headers.key?("x-remote-user") assert response.headers.key?("x-remote-user")
# Grafana with custom headers # Grafana with custom headers
get "/api/verify", headers: { "X-Forwarded-Host" => "grafana.example.com" } get "/api/verify", headers: {"X-Forwarded-Host" => "grafana.example.com"}
assert_response 200 assert_response 200
assert response.headers.key?("x-webauth-user") assert response.headers.key?("x-webauth-user")
# Metube with no headers # Metube with no headers
get "/api/verify", headers: { "X-Forwarded-Host" => "metube.example.com" } get "/api/verify", headers: {"X-Forwarded-Host" => "metube.example.com"}
assert_response 200 assert_response 200
auth_headers = response.headers.select { |k, v| k.match?(/^x-remote-|^x-webauth-|^x-admin-/i) } auth_headers = response.headers.select { |k, v| k.match?(/^x-remote-|^x-webauth-|^x-admin-/i) }
assert_empty auth_headers assert_empty auth_headers
@@ -98,11 +98,11 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
@user.groups << @group @user.groups << @group
# Sign in # Sign in
post "/signin", params: { email_address: @user.email_address, password: "password" } post "/signin", params: {email_address: @user.email_address, password: "password"}
assert_response 302 assert_response 302
# Should have access (in allowed group) # Should have access (in allowed group)
get "/api/verify", headers: { "X-Forwarded-Host" => "admin.example.com" } get "/api/verify", headers: {"X-Forwarded-Host" => "admin.example.com"}
assert_response 200 assert_response 200
assert_equal @group.name, response.headers["x-remote-groups"] assert_equal @group.name, response.headers["x-remote-groups"]
@@ -110,7 +110,7 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
@user.groups << @group2 @user.groups << @group2
# Should show multiple groups # Should show multiple groups
get "/api/verify", headers: { "X-Forwarded-Host" => "admin.example.com" } get "/api/verify", headers: {"X-Forwarded-Host" => "admin.example.com"}
assert_response 200 assert_response 200
groups_header = response.headers["x-remote-groups"] groups_header = response.headers["x-remote-groups"]
assert_includes groups_header, @group.name assert_includes groups_header, @group.name
@@ -120,13 +120,13 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
@user.groups.clear @user.groups.clear
# Should be denied # Should be denied
get "/api/verify", headers: { "X-Forwarded-Host" => "admin.example.com" } get "/api/verify", headers: {"X-Forwarded-Host" => "admin.example.com"}
assert_response 403 assert_response 403
end end
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
@@ -136,11 +136,11 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
@user.groups.clear @user.groups.clear
# Sign in # Sign in
post "/signin", params: { email_address: @user.email_address, password: "password" } post "/signin", params: {email_address: @user.email_address, password: "password"}
assert_response 302 assert_response 302
# Should have access (bypass mode) # Should have access (bypass mode)
get "/api/verify", headers: { "X-Forwarded-Host" => "public.example.com" } get "/api/verify", headers: {"X-Forwarded-Host" => "public.example.com"}
assert_response 200 assert_response 200
assert_equal @user.email_address, response.headers["x-remote-user"] assert_equal @user.email_address, response.headers["x-remote-user"]
end end
@@ -148,12 +148,12 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
# Security System Tests # Security System Tests
test "session security and isolation" do test "session security and isolation" do
# User A signs in # User A signs in
post "/signin", params: { email_address: @user.email_address, password: "password" } post "/signin", params: {email_address: @user.email_address, password: "password"}
user_a_session = cookies[:session_id] user_a_session = cookies[:session_id]
# User B signs in # User B signs in
delete "/session" delete "/session"
post "/signin", params: { email_address: @admin_user.email_address, password: "password" } post "/signin", params: {email_address: @admin_user.email_address, password: "password"}
user_b_session = cookies[:session_id] user_b_session = cookies[:session_id]
# User A should still be able to access resources # User A should still be able to access resources
@@ -178,11 +178,11 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
test "session expiration and cleanup" do test "session expiration and cleanup" do
# Sign in # Sign in
post "/signin", params: { email_address: @user.email_address, password: "password" } post "/signin", params: {email_address: @user.email_address, password: "password"}
session_id = cookies[:session_id] session_id = cookies[:session_id]
# Should work initially # Should work initially
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" } get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
assert_response 200 assert_response 200
# Manually expire session # Manually expire session
@@ -190,7 +190,7 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
session.update!(expires_at: 1.hour.ago) session.update!(expires_at: 1.hour.ago)
# Should redirect to login # Should redirect to login
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" } get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
assert_response 302 assert_response 302
assert_equal "Session expired", response.headers["x-auth-reason"] assert_equal "Session expired", response.headers["x-auth-reason"]
@@ -200,7 +200,7 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
test "concurrent access with rate limiting considerations" do test "concurrent access with rate limiting considerations" do
# Sign in # Sign in
post "/signin", params: { email_address: @user.email_address, password: "password" } post "/signin", params: {email_address: @user.email_address, password: "password"}
session_cookie = cookies[:session_id] session_cookie = cookies[:session_id]
# Simulate multiple concurrent requests from different IPs # Simulate multiple concurrent requests from different IPs
@@ -244,23 +244,23 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
apps = [ apps = [
{ {
domain: "dashboard.example.com", domain: "dashboard.example.com",
headers_config: { user: "X-DASHBOARD-USER", groups: "X-DASHBOARD-GROUPS" }, headers_config: {user: "X-DASHBOARD-USER", groups: "X-DASHBOARD-GROUPS"},
groups: [@group] groups: [@group]
}, },
{ {
domain: "api.example.com", domain: "api.example.com",
headers_config: { user: "X-API-USER", email: "X-API-EMAIL" }, headers_config: {user: "X-API-USER", email: "X-API-EMAIL"},
groups: [] groups: []
}, },
{ {
domain: "logs.example.com", domain: "logs.example.com",
headers_config: { user: "", email: "", name: "", groups: "", admin: "" }, headers_config: {user: "", email: "", name: "", groups: "", admin: ""},
groups: [] groups: []
} }
] ]
# 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],
@@ -275,19 +275,19 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
@user.groups << @group @user.groups << @group
# Sign in once # Sign in once
post "/signin", params: { email_address: @user.email_address, password: "password" } post "/signin", params: {email_address: @user.email_address, password: "password"}
assert_response 302 assert_response 302
# Test access to each application # Test access to each application
apps.each do |app| apps.each do |app|
get "/api/verify", headers: { "X-Forwarded-Host" => app[:domain] } get "/api/verify", headers: {"X-Forwarded-Host" => app[:domain]}
assert_response 200, "Failed for #{app[:domain]}" assert_response 200, "Failed for #{app[:domain]}"
# Verify headers are correct # Verify headers are correct
if app[:headers_config][:user].present? if app[:headers_config][:user].present?
assert_equal app[:headers_config][:user], assert_equal app[:headers_config][:user],
response.headers.keys.find { |k| k.include?("USER") }, response.headers.keys.find { |k| k.include?("USER") },
"Wrong user header for #{app[:domain]}" "Wrong user header for #{app[:domain]}"
assert_equal @user.email_address, response.headers[app[:headers_config][:user]] assert_equal @user.email_address, response.headers[app[:headers_config][:user]]
else else
# Should have no auth headers # Should have no auth headers
@@ -300,24 +300,24 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
test "domain pattern edge cases" do test "domain pattern edge cases" do
# Test various domain patterns # Test various domain patterns
patterns = [ patterns = [
{ pattern: "*.example.com", domains: ["app.example.com", "api.example.com", "sub.app.example.com"] }, {pattern: "*.example.com", domains: ["app.example.com", "api.example.com", "sub.app.example.com"]},
{ pattern: "api.*.com", domains: ["api.example.com", "api.test.com"] }, {pattern: "api.*.com", domains: ["api.example.com", "api.test.com"]},
{ pattern: "*.*.example.com", domains: ["app.dev.example.com", "api.staging.example.com"] } {pattern: "*.*.example.com", domains: ["app.dev.example.com", "api.staging.example.com"]}
] ]
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
) )
# Sign in # Sign in
post "/signin", params: { email_address: @user.email_address, password: "password" } post "/signin", params: {email_address: @user.email_address, password: "password"}
# Test each domain # Test each domain
pattern_config[:domains].each do |domain| pattern_config[:domains].each do |domain|
get "/api/verify", headers: { "X-Forwarded-Host" => domain } get "/api/verify", headers: {"X-Forwarded-Host" => domain}
assert_response 200, "Failed for pattern #{pattern_config[:pattern]} with domain #{domain}" assert_response 200, "Failed for pattern #{pattern_config[:pattern]} with domain #{domain}"
assert_equal @user.email_address, response.headers["x-remote-user"] assert_equal @user.email_address, response.headers["x-remote-user"]
end end
@@ -330,10 +330,10 @@ 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"}
session_cookie = cookies[:session_id] session_cookie = cookies[:session_id]
# Performance test # Performance test
@@ -374,7 +374,7 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
# Error Recovery System Tests # Error Recovery System Tests
test "graceful degradation with database issues" do test "graceful degradation with database issues" do
# Sign in first # Sign in first
post "/signin", params: { email_address: @user.email_address, password: "password" } post "/signin", params: {email_address: @user.email_address, password: "password"}
assert_response 302 assert_response 302
# Simulate database connection issue by mocking # Simulate database connection issue by mocking
@@ -387,7 +387,7 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
begin begin
# Request should handle the error gracefully # Request should handle the error gracefully
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" } get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
# Should return 302 (redirect to login) rather than 500 error # Should return 302 (redirect to login) rather than 500 error
assert_response 302, "Should gracefully handle database issues" assert_response 302, "Should gracefully handle database issues"
@@ -398,7 +398,7 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
end end
# Normal operation should still work # Normal operation should still work
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" } get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
assert_response 200 assert_response 200
end end
end end

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,
@@ -107,14 +107,14 @@ 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,
@@ -133,10 +133,10 @@ class WebauthnSecurityTest < ActionDispatch::SystemTestCase
) )
# Sign in with WebAuthn # Sign in with WebAuthn
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,
@@ -317,7 +317,7 @@ class WebauthnSecurityTest < ActionDispatch::SystemTestCase
user.update!(webauthn_enabled: true) user.update!(webauthn_enabled: true)
# Sign in with password should still work # Sign in with password should still work
post signin_path, params: { email_address: "webauthn_required_test@example.com", password: "password123" } post signin_path, params: {email_address: "webauthn_required_test@example.com", password: "password123"}
# If WebAuthn is enabled, should offer WebAuthn as an option # If WebAuthn is enabled, should offer WebAuthn as an option
# Implementation should handle password + WebAuthn or passwordless flow # Implementation should handle password + WebAuthn or passwordless flow
@@ -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,