From 93a0edb0a2a63eff383ee0f7c1d14c1700969430 Mon Sep 17 00:00:00 2001 From: Dan Milne Date: Thu, 1 Jan 2026 13:29:44 +1100 Subject: [PATCH] StandardRB fixes --- .../admin/applications_controller.rb | 4 +- app/controllers/api/csp_controller.rb | 36 +-- .../api/forward_auth_controller.rb | 42 +-- app/controllers/application_controller.rb | 1 + app/controllers/concerns/authentication.rb | 250 +++++++++--------- app/controllers/invitations_controller.rb | 13 +- app/controllers/oidc_controller.rb | 114 ++++---- app/controllers/sessions_controller.rb | 37 ++- app/controllers/users_controller.rb | 4 +- app/controllers/webauthn_controller.rb | 60 ++--- app/helpers/application_helper.rb | 18 +- app/helpers/claims_helper.rb | 4 +- app/jobs/backchannel_logout_job.rb | 10 +- app/mailers/application_mailer.rb | 2 +- app/mailers/invitations_mailer.rb | 2 +- app/models/application.rb | 58 ++-- app/models/application_group.rb | 2 +- app/models/application_user_claim.rb | 4 +- app/models/group.rb | 4 +- app/models/oidc_access_token.rb | 2 +- app/models/oidc_authorization_code.rb | 4 +- app/models/oidc_refresh_token.rb | 2 +- app/models/oidc_user_consent.rb | 24 +- app/models/user.rb | 23 +- app/models/user_group.rb | 2 +- app/models/webauthn_credential.rb | 12 +- app/services/concerns/claims_merger.rb | 10 +- app/services/oidc_jwt_service.rb | 6 +- config/application.rb | 16 +- config/environments/development.rb | 7 +- config/environments/production.rb | 36 +-- config/environments/test.rb | 4 +- .../initializers/active_record_encryption.rb | 12 +- .../initializers/content_security_policy.rb | 5 +- config/initializers/csp_local_logger.rb | 18 +- config/initializers/permissions_policy.rb | 12 +- config/initializers/sentry.rb | 8 +- config/initializers/sentry_subscriber.rb | 8 +- config/initializers/token_hmac.rb | 2 +- config/initializers/webauthn.rb | 2 +- config/puma.rb | 1 - config/routes.rb | 28 +- .../20251023053837_create_user_groups.rb | 2 +- ...0251023053938_create_application_groups.rb | 2 +- ...3054038_create_oidc_authorization_codes.rb | 2 +- ...0251023054039_create_oidc_access_tokens.rb | 2 +- ...012201_add_role_mapping_to_applications.rb | 6 +- ...rate_forward_auth_rules_to_applications.rb | 4 +- ...51104042155_create_webauthn_credentials.rb | 2 +- ...251112114852_create_oidc_refresh_tokens.rb | 2 +- ...23052026_create_application_user_claims.rb | 6 +- ...te_active_storage_tables.active_storage.rb | 41 +-- test/application_system_test_case.rb | 2 +- .../api/forward_auth_controller_test.rb | 88 +++--- .../concerns/authentication_test.rb | 10 +- test/controllers/input_validation_test.rb | 12 +- .../invitations_controller_test.rb | 4 +- .../oidc_authorization_code_security_test.rb | 40 +-- test/controllers/oidc_pkce_controller_test.rb | 11 +- test/controllers/passwords_controller_test.rb | 17 +- test/controllers/sessions_controller_test.rb | 4 +- test/controllers/totp_security_test.rb | 36 +-- .../forward_auth_integration_test.rb | 71 +++-- test/integration/invitation_flow_test.rb | 4 +- test/integration/session_security_test.rb | 26 +- .../webauthn_credential_enumeration_test.rb | 6 +- test/jobs/application_job_test.rb | 6 +- test/jobs/invitations_mailer_test.rb | 2 +- test/jobs/passwords_mailer_test.rb | 4 +- test/models/application_user_claim_test.rb | 12 +- test/models/oidc_access_token_test.rb | 8 +- test/models/oidc_authorization_code_test.rb | 4 +- test/models/oidc_user_consent_test.rb | 2 +- test/models/pkce_authorization_code_test.rb | 2 +- test/models/user_password_management_test.rb | 6 +- test/models/user_test.rb | 6 +- test/services/oidc_jwt_service_test.rb | 82 +++--- test/system/forward_auth_system_test.rb | 92 +++---- test/system/webauthn_security_test.rb | 30 +-- 79 files changed, 779 insertions(+), 786 deletions(-) diff --git a/app/controllers/admin/applications_controller.rb b/app/controllers/admin/applications_controller.rb index 01453cc..12a9ad3 100644 --- a/app/controllers/admin/applications_controller.rb +++ b/app/controllers/admin/applications_controller.rb @@ -32,13 +32,11 @@ module Admin client_secret = @application.generate_new_client_secret! end + flash[:notice] = "Application created successfully." if @application.oidc? - flash[:notice] = "Application created successfully." flash[:client_id] = @application.client_id flash[:client_secret] = client_secret if client_secret flash[:public_client] = true if @application.public_client? - else - flash[:notice] = "Application created successfully." end redirect_to admin_application_path(@application) diff --git a/app/controllers/api/csp_controller.rb b/app/controllers/api/csp_controller.rb index 856f4fc..e9ce650 100644 --- a/app/controllers/api/csp_controller.rb +++ b/app/controllers/api/csp_controller.rb @@ -8,7 +8,7 @@ module Api def violation_report # Parse CSP violation report report_data = JSON.parse(request.body.read) - csp_report = report_data['csp-report'] + csp_report = report_data["csp-report"] # Validate that we have a proper CSP report unless csp_report.is_a?(Hash) && csp_report.present? @@ -19,28 +19,28 @@ module Api # Log the violation for security monitoring Rails.logger.warn "CSP Violation Report:" - Rails.logger.warn " Blocked URI: #{csp_report['blocked-uri']}" - Rails.logger.warn " Document URI: #{csp_report['document-uri']}" - Rails.logger.warn " Referrer: #{csp_report['referrer']}" - Rails.logger.warn " Violated Directive: #{csp_report['violated-directive']}" - Rails.logger.warn " Original Policy: #{csp_report['original-policy']}" + Rails.logger.warn " Blocked URI: #{csp_report["blocked-uri"]}" + Rails.logger.warn " Document URI: #{csp_report["document-uri"]}" + Rails.logger.warn " Referrer: #{csp_report["referrer"]}" + Rails.logger.warn " Violated Directive: #{csp_report["violated-directive"]}" + Rails.logger.warn " Original Policy: #{csp_report["original-policy"]}" Rails.logger.warn " User Agent: #{request.user_agent}" Rails.logger.warn " IP Address: #{request.remote_ip}" # Emit structured event for CSP violation # This allows multiple subscribers to process the event (Sentry, local logging, etc.) Rails.event.notify("csp.violation", { - blocked_uri: csp_report['blocked-uri'], - document_uri: csp_report['document-uri'], - referrer: csp_report['referrer'], - violated_directive: csp_report['violated-directive'], - original_policy: csp_report['original-policy'], - disposition: csp_report['disposition'], - effective_directive: csp_report['effective-directive'], - source_file: csp_report['source-file'], - line_number: csp_report['line-number'], - column_number: csp_report['column-number'], - status_code: csp_report['status-code'], + blocked_uri: csp_report["blocked-uri"], + document_uri: csp_report["document-uri"], + referrer: csp_report["referrer"], + violated_directive: csp_report["violated-directive"], + original_policy: csp_report["original-policy"], + disposition: csp_report["disposition"], + effective_directive: csp_report["effective-directive"], + source_file: csp_report["source-file"], + line_number: csp_report["line-number"], + column_number: csp_report["column-number"], + status_code: csp_report["status-code"], user_agent: request.user_agent, ip_address: request.remote_ip, current_user_id: Current.user&.id, @@ -54,4 +54,4 @@ module Api head :bad_request end end -end \ No newline at end of file +end diff --git a/app/controllers/api/forward_auth_controller.rb b/app/controllers/api/forward_auth_controller.rb index 95604a0..98daea8 100644 --- a/app/controllers/api/forward_auth_controller.rb +++ b/app/controllers/api/forward_auth_controller.rb @@ -81,22 +81,26 @@ module Api # User is authenticated and authorized # Return 200 with user information headers using app-specific configuration - headers = app ? app.headers_for_user(user) : Application::DEFAULT_HEADERS.map { |key, header_name| - case key - when :user, :email, :name - [header_name, user.email_address] - when :groups - user.groups.any? ? [header_name, user.groups.pluck(:name).join(",")] : nil - when :admin - [header_name, user.admin? ? "true" : "false"] - end - }.compact.to_h + headers = if app + app.headers_for_user(user) + else + Application::DEFAULT_HEADERS.map { |key, header_name| + case key + when :user, :email, :name + [header_name, user.email_address] + when :groups + user.groups.any? ? [header_name, user.groups.pluck(:name).join(",")] : nil + when :admin + [header_name, user.admin? ? "true" : "false"] + end + }.compact.to_h + end headers.each { |key, value| response.headers[key] = value } # Log what headers we're sending (helpful for debugging) if headers.any? - Rails.logger.debug "ForwardAuth: Headers sent: #{headers.keys.join(', ')}" + Rails.logger.debug "ForwardAuth: Headers sent: #{headers.keys.join(", ")}" else Rails.logger.debug "ForwardAuth: No headers sent (access only)" end @@ -123,14 +127,13 @@ module Api # Delete the token immediately (one-time use) Rails.cache.delete("forward_auth_token:#{token}") - session_id + session_id end def extract_session_id # Extract session ID from cookie # Rails uses signed cookies by default - session_id = cookies.signed[:session_id] - session_id + cookies.signed[:session_id] end def extract_app_from_headers @@ -155,7 +158,7 @@ module Api original_uri = request.headers["X-Forwarded-Uri"] || request.headers["X-Forwarded-Path"] || "/" # Debug logging to see what headers we're getting - Rails.logger.info "ForwardAuth Headers: Host=#{request.headers['Host']}, X-Forwarded-Host=#{original_host}, X-Forwarded-Uri=#{request.headers['X-Forwarded-Uri']}, X-Forwarded-Path=#{request.headers['X-Forwarded-Path']}" + Rails.logger.info "ForwardAuth Headers: Host=#{request.headers["Host"]}, X-Forwarded-Host=#{original_host}, X-Forwarded-Uri=#{request.headers["X-Forwarded-Uri"]}, X-Forwarded-Path=#{request.headers["X-Forwarded-Path"]}" original_url = if original_host # Use the forwarded host and URI (original behavior) @@ -203,7 +206,7 @@ module Api return nil unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS) # Only allow HTTPS in production - return nil unless Rails.env.development? || uri.scheme == 'https' + return nil unless Rails.env.development? || uri.scheme == "https" redirect_domain = uri.host.downcase return nil unless redirect_domain.present? @@ -214,7 +217,6 @@ module Api end matching_app ? url : nil - rescue URI::InvalidURIError nil end @@ -233,13 +235,13 @@ module Api return redirect_url if redirect_url.present? # Try CLINCH_HOST environment variable first - if ENV['CLINCH_HOST'].present? - host = ENV['CLINCH_HOST'] + if ENV["CLINCH_HOST"].present? + host = ENV["CLINCH_HOST"] # Ensure URL has https:// protocol host.match?(/^https?:\/\//) ? host : "https://#{host}" else # Fallback to the request host - request_host = request.host || request.headers['X-Forwarded-Host'] + request_host = request.host || request.headers["X-Forwarded-Host"] if request_host.present? Rails.logger.warn "ForwardAuth: CLINCH_HOST not set, using request host: #{request_host}" "https://#{request_host}" diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 5b17219..0f0ea9f 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,5 +1,6 @@ class ApplicationController < ActionController::Base include Authentication + # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has. allow_browser versions: :modern diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index 9f56e5f..c60d06f 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -1,6 +1,6 @@ -require 'uri' -require 'public_suffix' -require 'ipaddr' +require "uri" +require "public_suffix" +require "ipaddr" module Authentication extend ActiveSupport::Concern @@ -17,133 +17,137 @@ module Authentication end 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 - def require_authentication - resume_session || request_authentication - end + # 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 - def resume_session - Current.session ||= find_session_by_cookie - 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) - def find_session_by_cookie - Session.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id] - end + # Store it with an expiry of 60 seconds + Rails.cache.write( + "forward_auth_token:#{token}", + session_obj.id, + expires_in: 60.seconds + ) - def request_authentication - session[:return_to_after_authenticating] = request.url - redirect_to signin_path - end + # 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) - def after_authentication_url - return_url = session[:return_to_after_authenticating] - final_url = session.delete(:return_to_after_authenticating) || root_url - final_url - end + # 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) - 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 - 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 + # Update the session with the tokenized URL + controller_session[:return_to_after_authenticating] = uri.to_s end end + end end diff --git a/app/controllers/invitations_controller.rb b/app/controllers/invitations_controller.rb index f5daebd..395b954 100644 --- a/app/controllers/invitations_controller.rb +++ b/app/controllers/invitations_controller.rb @@ -1,7 +1,8 @@ class InvitationsController < ApplicationController include Authentication + 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 # Show the password setup form @@ -35,16 +36,16 @@ class InvitationsController < ApplicationController # Check if user is still pending invitation if @user.nil? redirect_to signin_path, alert: "Invitation link is invalid or has expired." - return false + false elsif @user.pending_invitation? # User is valid and pending - proceed - return true + true else redirect_to signin_path, alert: "This invitation has already been used or is no longer valid." - return false + false end rescue ActiveSupport::MessageVerifier::InvalidSignature redirect_to signin_path, alert: "Invitation link is invalid or has expired." - return false + false end -end \ No newline at end of file +end diff --git a/app/controllers/oidc_controller.rb b/app/controllers/oidc_controller.rb index 423df5c..575803a 100644 --- a/app/controllers/oidc_controller.rb +++ b/app/controllers/oidc_controller.rb @@ -5,7 +5,7 @@ class OidcController < ApplicationController # Rate limiting to prevent brute force and abuse rate_limit to: 60, within: 1.minute, only: [:token, :revoke], with: -> { - render json: { error: "too_many_requests", error_description: "Rate limit exceeded. Try again later." }, status: :too_many_requests + render json: {error: "too_many_requests", error_description: "Rate limit exceeded. Try again later."}, status: :too_many_requests } rate_limit to: 30, within: 1.minute, only: [:authorize, :consent], with: -> { render plain: "Too many authorization attempts. Try again later.", status: :too_many_requests @@ -63,7 +63,7 @@ class OidcController < ApplicationController error_details << "redirect_uri is required" unless redirect_uri.present? error_details << "response_type must be 'code'" unless response_type == "code" - render plain: "Invalid request: #{error_details.join(', ')}", status: :bad_request + render plain: "Invalid request: #{error_details.join(", ")}", status: :bad_request return end @@ -90,7 +90,7 @@ class OidcController < ApplicationController Rails.logger.error "OAuth: Available OIDC applications: #{all_oidc_apps.pluck(:id, :client_id, :name)}" error_msg = if Rails.env.development? - "Invalid request: Application not found for client_id '#{client_id}'. Available OIDC applications: #{all_oidc_apps.pluck(:name, :client_id).map { |name, id| "#{name} (#{id})" }.join(', ')}" + "Invalid request: Application not found for client_id '#{client_id}'. Available OIDC applications: #{all_oidc_apps.pluck(:name, :client_id).map { |name, id| "#{name} (#{id})" }.join(", ")}" else "Invalid request: Application not found" end @@ -105,7 +105,7 @@ class OidcController < ApplicationController # For development, show detailed error error_msg = if Rails.env.development? - "Invalid request: Redirect URI mismatch. Application is configured for: #{@application.parsed_redirect_uris.join(', ')}, but received: #{redirect_uri}" + "Invalid request: Redirect URI mismatch. Application is configured for: #{@application.parsed_redirect_uris.join(", ")}, but received: #{redirect_uri}" else "Invalid request: Redirect URI not registered for this application" end @@ -223,22 +223,22 @@ class OidcController < ApplicationController # User denied consent if params[:deny].present? session.delete(:oauth_params) - error_uri = "#{oauth_params['redirect_uri']}?error=access_denied" - error_uri += "&state=#{CGI.escape(oauth_params['state'])}" if oauth_params['state'] + error_uri = "#{oauth_params["redirect_uri"]}?error=access_denied" + error_uri += "&state=#{CGI.escape(oauth_params["state"])}" if oauth_params["state"] redirect_to error_uri, allow_other_host: true return end # Find the application - client_id = oauth_params['client_id'] + client_id = oauth_params["client_id"] application = Application.find_by(client_id: client_id, app_type: "oidc") # Check if application is active (redirect with OAuth error) unless application&.active? Rails.logger.error "OAuth: Application is not active: #{application&.name || client_id}" session.delete(:oauth_params) - error_uri = "#{oauth_params['redirect_uri']}?error=unauthorized_client&error_description=Application+is+not+active" - error_uri += "&state=#{CGI.escape(oauth_params['state'])}" if oauth_params['state'].present? + error_uri = "#{oauth_params["redirect_uri"]}?error=unauthorized_client&error_description=Application+is+not+active" + error_uri += "&state=#{CGI.escape(oauth_params["state"])}" if oauth_params["state"].present? redirect_to error_uri, allow_other_host: true return end @@ -246,9 +246,9 @@ class OidcController < ApplicationController user = Current.session.user # Record user consent - requested_scopes = oauth_params['scope'].split(' ') + requested_scopes = oauth_params["scope"].split(" ") consent = OidcUserConsent.find_or_initialize_by(user: user, application: application) - consent.scopes_granted = requested_scopes.join(' ') + consent.scopes_granted = requested_scopes.join(" ") consent.granted_at = Time.current consent.save! @@ -256,11 +256,11 @@ class OidcController < ApplicationController auth_code = OidcAuthorizationCode.create!( application: application, user: user, - redirect_uri: oauth_params['redirect_uri'], - scope: oauth_params['scope'], - nonce: oauth_params['nonce'], - code_challenge: oauth_params['code_challenge'], - code_challenge_method: oauth_params['code_challenge_method'], + redirect_uri: oauth_params["redirect_uri"], + scope: oauth_params["scope"], + nonce: oauth_params["nonce"], + code_challenge: oauth_params["code_challenge"], + code_challenge_method: oauth_params["code_challenge_method"], auth_time: Current.session.created_at.to_i, acr: Current.session.acr, expires_at: 10.minutes.from_now @@ -270,8 +270,8 @@ class OidcController < ApplicationController session.delete(:oauth_params) # Redirect back to client with authorization code (plaintext) - redirect_uri = "#{oauth_params['redirect_uri']}?code=#{auth_code.plaintext_code}" - redirect_uri += "&state=#{CGI.escape(oauth_params['state'])}" if oauth_params['state'] + redirect_uri = "#{oauth_params["redirect_uri"]}?code=#{auth_code.plaintext_code}" + redirect_uri += "&state=#{CGI.escape(oauth_params["state"])}" if oauth_params["state"] redirect_to redirect_uri, allow_other_host: true end @@ -286,7 +286,7 @@ class OidcController < ApplicationController when "refresh_token" handle_refresh_token_grant else - render json: { error: "unsupported_grant_type" }, status: :bad_request + render json: {error: "unsupported_grant_type"}, status: :bad_request end end @@ -295,14 +295,14 @@ class OidcController < ApplicationController client_id, client_secret = extract_client_credentials 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 end # Find the application application = Application.find_by(client_id: client_id) 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 end @@ -313,7 +313,7 @@ class OidcController < ApplicationController else # Confidential clients MUST provide valid 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 end end @@ -321,7 +321,7 @@ class OidcController < ApplicationController # Check if application is active unless application.active? 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 end @@ -334,7 +334,7 @@ class OidcController < ApplicationController auth_code = OidcAuthorizationCode.find_by_plaintext(code) unless auth_code && auth_code.application == application - render json: { error: "invalid_grant" }, status: :bad_request + render json: {error: "invalid_grant"}, status: :bad_request return end @@ -365,13 +365,13 @@ class OidcController < ApplicationController # Check if code is expired 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 end # Validate redirect URI matches 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 end @@ -413,7 +413,7 @@ class OidcController < ApplicationController unless consent 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 end @@ -440,7 +440,7 @@ class OidcController < ApplicationController } end rescue ActiveRecord::RecordNotFound - render json: { error: "invalid_grant" }, status: :bad_request + render json: {error: "invalid_grant"}, status: :bad_request end end @@ -449,14 +449,14 @@ class OidcController < ApplicationController client_id, client_secret = extract_client_credentials 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 end # Find the application application = Application.find_by(client_id: client_id) 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 end @@ -467,7 +467,7 @@ class OidcController < ApplicationController else # Confidential clients MUST provide valid 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 end end @@ -475,14 +475,14 @@ class OidcController < ApplicationController # Check if application is active unless application.active? 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 end # Get the refresh token refresh_token = params[:refresh_token] 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 end @@ -491,13 +491,13 @@ class OidcController < ApplicationController # Verify the token belongs to the correct 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 end # Check if refresh token is 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 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}" 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 end @@ -541,7 +541,7 @@ class OidcController < ApplicationController unless consent 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 end @@ -566,7 +566,7 @@ class OidcController < ApplicationController scope: refresh_token_record.scope } rescue ActiveRecord::RecordNotFound - render json: { error: "invalid_grant" }, status: :bad_request + render json: {error: "invalid_grant"}, status: :bad_request end # GET /oauth/userinfo @@ -650,7 +650,7 @@ class OidcController < ApplicationController # Find and validate the application application = Application.find_by(client_id: client_id) - unless application && application.authenticate_client_secret(client_secret) + unless application&.authenticate_client_secret(client_secret) Rails.logger.warn "OAuth: Token revocation attempted for invalid application: #{client_id}" head :ok return @@ -669,7 +669,7 @@ class OidcController < ApplicationController unless token.present? # 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 end @@ -695,7 +695,7 @@ class OidcController < ApplicationController if access_token_record access_token_record.revoke! Rails.logger.info "OAuth: Access token revoked for application #{application.name}" - revoked = true + true end end @@ -709,7 +709,7 @@ class OidcController < ApplicationController # OpenID Connect RP-Initiated Logout # Handle id_token_hint and post_logout_redirect_uri parameters - id_token_hint = params[:id_token_hint] + params[:id_token_hint] post_logout_redirect_uri = params[:post_logout_redirect_uri] state = params[:state] @@ -763,7 +763,7 @@ class OidcController < ApplicationController end # 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 unless code_verifier.present? @@ -787,18 +787,18 @@ class OidcController < ApplicationController # Recreate code challenge based on method expected_challenge = case auth_code.code_challenge_method - when "plain" - code_verifier - when "S256" - Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false) - else - return { - valid: false, - error: "server_error", - error_description: "Unsupported code challenge method", - status: :internal_server_error - } - end + when "plain" + code_verifier + when "S256" + Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false) + else + return { + valid: false, + error: "server_error", + error_description: "Unsupported code challenge method", + status: :internal_server_error + } + end # Validate the code challenge unless auth_code.code_challenge == expected_challenge @@ -810,7 +810,7 @@ class OidcController < ApplicationController } end - { valid: true } + {valid: true} end 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) # Only allow HTTPS in production - return nil if Rails.env.production? && parsed_uri.scheme != 'https' + return nil if Rails.env.production? && parsed_uri.scheme != "https" # Check if URI matches any registered OIDC application's redirect URIs # According to OIDC spec, post_logout_redirect_uri should be pre-registered diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index b379f91..4d80283 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -1,22 +1,22 @@ 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: 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 # Redirect to signup if this is first run if User.count.zero? respond_to do |format| 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 return end respond_to do |format| 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 @@ -127,7 +127,7 @@ class SessionsController < ApplicationController # Invalid code redirect_to totp_verification_path, alert: "Invalid verification code. Please try again." - return + nil end # Just render the form @@ -155,14 +155,14 @@ class SessionsController < ApplicationController email = params[:email]&.strip&.downcase if email.blank? - render json: { error: "Email is required" }, status: :unprocessable_entity + render json: {error: "Email is required"}, status: :unprocessable_entity return end user = User.find_by(email_address: email) 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 end @@ -191,10 +191,9 @@ class SessionsController < ApplicationController session[:webauthn_challenge] = options.challenge render json: options - rescue => e Rails.logger.error "WebAuthn challenge generation error: #{e.message}" - render json: { error: "Failed to generate WebAuthn challenge" }, status: :internal_server_error + render json: {error: "Failed to generate WebAuthn challenge"}, status: :internal_server_error end end @@ -202,21 +201,21 @@ class SessionsController < ApplicationController # Get pending user from session user_id = session[:pending_webauthn_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 end user = User.find_by(id: user_id) unless user 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 end # Get the credential and assertion from params credential_data = params[:credential] 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 end @@ -224,7 +223,7 @@ class SessionsController < ApplicationController challenge = session.delete(:webauthn_challenge) if challenge.blank? - render json: { error: "Invalid or expired session" }, status: :unprocessable_entity + render json: {error: "Invalid or expired session"}, status: :unprocessable_entity return end @@ -237,7 +236,7 @@ class SessionsController < ApplicationController stored_credential = user.webauthn_credential_for(external_id) if stored_credential.nil? - render json: { error: "Credential not found" }, status: :unprocessable_entity + render json: {error: "Credential not found"}, status: :unprocessable_entity return end @@ -276,16 +275,15 @@ class SessionsController < ApplicationController redirect_to: after_authentication_url, message: "Signed in successfully with passkey" } - rescue WebAuthn::Error => e Rails.logger.error "WebAuthn verification error: #{e.message}" - render json: { error: "Authentication failed: #{e.message}" }, status: :unprocessable_entity + render json: {error: "Authentication failed: #{e.message}"}, status: :unprocessable_entity rescue JSON::ParserError => e 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 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 @@ -301,7 +299,7 @@ class SessionsController < ApplicationController return nil unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS) # Only allow HTTPS in production - return nil unless Rails.env.development? || uri.scheme == 'https' + return nil unless Rails.env.development? || uri.scheme == "https" redirect_domain = uri.host.downcase return nil unless redirect_domain.present? @@ -312,7 +310,6 @@ class SessionsController < ApplicationController end matching_app ? url : nil - rescue URI::InvalidURIError nil end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index a7fc08c..6018841 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -1,6 +1,6 @@ class UsersController < ApplicationController - allow_unauthenticated_access only: %i[ new create ] - before_action :ensure_first_run, only: %i[ new create ] + allow_unauthenticated_access only: %i[new create] + before_action :ensure_first_run, only: %i[new create] def new @user = User.new diff --git a/app/controllers/webauthn_controller.rb b/app/controllers/webauthn_controller.rb index 5c17446..d058148 100644 --- a/app/controllers/webauthn_controller.rb +++ b/app/controllers/webauthn_controller.rb @@ -4,7 +4,7 @@ class WebauthnController < ApplicationController # Rate limit check endpoint to prevent enumeration attacks 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 @@ -16,7 +16,7 @@ class WebauthnController < ApplicationController # Generate registration challenge for creating a new passkey def challenge 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( user: { @@ -44,7 +44,7 @@ class WebauthnController < ApplicationController credential_data, nickname = extract_credential_params 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 end @@ -52,7 +52,7 @@ class WebauthnController < ApplicationController challenge = session.delete(:webauthn_challenge) if challenge.blank? - render json: { error: "Invalid or expired session" }, status: :unprocessable_entity + render json: {error: "Invalid or expired session"}, status: :unprocessable_entity return end @@ -68,10 +68,10 @@ class WebauthnController < ApplicationController client_extension_results = response["clientExtensionResults"] || {} authenticator_type = if response["response"]["authenticatorAttachment"] == "cross-platform" - "cross-platform" - else - "platform" - end + "cross-platform" + else + "platform" + end # Determine if this is a backup/synced credential backup_eligible = client_extension_results["credProps"]&.dig("rk") || false @@ -79,7 +79,7 @@ class WebauthnController < ApplicationController # Store the credential 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!( external_id: Base64.urlsafe_encode64(webauthn_credential.id), @@ -96,13 +96,12 @@ class WebauthnController < ApplicationController message: "Passkey '#{nickname}' registered successfully", credential_id: @webauthn_credential.id } - rescue WebAuthn::Error => e Rails.logger.error "WebAuthn registration error: #{e.message}" - render json: { error: "Failed to register passkey: #{e.message}" }, status: :unprocessable_entity + render json: {error: "Failed to register passkey: #{e.message}"}, status: :unprocessable_entity rescue => e 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 @@ -115,7 +114,7 @@ class WebauthnController < ApplicationController respond_to do |format| format.html { redirect_to profile_path, - notice: "Passkey '#{nickname}' has been removed" + notice: "Passkey '#{nickname}' has been removed" } format.json { render json: { @@ -133,7 +132,7 @@ class WebauthnController < ApplicationController email = params[:email]&.strip&.downcase if email.blank? - render json: { has_webauthn: false, requires_webauthn: false } + render json: {has_webauthn: false, requires_webauthn: false} return end @@ -142,7 +141,7 @@ class WebauthnController < ApplicationController # Security: Return identical response for non-existent users # Combined with rate limiting (10/min), this prevents account enumeration if user.nil? - render json: { has_webauthn: false, requires_webauthn: false } + render json: {has_webauthn: false, requires_webauthn: false} return end @@ -158,37 +157,36 @@ class WebauthnController < ApplicationController def extract_credential_params # Use require.permit which is working and reliable # The JavaScript sends params both directly and wrapped in webauthn key - begin - # Try direct parameters first - credential_params = params.require(:credential).permit(:id, :rawId, :type, response: {}, clientExtensionResults: {}) - nickname = params.require(:nickname) - [credential_params, nickname] - rescue ActionController::ParameterMissing - Rails.logger.error("Using the fallback parameters") - # Fallback to webauthn-wrapped parameters - webauthn_params = params.require(:webauthn).permit(:nickname, credential: [:id, :rawId, :type, response: {}, clientExtensionResults: {}]) - [webauthn_params[:credential], webauthn_params[:nickname]] - end + + # Try direct parameters first + credential_params = params.require(:credential).permit(:id, :rawId, :type, response: {}, clientExtensionResults: {}) + nickname = params.require(:nickname) + [credential_params, nickname] + rescue ActionController::ParameterMissing + Rails.logger.error("Using the fallback parameters") + # Fallback to webauthn-wrapped parameters + webauthn_params = params.require(:webauthn).permit(:nickname, credential: [:id, :rawId, :type, response: {}, clientExtensionResults: {}]) + [webauthn_params[:credential], webauthn_params[:nickname]] end def set_webauthn_credential 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]) rescue ActiveRecord::RecordNotFound respond_to do |format| 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 # Helper method to convert Base64 to Base64URL if needed def base64_to_base64url(str) - str.gsub('+', '-').gsub('/', '_').gsub(/=+$/, '') + str.tr("+", "-").tr("/", "_").gsub(/=+$/, "") end # Helper method to convert Base64URL to Base64 if needed def base64url_to_base64(str) - str.gsub('-', '+').gsub('_', '/') + '=' * (4 - str.length % 4) % 4 + str.tr("-", "+").tr("_", "/") + "=" * (4 - str.length % 4) % 4 end -end \ No newline at end of file +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 12c82c4..f28c936 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -6,10 +6,10 @@ module ApplicationHelper smtp_port = ENV["SMTP_PORT"] smtp_address.present? && - smtp_port.present? && - smtp_address != "localhost" && - !smtp_address.start_with?("127.0.0.1") && - !smtp_address.start_with?("localhost") + smtp_port.present? && + smtp_address != "localhost" && + !smtp_address.start_with?("127.0.0.1") && + !smtp_address.start_with?("localhost") end def email_delivery_method @@ -22,11 +22,11 @@ module ApplicationHelper def border_class_for(type) case type.to_s - when 'notice' then 'border-green-200' - when 'alert', 'error' then 'border-red-200' - when 'warning' then 'border-yellow-200' - when 'info' then 'border-blue-200' - else 'border-gray-200' + when "notice" then "border-green-200" + when "alert", "error" then "border-red-200" + when "warning" then "border-yellow-200" + when "info" then "border-blue-200" + else "border-gray-200" end end end diff --git a/app/helpers/claims_helper.rb b/app/helpers/claims_helper.rb index 3ddbd74..f546212 100644 --- a/app/helpers/claims_helper.rb +++ b/app/helpers/claims_helper.rb @@ -25,9 +25,7 @@ module ClaimsHelper claims = deep_merge_claims(claims, user.parsed_custom_claims) # Merge app-specific claims (arrays are combined) - claims = deep_merge_claims(claims, application.custom_claims_for_user(user)) - - claims + deep_merge_claims(claims, application.custom_claims_for_user(user)) end # Get claim sources breakdown for display diff --git a/app/jobs/backchannel_logout_job.rb b/app/jobs/backchannel_logout_job.rb index ee37a87..9a97e61 100644 --- a/app/jobs/backchannel_logout_job.rb +++ b/app/jobs/backchannel_logout_job.rb @@ -29,10 +29,10 @@ class BackchannelLogoutJob < ApplicationJob uri = URI.parse(application.backchannel_logout_uri) begin - response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https', open_timeout: 5, read_timeout: 5) do |http| - request = Net::HTTP::Post.new(uri.path.presence || '/') - request['Content-Type'] = 'application/x-www-form-urlencoded' - request.set_form_data({ logout_token: logout_token }) + response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 5, read_timeout: 5) do |http| + request = Net::HTTP::Post.new(uri.path.presence || "/") + request["Content-Type"] = "application/x-www-form-urlencoded" + request.set_form_data({logout_token: logout_token}) http.request(request) end @@ -44,7 +44,7 @@ class BackchannelLogoutJob < ApplicationJob rescue Net::OpenTimeout, Net::ReadTimeout => e Rails.logger.warn "BackchannelLogout: Timeout sending logout to #{application.name} (#{application.backchannel_logout_uri}): #{e.message}" raise # Retry on timeout - rescue StandardError => e + rescue => e Rails.logger.error "BackchannelLogout: Failed to send logout to #{application.name} (#{application.backchannel_logout_uri}): #{e.class} - #{e.message}" raise # Retry on error end diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb index 9f3d1c0..7e98bcf 100644 --- a/app/mailers/application_mailer.rb +++ b/app/mailers/application_mailer.rb @@ -1,4 +1,4 @@ class ApplicationMailer < ActionMailer::Base - default from: ENV.fetch('CLINCH_FROM_EMAIL', 'clinch@example.com') + default from: ENV.fetch("CLINCH_FROM_EMAIL", "clinch@example.com") layout "mailer" end diff --git a/app/mailers/invitations_mailer.rb b/app/mailers/invitations_mailer.rb index 4943ccc..42a7979 100644 --- a/app/mailers/invitations_mailer.rb +++ b/app/mailers/invitations_mailer.rb @@ -3,4 +3,4 @@ class InvitationsMailer < ApplicationMailer @user = user mail subject: "You're invited to join Clinch", to: user.email_address end -end \ No newline at end of file +end diff --git a/app/models/application.rb b/app/models/application.rb index 14c7c21..beddc91 100644 --- a/app/models/application.rb +++ b/app/models/application.rb @@ -19,16 +19,16 @@ class Application < ApplicationRecord has_many :oidc_user_consents, dependent: :destroy validates :name, presence: true - validates :slug, presence: true, uniqueness: { case_sensitive: false }, - format: { with: /\A[a-z0-9\-]+\z/, message: "only lowercase letters, numbers, and hyphens" } + validates :slug, presence: true, uniqueness: {case_sensitive: false}, + format: {with: /\A[a-z0-9-]+\z/, message: "only lowercase letters, numbers, and hyphens"} validates :app_type, presence: true, - inclusion: { in: %w[oidc forward_auth] } - validates :client_id, uniqueness: { allow_nil: true } + inclusion: {in: %w[oidc forward_auth]} + validates :client_id, uniqueness: {allow_nil: true} validates :client_secret, presence: true, on: :create, if: -> { oidc? && confidential_client? } - validates :domain_pattern, presence: true, uniqueness: { case_sensitive: false }, if: :forward_auth? - validates :landing_url, format: { with: URI::regexp(%w[http https]), allow_nil: true, message: "must be a valid URL" } + validates :domain_pattern, presence: true, uniqueness: {case_sensitive: false}, if: :forward_auth? + validates :landing_url, format: {with: URI::RFC2396_PARSER.make_regexp(%w[http https]), allow_nil: true, message: "must be a valid URL"} validates :backchannel_logout_uri, format: { - with: URI::regexp(%w[http https]), + with: URI::RFC2396_PARSER.make_regexp(%w[http https]), allow_nil: true, message: "must be a valid HTTP or HTTPS URL" } @@ -38,9 +38,9 @@ class Application < ApplicationRecord validate :icon_validation, if: -> { icon.attached? } # 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 :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 :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 :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 :domain_pattern, with: ->(pattern) { @@ -56,11 +56,11 @@ class Application < ApplicationRecord # Default header configuration for ForwardAuth DEFAULT_HEADERS = { - user: 'X-Remote-User', - email: 'X-Remote-Email', - name: 'X-Remote-Name', - groups: 'X-Remote-Groups', - admin: 'X-Remote-Admin' + user: "X-Remote-User", + email: "X-Remote-Email", + name: "X-Remote-Name", + groups: "X-Remote-Groups", + admin: "X-Remote-Admin" }.freeze # Scopes @@ -135,8 +135,8 @@ class Application < ApplicationRecord def matches_domain?(domain) return false if domain.blank? || !forward_auth? - pattern = domain_pattern.gsub('.', '\.') - pattern = pattern.gsub('*', '[^.]*') + pattern = domain_pattern.gsub(".", '\.') + pattern = pattern.gsub("*", "[^.]*") regex = Regexp.new("^#{pattern}$", Regexp::IGNORECASE) regex.match?(domain.downcase) @@ -144,18 +144,18 @@ class Application < ApplicationRecord # Policy determination based on user status (for ForwardAuth) def policy_for_user(user) - return 'deny' unless active? - return 'deny' unless user.active? + return "deny" unless active? + return "deny" unless user.active? # If no groups specified, bypass authentication - return 'bypass' if allowed_groups.empty? + return "bypass" if allowed_groups.empty? # If user is in allowed groups, determine auth level if user_allowed?(user) # Require 2FA if user has TOTP configured, otherwise one factor - user.totp_enabled? ? 'two_factor' : 'one_factor' + user.totp_enabled? ? "two_factor" : "one_factor" else - 'deny' + "deny" end end @@ -197,7 +197,7 @@ class Application < ApplicationRecord def generate_new_client_secret! secret = SecureRandom.urlsafe_base64(48) self.client_secret = secret - self.save! + save! secret end @@ -242,7 +242,7 @@ class Application < ApplicationRecord # (i.e., has valid, non-revoked tokens) def user_has_active_session?(user) 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 private @@ -260,14 +260,14 @@ class Application < ApplicationRecord return unless icon.attached? # Check content type - allowed_types = ['image/png', 'image/jpg', 'image/jpeg', 'image/gif', 'image/svg+xml'] + allowed_types = ["image/png", "image/jpg", "image/jpeg", "image/gif", "image/svg+xml"] unless allowed_types.include?(icon.content_type) - errors.add(:icon, 'must be a PNG, JPG, GIF, or SVG image') + errors.add(:icon, "must be a PNG, JPG, GIF, or SVG image") end # Check file size (2MB limit) if icon.blob.byte_size > 2.megabytes - errors.add(:icon, 'must be less than 2MB') + errors.add(:icon, "must be less than 2MB") end end @@ -302,8 +302,8 @@ class Application < ApplicationRecord begin uri = URI.parse(backchannel_logout_uri) - unless uri.scheme == 'https' - errors.add(:backchannel_logout_uri, 'must use HTTPS in production') + unless uri.scheme == "https" + errors.add(:backchannel_logout_uri, "must use HTTPS in production") end rescue URI::InvalidURIError # Let the format validator handle invalid URIs diff --git a/app/models/application_group.rb b/app/models/application_group.rb index 4eb0e31..5eb95a0 100644 --- a/app/models/application_group.rb +++ b/app/models/application_group.rb @@ -2,5 +2,5 @@ class ApplicationGroup < ApplicationRecord belongs_to :application belongs_to :group - validates :application_id, uniqueness: { scope: :group_id } + validates :application_id, uniqueness: {scope: :group_id} end diff --git a/app/models/application_user_claim.rb b/app/models/application_user_claim.rb index 2e67073..76d4e36 100644 --- a/app/models/application_user_claim.rb +++ b/app/models/application_user_claim.rb @@ -9,7 +9,7 @@ class ApplicationUserClaim < ApplicationRecord groups ].freeze - validates :user_id, uniqueness: { scope: :application_id } + validates :user_id, uniqueness: {scope: :application_id} validate :no_reserved_claim_names # Parse custom_claims JSON field @@ -25,7 +25,7 @@ class ApplicationUserClaim < ApplicationRecord reserved_used = parsed_custom_claims.keys.map(&:to_s) & RESERVED_CLAIMS if reserved_used.any? - errors.add(:custom_claims, "cannot override reserved OIDC claims: #{reserved_used.join(', ')}") + errors.add(:custom_claims, "cannot override reserved OIDC claims: #{reserved_used.join(", ")}") end end end diff --git a/app/models/group.rb b/app/models/group.rb index 4a1c77d..d7dac0f 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -11,7 +11,7 @@ class Group < ApplicationRecord groups ].freeze - validates :name, presence: true, uniqueness: { case_sensitive: false } + validates :name, presence: true, uniqueness: {case_sensitive: false} normalizes :name, with: ->(name) { name.strip.downcase } validate :no_reserved_claim_names @@ -28,7 +28,7 @@ class Group < ApplicationRecord reserved_used = parsed_custom_claims.keys.map(&:to_s) & RESERVED_CLAIMS if reserved_used.any? - errors.add(:custom_claims, "cannot override reserved OIDC claims: #{reserved_used.join(', ')}") + errors.add(:custom_claims, "cannot override reserved OIDC claims: #{reserved_used.join(", ")}") end end end diff --git a/app/models/oidc_access_token.rb b/app/models/oidc_access_token.rb index 596da20..69f42cd 100644 --- a/app/models/oidc_access_token.rb +++ b/app/models/oidc_access_token.rb @@ -25,7 +25,7 @@ class OidcAccessToken < ApplicationRecord # Compute HMAC for token lookup def self.compute_token_hmac(plaintext_token) - OpenSSL::HMAC.hexdigest('SHA256', TokenHmac::KEY, plaintext_token) + OpenSSL::HMAC.hexdigest("SHA256", TokenHmac::KEY, plaintext_token) end def expired? diff --git a/app/models/oidc_authorization_code.rb b/app/models/oidc_authorization_code.rb index aaf4f95..df3e791 100644 --- a/app/models/oidc_authorization_code.rb +++ b/app/models/oidc_authorization_code.rb @@ -9,7 +9,7 @@ class OidcAuthorizationCode < ApplicationRecord validates :code_hmac, presence: true, uniqueness: 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? } scope :valid, -> { where(used: false).where("expires_at > ?", Time.current) } @@ -25,7 +25,7 @@ class OidcAuthorizationCode < ApplicationRecord # Compute HMAC for code lookup def self.compute_code_hmac(plaintext_code) - OpenSSL::HMAC.hexdigest('SHA256', TokenHmac::KEY, plaintext_code) + OpenSSL::HMAC.hexdigest("SHA256", TokenHmac::KEY, plaintext_code) end def expired? diff --git a/app/models/oidc_refresh_token.rb b/app/models/oidc_refresh_token.rb index 8d83584..a4d0724 100644 --- a/app/models/oidc_refresh_token.rb +++ b/app/models/oidc_refresh_token.rb @@ -29,7 +29,7 @@ class OidcRefreshToken < ApplicationRecord # Compute HMAC for token lookup def self.compute_token_hmac(plaintext_token) - OpenSSL::HMAC.hexdigest('SHA256', TokenHmac::KEY, plaintext_token) + OpenSSL::HMAC.hexdigest("SHA256", TokenHmac::KEY, plaintext_token) end def expired? diff --git a/app/models/oidc_user_consent.rb b/app/models/oidc_user_consent.rb index 09dcf2e..ebb3c3f 100644 --- a/app/models/oidc_user_consent.rb +++ b/app/models/oidc_user_consent.rb @@ -3,19 +3,19 @@ class OidcUserConsent < ApplicationRecord belongs_to :application 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_sid, on: :create # Parse scopes_granted into an array def scopes - scopes_granted.split(' ') + scopes_granted.split(" ") end # Set scopes from an array def scopes=(scope_array) - self.scopes_granted = Array(scope_array).uniq.join(' ') + self.scopes_granted = Array(scope_array).uniq.join(" ") end # Check if this consent covers the requested scopes @@ -31,18 +31,18 @@ class OidcUserConsent < ApplicationRecord def formatted_scopes scopes.map do |scope| case scope - when 'openid' - 'Basic authentication' - when 'profile' - 'Profile information' - when 'email' - 'Email address' - when 'groups' - 'Group membership' + when "openid" + "Basic authentication" + when "profile" + "Profile information" + when "email" + "Email address" + when "groups" + "Group membership" else scope.humanize end - end.join(', ') + end.join(", ") end # Find consent by SID diff --git a/app/models/user.rb b/app/models/user.rb index de156d5..168cf3f 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -29,16 +29,16 @@ class User < ApplicationRecord groups ].freeze - validates :email_address, presence: true, uniqueness: { case_sensitive: false }, - format: { with: URI::MailTo::EMAIL_REGEXP } - 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" }, - length: { minimum: 2, maximum: 30 } - validates :password, length: { minimum: 8 }, allow_nil: true + validates :email_address, presence: true, uniqueness: {case_sensitive: false}, + format: {with: URI::MailTo::EMAIL_REGEXP} + 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"}, + length: {minimum: 2, maximum: 30} + validates :password, length: {minimum: 8}, allow_nil: true validate :no_reserved_claim_names # 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 scope :admins, -> { where(admin: true) } @@ -122,12 +122,7 @@ class User < ApplicationRecord cache_key = "backup_code_failed_attempts_#{id}" attempts = Rails.cache.read(cache_key) || 0 - if attempts >= 5 # Allow max 5 failed attempts per hour - true - else - # Don't increment here - increment only on failed attempts - false - end + attempts >= 5 end # Increment failed attempt counter @@ -231,7 +226,7 @@ class User < ApplicationRecord reserved_used = parsed_custom_claims.keys.map(&:to_s) & RESERVED_CLAIMS if reserved_used.any? - errors.add(:custom_claims, "cannot override reserved OIDC claims: #{reserved_used.join(', ')}") + errors.add(:custom_claims, "cannot override reserved OIDC claims: #{reserved_used.join(", ")}") end end diff --git a/app/models/user_group.rb b/app/models/user_group.rb index 1550208..c9df469 100644 --- a/app/models/user_group.rb +++ b/app/models/user_group.rb @@ -2,5 +2,5 @@ class UserGroup < ApplicationRecord belongs_to :user belongs_to :group - validates :user_id, uniqueness: { scope: :group_id } + validates :user_id, uniqueness: {scope: :group_id} end diff --git a/app/models/webauthn_credential.rb b/app/models/webauthn_credential.rb index a859faf..2f54715 100644 --- a/app/models/webauthn_credential.rb +++ b/app/models/webauthn_credential.rb @@ -4,9 +4,9 @@ class WebauthnCredential < ApplicationRecord # Validations validates :external_id, presence: true, uniqueness: 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 :authenticator_type, inclusion: { in: %w[platform cross-platform] } + validates :authenticator_type, inclusion: {in: %w[platform cross-platform]} # Scopes for querying scope :active, -> { where(nil) } # All credentials are active (we can add revoked_at later if needed) @@ -84,13 +84,13 @@ class WebauthnCredential < ApplicationRecord days = hours / 24 if days > 0 - "#{days.floor} day#{'s' if days > 1} ago" + "#{days.floor} day#{"s" if days > 1} ago" elsif hours > 0 - "#{hours.floor} hour#{'s' if hours > 1} ago" + "#{hours.floor} hour#{"s" if hours > 1} ago" elsif minutes > 0 - "#{minutes.floor} minute#{'s' if minutes > 1} ago" + "#{minutes.floor} minute#{"s" if minutes > 1} ago" else "Just now" end end -end \ No newline at end of file +end diff --git a/app/services/concerns/claims_merger.rb b/app/services/concerns/claims_merger.rb index e58c0e8..ec39117 100644 --- a/app/services/concerns/claims_merger.rb +++ b/app/services/concerns/claims_merger.rb @@ -13,20 +13,20 @@ module ClaimsMerger result = base.dup incoming.each do |key, value| - if result.key?(key) + result[key] = if result.key?(key) # If both values are arrays, combine them (union to avoid duplicates) if result[key].is_a?(Array) && value.is_a?(Array) - result[key] = (result[key] + value).uniq + (result[key] + value).uniq # If both values are hashes, recursively merge them elsif result[key].is_a?(Hash) && value.is_a?(Hash) - result[key] = deep_merge_claims(result[key], value) + deep_merge_claims(result[key], value) else # Otherwise, incoming value wins (override) - result[key] = value + value end else # New key, just add it - result[key] = value + value end end diff --git a/app/services/oidc_jwt_service.rb b/app/services/oidc_jwt_service.rb index 73bf9f9..3935f62 100644 --- a/app/services/oidc_jwt_service.rb +++ b/app/services/oidc_jwt_service.rb @@ -60,7 +60,7 @@ class OidcJwtService # Merge app-specific custom claims (highest priority, arrays are combined) 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 # Generate a backchannel logout token (JWT) @@ -84,12 +84,12 @@ class OidcJwtService } # 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 # Decode and verify an ID token def decode_id_token(token) - JWT.decode(token, public_key, true, { algorithm: "RS256" }) + JWT.decode(token, public_key, true, {algorithm: "RS256"}) end # Get the public key in JWK format for the JWKS endpoint diff --git a/config/application.rb b/config/application.rb index 41669e2..f165274 100644 --- a/config/application.rb +++ b/config/application.rb @@ -24,16 +24,16 @@ module Clinch # config.time_zone = "Central Time (US & Canada)" # 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.smtp_settings = { - address: ENV.fetch('SMTP_ADDRESS', 'localhost'), - port: ENV.fetch('SMTP_PORT', 587), - domain: ENV.fetch('SMTP_DOMAIN', 'localhost'), - user_name: ENV.fetch('SMTP_USERNAME', nil), - password: ENV.fetch('SMTP_PASSWORD', nil), - authentication: ENV.fetch('SMTP_AUTHENTICATION', 'plain').to_sym, - enable_starttls_auto: ENV.fetch('SMTP_STARTTLS_AUTO', 'true') == 'true', + address: ENV.fetch("SMTP_ADDRESS", "localhost"), + port: ENV.fetch("SMTP_PORT", 587), + domain: ENV.fetch("SMTP_DOMAIN", "localhost"), + user_name: ENV.fetch("SMTP_USERNAME", nil), + password: ENV.fetch("SMTP_PASSWORD", nil), + authentication: ENV.fetch("SMTP_AUTHENTICATION", "plain").to_sym, + enable_starttls_auto: ENV.fetch("SMTP_STARTTLS_AUTO", "true") == "true", openssl_verify_mode: OpenSSL::SSL::VERIFY_PEER } end diff --git a/config/environments/development.rb b/config/environments/development.rb index 9c960cc..46ad15f 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -20,7 +20,7 @@ Rails.application.configure do if Rails.root.join("tmp/caching-dev.txt").exist? config.action_controller.perform_caching = 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 config.action_controller.perform_caching = false end @@ -39,10 +39,10 @@ Rails.application.configure do config.action_mailer.perform_caching = false # 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). - config.log_tags = [ :request_id ] + config.log_tags = [:request_id] # Print deprecation notices to the Rails logger. config.active_support.deprecation = :log @@ -61,7 +61,6 @@ Rails.application.configure do # Use async processor for background jobs in development config.active_job.queue_adapter = :async - # Highlight code that triggered redirect in logs. config.action_dispatch.verbose_redirect_logs = true diff --git a/config/environments/production.rb b/config/environments/production.rb index ca66c3b..296b308 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -16,7 +16,7 @@ Rails.application.configure do config.action_controller.perform_caching = true # 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. # 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: Permissions-Policy is configured in config/initializers/permissions_policy.rb config.action_dispatch.default_headers.merge!( - 'X-Frame-Options' => 'DENY', # Override default SAMEORIGIN to prevent clickjacking - 'Referrer-Policy' => 'strict-origin-when-cross-origin' # Control referrer information + "X-Frame-Options" => "DENY", # Override default SAMEORIGIN to prevent clickjacking + "Referrer-Policy" => "strict-origin-when-cross-origin" # Control referrer information ) # Skip http-to-https redirect for the default health check endpoint. # config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } } # Log to STDOUT with the current request id as a default log tag. - config.log_tags = [ :request_id ] - config.logger = ActiveSupport::TaggedLogging.logger(STDOUT) + config.log_tags = [:request_id] + config.logger = ActiveSupport::TaggedLogging.logger($stdout) # Change to "debug" to log everything (including potentially personally-identifiable information!). config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info") @@ -66,7 +66,7 @@ Rails.application.configure do # Set host to be used by links generated in mailer templates. config.action_mailer.default_url_options = { - host: ENV.fetch('CLINCH_HOST', 'example.com') + host: ENV.fetch("CLINCH_HOST", "example.com") } # Specify outgoing SMTP server. Remember to add smtp/* credentials via bin/rails credentials:edit. @@ -86,13 +86,13 @@ Rails.application.configure do config.active_record.dump_schema_after_migration = false # 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) def self.extract_domain(host) return host if host.blank? # Remove protocol (http:// or https://) if present - host.gsub(/^https?:\/\//, '') + host.gsub(/^https?:\/\//, "") end # Helper method to ensure URL has https:// protocol @@ -105,11 +105,11 @@ Rails.application.configure do # Enable DNS rebinding protection and other `Host` header attacks. # Configure allowed hosts based on deployment scenario allowed_hosts = [ - extract_domain(ENV.fetch('CLINCH_HOST', 'auth.example.com')), # External domain (auth service itself) + extract_domain(ENV.fetch("CLINCH_HOST", "auth.example.com")) # External domain (auth service itself) ] # Use PublicSuffix to extract registrable domain and allow all subdomains - host_domain = extract_domain(ENV.fetch('CLINCH_HOST', 'auth.example.com')) + host_domain = extract_domain(ENV.fetch("CLINCH_HOST", "auth.example.com")) if host_domain.present? begin # Use PublicSuffix to properly extract the domain @@ -123,20 +123,20 @@ Rails.application.configure do rescue PublicSuffix::DomainInvalid # Fallback to simple domain extraction if PublicSuffix fails Rails.logger.warn "Could not parse domain '#{host_domain}' with PublicSuffix, using fallback" - base_domain = host_domain.split('.').last(2).join('.') + base_domain = host_domain.split(".").last(2).join(".") allowed_hosts << /.*#{Regexp.escape(base_domain)}/ end end # Allow Docker service names if running in same compose - if ENV['CLINCH_DOCKER_SERVICE_NAME'] - allowed_hosts << ENV['CLINCH_DOCKER_SERVICE_NAME'] + if ENV["CLINCH_DOCKER_SERVICE_NAME"] + allowed_hosts << ENV["CLINCH_DOCKER_SERVICE_NAME"] end # Allow internal IP access for cross-compose or host networking - if ENV['CLINCH_ALLOW_INTERNAL_IPS'] == 'true' + if ENV["CLINCH_ALLOW_INTERNAL_IPS"] == "true" # Specific host IP - allowed_hosts << '192.168.2.246' + allowed_hosts << "192.168.2.246" # Private IP ranges for internal network access allowed_hosts += [ @@ -147,14 +147,14 @@ Rails.application.configure do end # Local development fallbacks - if ENV['CLINCH_ALLOW_LOCALHOST'] == 'true' - allowed_hosts += ['localhost', '127.0.0.1', '0.0.0.0'] + if ENV["CLINCH_ALLOW_LOCALHOST"] == "true" + allowed_hosts += ["localhost", "127.0.0.1", "0.0.0.0"] end config.hosts = allowed_hosts # 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 # Only enabled if SENTRY_DSN environment variable is set diff --git a/config/environments/test.rb b/config/environments/test.rb index 6d15427..e47a905 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -16,7 +16,7 @@ Rails.application.configure do config.eager_load = ENV["CI"].present? # 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. config.consider_all_requests_local = true @@ -37,7 +37,7 @@ Rails.application.configure do config.action_mailer.delivery_method = :test # 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. config.active_support.deprecation = :stderr diff --git a/config/initializers/active_record_encryption.rb b/config/initializers/active_record_encryption.rb index 38f7f6f..9ff40cb 100644 --- a/config/initializers/active_record_encryption.rb +++ b/config/initializers/active_record_encryption.rb @@ -8,14 +8,14 @@ # - ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT # Use env vars if set, otherwise derive from SECRET_KEY_BASE (deterministic) -primary_key = ENV.fetch('ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY') do - Rails.application.key_generator.generate_key('active_record_encryption_primary', 32) +primary_key = ENV.fetch("ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY") do + Rails.application.key_generator.generate_key("active_record_encryption_primary", 32) end -deterministic_key = ENV.fetch('ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY') do - Rails.application.key_generator.generate_key('active_record_encryption_deterministic', 32) +deterministic_key = ENV.fetch("ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY") do + Rails.application.key_generator.generate_key("active_record_encryption_deterministic", 32) end -key_derivation_salt = ENV.fetch('ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT') do - Rails.application.key_generator.generate_key('active_record_encryption_salt', 32) +key_derivation_salt = ENV.fetch("ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT") do + Rails.application.key_generator.generate_key("active_record_encryption_salt", 32) end # Configure Rails 7.1+ ActiveRecord encryption diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb index 6089c09..926cfd5 100644 --- a/config/initializers/content_security_policy.rb +++ b/config/initializers/content_security_policy.rb @@ -56,14 +56,13 @@ Rails.application.configure do policy.require_trusted_types_for :none # CSP reporting using report_uri (supported method) - policy.report_uri "/api/csp-violation-report" + policy.report_uri "/api/csp-violation-report" end - # Start with CSP in report-only mode for testing # Set to false after verifying everything works in production config.content_security_policy_report_only = Rails.env.development? # Report CSP violations (optional - uncomment to enable) # config.content_security_policy_report_uri = "/csp-violations" -end \ No newline at end of file +end diff --git a/config/initializers/csp_local_logger.rb b/config/initializers/csp_local_logger.rb index 854949e..81912cf 100644 --- a/config/initializers/csp_local_logger.rb +++ b/config/initializers/csp_local_logger.rb @@ -8,7 +8,7 @@ Rails.application.config.after_initialize do # Configure log rotation csp_logger = Logger.new( csp_log_path, - 'daily', # Rotate daily + "daily", # Rotate daily 30 # Keep 30 old log files ) @@ -16,7 +16,7 @@ Rails.application.config.after_initialize do # Format: [TIMESTAMP] LEVEL MESSAGE csp_logger.formatter = proc do |severity, datetime, progname, msg| - "[#{datetime.strftime('%Y-%m-%d %H:%M:%S')}] #{severity} #{msg}\n" + "[#{datetime.strftime("%Y-%m-%d %H:%M:%S")}] #{severity} #{msg}\n" end module CspViolationLocalLogger @@ -25,9 +25,9 @@ Rails.application.config.after_initialize do # Skip logging if there's no meaningful violation data return if csp_data.empty? || - (csp_data[:violated_directive].nil? && - csp_data[:blocked_uri].nil? && - csp_data[:document_uri].nil?) + (csp_data[:violated_directive].nil? && + csp_data[:blocked_uri].nil? && + csp_data[:document_uri].nil?) # Build a structured log message 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 Rails.logger.info "CSP violation logged to csp_violations.log: #{violated_directive} - #{blocked_uri}" - rescue => e # Ensure logger errors don't break the CSP reporting flow Rails.logger.error "Failed to log CSP violation to file: #{e.message}" @@ -81,12 +80,12 @@ Rails.application.config.after_initialize do csp_log_path = Rails.root.join("log", "csp_violations.log") logger = Logger.new( csp_log_path, - 'daily', # Rotate daily + "daily", # Rotate daily 30 # Keep 30 old log files ) logger.level = Logger::INFO logger.formatter = proc do |severity, datetime, progname, msg| - "[#{datetime.strftime('%Y-%m-%d %H:%M:%S')}] #{severity} #{msg}\n" + "[#{datetime.strftime("%Y-%m-%d %H:%M:%S")}] #{severity} #{msg}\n" end logger end @@ -120,9 +119,8 @@ Rails.application.config.after_initialize do # Test write to ensure permissions are correct csp_logger.info "CSP Logger initialized at #{Time.current}" - rescue => e Rails.logger.error "Failed to initialize CSP local logger: #{e.message}" Rails.logger.error "CSP violations will only be sent to Sentry (if configured)" end -end \ No newline at end of file +end diff --git a/config/initializers/permissions_policy.rb b/config/initializers/permissions_policy.rb index f7df04e..93ca155 100644 --- a/config/initializers/permissions_policy.rb +++ b/config/initializers/permissions_policy.rb @@ -3,12 +3,12 @@ Rails.application.config.permissions_policy do |f| # Disable sensitive browser features for security - f.camera :none - f.gyroscope :none - f.microphone :none - f.payment :none - f.usb :none - f.magnetometer :none + f.camera :none + f.gyroscope :none + f.microphone :none + f.payment :none + f.usb :none + f.magnetometer :none # You can enable specific features as needed: # f.fullscreen :self diff --git a/config/initializers/sentry.rb b/config/initializers/sentry.rb index 19e50ac..9265979 100644 --- a/config/initializers/sentry.rb +++ b/config/initializers/sentry.rb @@ -74,7 +74,7 @@ Rails.application.configure do app_environment: Rails.env, # Add CSP policy status csp_enabled: defined?(Rails.application.config.content_security_policy) && - Rails.application.config.content_security_policy.present? + Rails.application.config.content_security_policy.present? } end @@ -120,13 +120,13 @@ Rails.application.configure do if breadcrumb[:data] breadcrumb[:data].reject! { |key, value| 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 # Mark CSP-related events if breadcrumb[:message]&.include?("CSP Violation") || - breadcrumb[:category]&.include?("csp") + breadcrumb[:category]&.include?("csp") breadcrumb[:data] ||= {} breadcrumb[:data][:security_event] = true breadcrumb[:data][:csp_violation] = true @@ -137,4 +137,4 @@ Rails.application.configure do # Only send errors in production unless explicitly enabled config.sentry.enabled = Rails.env.production? || ENV["SENTRY_ENABLED_IN_DEVELOPMENT"] == "true" -end \ No newline at end of file +end diff --git a/config/initializers/sentry_subscriber.rb b/config/initializers/sentry_subscriber.rb index 4678171..5de17a8 100644 --- a/config/initializers/sentry_subscriber.rb +++ b/config/initializers/sentry_subscriber.rb @@ -47,7 +47,7 @@ Rails.application.config.after_initialize do 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 @@ -69,10 +69,10 @@ Rails.application.config.after_initialize do parsed.host rescue URI::InvalidURIError # Handle cases where URI might be malformed or just a path - if uri.start_with?('/') + if uri.start_with?("/") nil # It's a relative path, no domain else - uri.split('/').first # Best effort extraction + uri.split("/").first # Best effort extraction end end end @@ -117,4 +117,4 @@ Rails.application.config.after_initialize do else Rails.logger.info "Sentry not initialized - CSP violations will only be logged locally" end -end \ No newline at end of file +end diff --git a/config/initializers/token_hmac.rb b/config/initializers/token_hmac.rb index 7d6608a..6f3ba62 100644 --- a/config/initializers/token_hmac.rb +++ b/config/initializers/token_hmac.rb @@ -3,5 +3,5 @@ # Derived from SECRET_KEY_BASE - no storage needed, deterministic output # Optional: Set OIDC_TOKEN_PREFIX_HMAC env var to override with explicit key module TokenHmac - KEY = ENV['OIDC_TOKEN_PREFIX_HMAC'] || Rails.application.key_generator.generate_key('oidc_token_prefix', 32) + KEY = ENV["OIDC_TOKEN_PREFIX_HMAC"] || Rails.application.key_generator.generate_key("oidc_token_prefix", 32) end diff --git a/config/initializers/webauthn.rb b/config/initializers/webauthn.rb index d5567bb..ded494a 100644 --- a/config/initializers/webauthn.rb +++ b/config/initializers/webauthn.rb @@ -68,4 +68,4 @@ end # CLINCH_RP_NAME="Example Company Identity Provider" # CLINCH_WEBAUTHN_ATTESTATION=none # CLINCH_WEBAUTHN_USER_VERIFICATION=preferred -# CLINCH_WEBAUTHN_RESIDENT_KEY=preferred \ No newline at end of file +# CLINCH_WEBAUTHN_RESIDENT_KEY=preferred diff --git a/config/puma.rb b/config/puma.rb index d7257cc..5771f2b 100644 --- a/config/puma.rb +++ b/config/puma.rb @@ -31,7 +31,6 @@ threads threads_count, threads_count # Specifies the `port` that Puma will listen on to receive requests; default is 3000. port ENV.fetch("PORT", 3000) - # Allow puma to be restarted by `bin/rails restart` command. plugin :tmp_restart diff --git a/config/routes.rb b/config/routes.rb index f6d6bf2..877fb68 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -8,7 +8,7 @@ Rails.application.routes.draw do # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. # Can be used by load balancers and uptime monitors to verify that the app is live. - get "up" => "rails/health#show", as: :rails_health_check + get "up" => "rails/health#show", :as => :rails_health_check # Authentication routes get "/signup", to: "users#new", as: :signup @@ -61,21 +61,21 @@ Rails.application.routes.draw do end # TOTP (2FA) routes - get '/totp/new', to: 'totp#new', as: :new_totp - post '/totp', to: 'totp#create', as: :totp - delete '/totp', to: 'totp#destroy' - get '/totp/backup_codes', to: 'totp#backup_codes', as: :backup_codes_totp - post '/totp/verify_password', to: 'totp#verify_password', as: :verify_password_totp - get '/totp/regenerate_backup_codes', to: 'totp#regenerate_backup_codes', as: :regenerate_backup_codes_totp - post '/totp/regenerate_backup_codes', to: 'totp#create_new_backup_codes', as: :create_new_backup_codes_totp - post '/totp/complete_setup', to: 'totp#complete_setup', as: :complete_totp_setup + get "/totp/new", to: "totp#new", as: :new_totp + post "/totp", to: "totp#create", as: :totp + delete "/totp", to: "totp#destroy" + get "/totp/backup_codes", to: "totp#backup_codes", as: :backup_codes_totp + post "/totp/verify_password", to: "totp#verify_password", as: :verify_password_totp + get "/totp/regenerate_backup_codes", to: "totp#regenerate_backup_codes", as: :regenerate_backup_codes_totp + post "/totp/regenerate_backup_codes", to: "totp#create_new_backup_codes", as: :create_new_backup_codes_totp + post "/totp/complete_setup", to: "totp#complete_setup", as: :complete_totp_setup # WebAuthn (Passkeys) routes - get '/webauthn/new', to: 'webauthn#new', as: :new_webauthn - post '/webauthn/challenge', to: 'webauthn#challenge' - post '/webauthn/create', to: 'webauthn#create' - delete '/webauthn/:id', to: 'webauthn#destroy', as: :webauthn_credential - get '/webauthn/check', to: 'webauthn#check' + get "/webauthn/new", to: "webauthn#new", as: :new_webauthn + post "/webauthn/challenge", to: "webauthn#challenge" + post "/webauthn/create", to: "webauthn#create" + delete "/webauthn/:id", to: "webauthn#destroy", as: :webauthn_credential + get "/webauthn/check", to: "webauthn#check" # Admin routes namespace :admin do diff --git a/db/migrate/20251023053837_create_user_groups.rb b/db/migrate/20251023053837_create_user_groups.rb index 8c5f128..d7ed46d 100644 --- a/db/migrate/20251023053837_create_user_groups.rb +++ b/db/migrate/20251023053837_create_user_groups.rb @@ -7,6 +7,6 @@ class CreateUserGroups < ActiveRecord::Migration[8.1] t.timestamps end - add_index :user_groups, [ :user_id, :group_id ], unique: true + add_index :user_groups, [:user_id, :group_id], unique: true end end diff --git a/db/migrate/20251023053938_create_application_groups.rb b/db/migrate/20251023053938_create_application_groups.rb index 827714b..b5aa599 100644 --- a/db/migrate/20251023053938_create_application_groups.rb +++ b/db/migrate/20251023053938_create_application_groups.rb @@ -7,6 +7,6 @@ class CreateApplicationGroups < ActiveRecord::Migration[8.1] t.timestamps end - add_index :application_groups, [ :application_id, :group_id ], unique: true + add_index :application_groups, [:application_id, :group_id], unique: true end end diff --git a/db/migrate/20251023054038_create_oidc_authorization_codes.rb b/db/migrate/20251023054038_create_oidc_authorization_codes.rb index f266110..c3f226c 100644 --- a/db/migrate/20251023054038_create_oidc_authorization_codes.rb +++ b/db/migrate/20251023054038_create_oidc_authorization_codes.rb @@ -13,6 +13,6 @@ class CreateOidcAuthorizationCodes < ActiveRecord::Migration[8.1] end add_index :oidc_authorization_codes, :code, unique: true 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 diff --git a/db/migrate/20251023054039_create_oidc_access_tokens.rb b/db/migrate/20251023054039_create_oidc_access_tokens.rb index b676d28..3f787b5 100644 --- a/db/migrate/20251023054039_create_oidc_access_tokens.rb +++ b/db/migrate/20251023054039_create_oidc_access_tokens.rb @@ -11,6 +11,6 @@ class CreateOidcAccessTokens < ActiveRecord::Migration[8.1] end add_index :oidc_access_tokens, :token, unique: true 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 diff --git a/db/migrate/20251024012201_add_role_mapping_to_applications.rb b/db/migrate/20251024012201_add_role_mapping_to_applications.rb index ef37fb6..811028a 100644 --- a/db/migrate/20251024012201_add_role_mapping_to_applications.rb +++ b/db/migrate/20251024012201_add_role_mapping_to_applications.rb @@ -1,9 +1,9 @@ class AddRoleMappingToApplications < ActiveRecord::Migration[8.1] def change - add_column :applications, :role_mapping_mode, :string, default: 'disabled', null: false + add_column :applications, :role_mapping_mode, :string, default: "disabled", null: false add_column :applications, :role_prefix, :string add_column :applications, :managed_permissions, :json, default: {} - add_column :applications, :role_claim_name, :string, default: 'roles' + add_column :applications, :role_claim_name, :string, default: "roles" create_table :application_roles do |t| t.references :application, null: false, foreign_key: true @@ -21,7 +21,7 @@ class AddRoleMappingToApplications < ActiveRecord::Migration[8.1] create_table :user_role_assignments do |t| t.references :user, null: false, foreign_key: true t.references :application_role, null: false, foreign_key: true - t.string :source, default: 'oidc' # 'oidc', 'manual', 'group_sync' + t.string :source, default: "oidc" # 'oidc', 'manual', 'group_sync' t.json :metadata, default: {} t.timestamps diff --git a/db/migrate/20251104014955_migrate_forward_auth_rules_to_applications.rb b/db/migrate/20251104014955_migrate_forward_auth_rules_to_applications.rb index 517e952..b0b55cc 100644 --- a/db/migrate/20251104014955_migrate_forward_auth_rules_to_applications.rb +++ b/db/migrate/20251104014955_migrate_forward_auth_rules_to_applications.rb @@ -41,7 +41,7 @@ class MigrateForwardAuthRulesToApplications < ActiveRecord::Migration[8.1] app = application_class.create!( name: rule.domain_pattern.titleize, slug: rule.domain_pattern.parameterize.presence || "forward-auth-#{rule.id}", - app_type: 'forward_auth', + app_type: "forward_auth", domain_pattern: rule.domain_pattern, headers_config: rule.headers_config || {}, active: rule.active @@ -59,7 +59,7 @@ class MigrateForwardAuthRulesToApplications < ActiveRecord::Migration[8.1] def down # Remove all forward_auth applications created by this migration - Application.where(app_type: 'forward_auth').destroy_all + Application.where(app_type: "forward_auth").destroy_all end private diff --git a/db/migrate/20251104042155_create_webauthn_credentials.rb b/db/migrate/20251104042155_create_webauthn_credentials.rb index 28fc696..1d61e16 100644 --- a/db/migrate/20251104042155_create_webauthn_credentials.rb +++ b/db/migrate/20251104042155_create_webauthn_credentials.rb @@ -5,7 +5,7 @@ class CreateWebauthnCredentials < ActiveRecord::Migration[8.1] t.references :user, null: false, foreign_key: true, index: true # 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.integer :sign_count, null: false, default: 0 # signature counter (clone detection) diff --git a/db/migrate/20251112114852_create_oidc_refresh_tokens.rb b/db/migrate/20251112114852_create_oidc_refresh_tokens.rb index c31b7e2..cd3b92b 100644 --- a/db/migrate/20251112114852_create_oidc_refresh_tokens.rb +++ b/db/migrate/20251112114852_create_oidc_refresh_tokens.rb @@ -17,6 +17,6 @@ class CreateOidcRefreshTokens < ActiveRecord::Migration[8.1] add_index :oidc_refresh_tokens, :expires_at add_index :oidc_refresh_tokens, :revoked_at 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 diff --git a/db/migrate/20251123052026_create_application_user_claims.rb b/db/migrate/20251123052026_create_application_user_claims.rb index 1f380ec..e9e4f34 100644 --- a/db/migrate/20251123052026_create_application_user_claims.rb +++ b/db/migrate/20251123052026_create_application_user_claims.rb @@ -1,13 +1,13 @@ class CreateApplicationUserClaims < ActiveRecord::Migration[8.1] def change create_table :application_user_claims do |t| - t.references :application, null: false, foreign_key: { on_delete: :cascade } - t.references :user, 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.json :custom_claims, default: {}, null: false t.timestamps end - add_index :application_user_claims, [:application_id, :user_id], unique: true, name: 'index_app_user_claims_unique' + add_index :application_user_claims, [:application_id, :user_id], unique: true, name: "index_app_user_claims_unique" end end diff --git a/db/migrate/20251125063248_create_active_storage_tables.active_storage.rb b/db/migrate/20251125063248_create_active_storage_tables.active_storage.rb index 6bd8bd0..49d3670 100644 --- a/db/migrate/20251125063248_create_active_storage_tables.active_storage.rb +++ b/db/migrate/20251125063248_create_active_storage_tables.active_storage.rb @@ -5,13 +5,13 @@ class CreateActiveStorageTables < ActiveRecord::Migration[7.0] primary_key_type, foreign_key_type = primary_and_foreign_key_types create_table :active_storage_blobs, id: primary_key_type do |t| - t.string :key, null: false - t.string :filename, null: false - t.string :content_type - t.text :metadata - t.string :service_name, null: false - t.bigint :byte_size, null: false - t.string :checksum + t.string :key, null: false + t.string :filename, null: false + t.string :content_type + t.text :metadata + t.string :service_name, null: false + t.bigint :byte_size, null: false + t.string :checksum if connection.supports_datetime_with_precision? t.datetime :created_at, precision: 6, null: false @@ -19,13 +19,13 @@ class CreateActiveStorageTables < ActiveRecord::Migration[7.0] t.datetime :created_at, null: false end - t.index [ :key ], unique: true + t.index [:key], unique: true end create_table :active_storage_attachments, id: primary_key_type do |t| - t.string :name, null: false - t.references :record, null: false, polymorphic: true, index: false, type: foreign_key_type - t.references :blob, null: false, type: foreign_key_type + t.string :name, null: false + t.references :record, null: false, polymorphic: true, index: false, type: foreign_key_type + t.references :blob, null: false, type: foreign_key_type if connection.supports_datetime_with_precision? t.datetime :created_at, precision: 6, null: false @@ -33,7 +33,7 @@ class CreateActiveStorageTables < ActiveRecord::Migration[7.0] t.datetime :created_at, null: false 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 end @@ -41,17 +41,18 @@ class CreateActiveStorageTables < ActiveRecord::Migration[7.0] t.belongs_to :blob, null: false, index: false, type: foreign_key_type 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 end end private - def primary_and_foreign_key_types - config = Rails.configuration.generators - setting = config.options[config.orm][:primary_key_type] - primary_key_type = setting || :primary_key - foreign_key_type = setting || :bigint - [ primary_key_type, foreign_key_type ] - end + + def primary_and_foreign_key_types + config = Rails.configuration.generators + setting = config.options[config.orm][:primary_key_type] + primary_key_type = setting || :primary_key + foreign_key_type = setting || :bigint + [primary_key_type, foreign_key_type] + end end diff --git a/test/application_system_test_case.rb b/test/application_system_test_case.rb index cee29fd..33264b9 100644 --- a/test/application_system_test_case.rb +++ b/test/application_system_test_case.rb @@ -1,5 +1,5 @@ require "test_helper" 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 diff --git a/test/controllers/api/forward_auth_controller_test.rb b/test/controllers/api/forward_auth_controller_test.rb index 42795ed..8f583b8 100644 --- a/test/controllers/api/forward_auth_controller_test.rb +++ b/test/controllers/api/forward_auth_controller_test.rb @@ -13,7 +13,7 @@ module Api # Authentication Tests 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_match %r{/signin}, response.location @@ -23,7 +23,7 @@ module Api test "should redirect when user is inactive" do 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_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 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 end @@ -41,7 +41,7 @@ module Api test "should return 200 when matching rule exists" do 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 end @@ -49,7 +49,7 @@ module Api test "should return 403 when no rule matches (fail-closed security)" do 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_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 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_equal "No authentication rule configured for this domain", response.headers["x-auth-reason"] @@ -68,7 +68,7 @@ module Api @rule.allowed_groups << @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_match %r{permission to access this domain}, response.headers["x-auth-reason"] @@ -79,35 +79,35 @@ module Api @user.groups << @group 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 end # Domain Pattern Tests test "should match wildcard domains correctly" do - wildcard_rule = Application.create!(name: "Wildcard App", slug: "wildcard-app", app_type: "forward_auth", domain_pattern: "*.example.com", active: true) + Application.create!(name: "Wildcard App", slug: "wildcard-app", app_type: "forward_auth", domain_pattern: "*.example.com", active: true) sign_in_as(@user) - get "/api/verify", headers: { "X-Forwarded-Host" => "app.example.com" } + get "/api/verify", headers: {"X-Forwarded-Host" => "app.example.com"} 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 - 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_equal "No authentication rule configured for this domain", response.headers["x-auth-reason"] end test "should match exact domains correctly" do - exact_rule = Application.create!(name: "Exact App", slug: "exact-app", app_type: "forward_auth", domain_pattern: "api.example.com", active: true) + Application.create!(name: "Exact App", slug: "exact-app", app_type: "forward_auth", domain_pattern: "api.example.com", active: true) sign_in_as(@user) - get "/api/verify", headers: { "X-Forwarded-Host" => "api.example.com" } + get "/api/verify", headers: {"X-Forwarded-Host" => "api.example.com"} 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_equal "No authentication rule configured for this domain", response.headers["x-auth-reason"] end @@ -116,7 +116,7 @@ module Api test "should return default headers when rule has no custom config" do 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_equal @user.email_address, response.headers["x-remote-user"] @@ -126,7 +126,7 @@ module Api end test "should return custom headers when configured" do - custom_rule = Application.create!( + Application.create!( name: "Custom App", slug: "custom-app", app_type: "forward_auth", @@ -140,7 +140,7 @@ module Api ) 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_equal @user.email_address, response.headers["x-webauth-user"] @@ -151,17 +151,17 @@ module Api end test "should return no headers when all headers disabled" do - no_headers_rule = Application.create!( + Application.create!( name: "No Headers App", slug: "no-headers-app", app_type: "forward_auth", domain_pattern: "noheaders.example.com", active: true, - headers_config: { user: "", email: "", name: "", groups: "", admin: "" } + headers_config: {user: "", email: "", name: "", groups: "", admin: ""} ) 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 # Check that auth-specific headers are not present (exclude Rails security headers) @@ -173,7 +173,7 @@ module Api @user.groups << @group 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 groups_header = response.headers["x-remote-groups"] @@ -186,7 +186,7 @@ module Api @user.groups.clear # Remove fixture groups 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_nil response.headers["x-remote-groups"] @@ -195,7 +195,7 @@ module Api test "should include admin header correctly" do 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_equal "true", response.headers["x-remote-admin"] @@ -207,7 +207,7 @@ module Api @user.groups << group2 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 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 sign_in_as(@user) - get "/api/verify", headers: { "Host" => "test.example.com" } + get "/api/verify", headers: {"Host" => "test.example.com"} assert_response 200 end @@ -239,7 +239,7 @@ module Api long_domain = "a" * 250 + ".example.com" 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_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 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 end @@ -262,7 +262,7 @@ module Api get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com", "X-Forwarded-Uri" => "/admin" - }, params: { rd: evil_url } + }, params: {rd: evil_url} assert_response 302 assert_match %r{/signin}, response.location @@ -292,8 +292,8 @@ module Api # This should be allowed (domain has ForwardAuthRule) allowed_url = "https://test.example.com/dashboard" - get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }, - params: { rd: allowed_url } + get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}, + params: {rd: allowed_url} assert_response 302 assert_match allowed_url, response.location @@ -305,8 +305,8 @@ module Api # This should be rejected (no ForwardAuthRule for evil-site.com) evil_url = "https://evil-site.com/steal-credentials" - get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }, - params: { rd: evil_url } + get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}, + params: {rd: evil_url} assert_response 302 # 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) http_url = "http://test.example.com/dashboard" - get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }, - params: { rd: http_url } + get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}, + params: {rd: http_url} assert_response 302 # Should redirect to login page or default URL, NOT to HTTP URL @@ -340,8 +340,8 @@ module Api ] dangerous_schemes.each do |dangerous_url| - get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }, - params: { rd: dangerous_url } + get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}, + params: {rd: dangerous_url} assert_response 302, "Should reject dangerous URL: #{dangerous_url}" # Should redirect to login page or default URL, NOT to dangerous URL @@ -355,7 +355,7 @@ module Api sign_in_as(@user) # 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 end @@ -461,11 +461,11 @@ module Api sign_in_as(@user) # 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 # 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 # Should maintain user identity across requests @@ -481,8 +481,8 @@ module Api 5.times do |i| threads << Thread.new do - get "/api/verify", headers: { "X-Forwarded-Host" => "app#{i}.example.com" } - results << { status: response.status } + get "/api/verify", headers: {"X-Forwarded-Host" => "app#{i}.example.com"} + results << {status: response.status} end end @@ -524,7 +524,7 @@ module Api request_count = 10 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 end @@ -535,4 +535,4 @@ module Api assert average_time < 0.1, "Average request time too slow: #{average_time}s" end end -end \ No newline at end of file +end diff --git a/test/controllers/concerns/authentication_test.rb b/test/controllers/concerns/authentication_test.rb index eb3a61e..87c803c 100644 --- a/test/controllers/concerns/authentication_test.rb +++ b/test/controllers/concerns/authentication_test.rb @@ -10,10 +10,14 @@ class AuthenticationTest < ActiveSupport::TestCase return nil if host.blank? || host.match?(/^(localhost|127\.0\.0\.1|::1)$/) # Strip port number for domain parsing - host_without_port = host.split(':').first + host_without_port = host.split(":").first # Check if it's an IP address (IPv4 or IPv6) - if so, don't set domain cookie - return nil if IPAddr.new(host_without_port) rescue false + begin + return nil if IPAddr.new(host_without_port) + rescue + false + end # Use Public Suffix List for accurate domain parsing domain = PublicSuffix.parse(host_without_port) @@ -214,4 +218,4 @@ class AuthenticationTest < ActiveSupport::TestCase assert_equal domain, extract_root_domain("api.example.com") assert_equal domain, extract_root_domain("sub.example.com") end -end \ No newline at end of file +end diff --git a/test/controllers/input_validation_test.rb b/test/controllers/input_validation_test.rb index 3e307ba..de042a7 100644 --- a/test/controllers/input_validation_test.rb +++ b/test/controllers/input_validation_test.rb @@ -31,7 +31,7 @@ class InputValidationTest < ActionDispatch::IntegrationTest user = User.create!(email_address: "xss_test@example.com", password: "password123", name: xss_payload) # 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 # Get a page that displays user name @@ -59,7 +59,7 @@ class InputValidationTest < ActionDispatch::IntegrationTest ) # 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 # Try to tamper with OAuth authorization parameters @@ -112,7 +112,7 @@ class InputValidationTest < ActionDispatch::IntegrationTest test "JSON input validation prevents malicious payloads" do # Try to send malformed 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 assert_includes [400, 422], response.status @@ -124,9 +124,9 @@ class InputValidationTest < ActionDispatch::IntegrationTest grant_type: "authorization_code", code: "test_code", redirect_uri: "http://localhost:4000/callback", - nested: { __proto__: "tampered", constructor: { prototype: "tampered" } } + nested: {__proto__: "tampered", constructor: {prototype: "tampered"}} }.to_json, - headers: { "CONTENT_TYPE" => "application/json" } + headers: {"CONTENT_TYPE" => "application/json"} # Should sanitize or reject prototype pollution attempts # 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| # 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 assert_response :redirect, "Should reject path traversal attempt" diff --git a/test/controllers/invitations_controller_test.rb b/test/controllers/invitations_controller_test.rb index 832d934..5246e53 100644 --- a/test/controllers/invitations_controller_test.rb +++ b/test/controllers/invitations_controller_test.rb @@ -100,7 +100,7 @@ class InvitationsControllerTest < ActionDispatch::IntegrationTest test "should destroy existing sessions when accepting invitation" do # Create an existing session for the user - existing_session = @user.sessions.create! + @user.sessions.create! put invitation_path(@token), params: { password: "newpassword123", @@ -145,4 +145,4 @@ class InvitationsControllerTest < ActionDispatch::IntegrationTest get invitation_path(@token) assert_response :success end -end \ No newline at end of file +end diff --git a/test/controllers/oidc_authorization_code_security_test.rb b/test/controllers/oidc_authorization_code_security_test.rb index fe115ac..7172004 100644 --- a/test/controllers/oidc_authorization_code_security_test.rb +++ b/test/controllers/oidc_authorization_code_security_test.rb @@ -35,7 +35,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest test "prevents authorization code reuse - sequential attempts" do # Create consent - consent = OidcUserConsent.create!( + OidcUserConsent.create!( user: @user, application: @application, scopes_granted: "openid profile", @@ -81,7 +81,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest test "revokes existing tokens when authorization code is reused" do # Create consent - consent = OidcUserConsent.create!( + OidcUserConsent.create!( user: @user, application: @application, scopes_granted: "openid profile", @@ -135,7 +135,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest test "rejects already used authorization code" do # Create consent - consent = OidcUserConsent.create!( + OidcUserConsent.create!( user: @user, application: @application, scopes_granted: "openid profile", @@ -171,7 +171,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest test "rejects expired authorization code" do # Create consent - consent = OidcUserConsent.create!( + OidcUserConsent.create!( user: @user, application: @application, scopes_granted: "openid profile", @@ -206,7 +206,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest test "rejects authorization code with mismatched redirect_uri" do # Create consent - consent = OidcUserConsent.create!( + OidcUserConsent.create!( user: @user, application: @application, scopes_granted: "openid profile", @@ -256,7 +256,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest test "rejects authorization code for different application" do # Create consent for the first application - consent = OidcUserConsent.create!( + OidcUserConsent.create!( user: @user, application: @application, scopes_granted: "openid profile", @@ -308,7 +308,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest test "rejects invalid client_id in Basic auth" do # Create consent - consent = OidcUserConsent.create!( + OidcUserConsent.create!( user: @user, application: @application, scopes_granted: "openid profile", @@ -341,7 +341,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest test "rejects invalid client_secret in Basic auth" do # Create consent - consent = OidcUserConsent.create!( + OidcUserConsent.create!( user: @user, application: @application, scopes_granted: "openid profile", @@ -374,7 +374,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest test "accepts client credentials in POST body" do # Create consent - consent = OidcUserConsent.create!( + OidcUserConsent.create!( user: @user, application: @application, scopes_granted: "openid profile", @@ -408,7 +408,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest test "rejects request with no client authentication" do # Create consent - consent = OidcUserConsent.create!( + OidcUserConsent.create!( user: @user, application: @application, scopes_granted: "openid profile", @@ -474,7 +474,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest test "client authentication uses constant-time comparison" do # Create consent - consent = OidcUserConsent.create!( + OidcUserConsent.create!( user: @user, application: @application, scopes_granted: "openid profile", @@ -546,7 +546,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest ) # 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 get "/oauth/authorize", params: { @@ -573,7 +573,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest ) # 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 get "/oauth/authorize", params: { @@ -593,7 +593,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest test "nonce parameter is included in ID token" do # Create consent - consent = OidcUserConsent.create!( + OidcUserConsent.create!( user: @user, application: @application, scopes_granted: "openid profile", @@ -637,7 +637,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest test "access tokens are not exposed in referer header" do # Create consent and authorization code - consent = OidcUserConsent.create!( + OidcUserConsent.create!( user: @user, application: @application, scopes_granted: "openid profile", @@ -664,7 +664,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest assert_response :success response_body = JSON.parse(@response.body) - access_token = response_body["access_token"] + response_body["access_token"] # Verify token is not in response headers (especially Referer) assert_nil response.headers["Referer"], "Access token should not leak in Referer header" @@ -677,7 +677,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest test "PKCE code_verifier is required when code_challenge was provided" do # Create consent - consent = OidcUserConsent.create!( + OidcUserConsent.create!( user: @user, application: @application, scopes_granted: "openid profile", @@ -716,7 +716,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest test "PKCE with S256 method validates correctly" do # Create consent - consent = OidcUserConsent.create!( + OidcUserConsent.create!( user: @user, application: @application, scopes_granted: "openid profile", @@ -755,7 +755,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest test "PKCE rejects invalid code_verifier" do # Create consent - consent = OidcUserConsent.create!( + OidcUserConsent.create!( user: @user, application: @application, scopes_granted: "openid profile", @@ -798,7 +798,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest test "refresh token rotation is enforced" do # Create consent for the refresh token endpoint - consent = OidcUserConsent.create!( + OidcUserConsent.create!( user: @user, application: @application, scopes_granted: "openid profile", diff --git a/test/controllers/oidc_pkce_controller_test.rb b/test/controllers/oidc_pkce_controller_test.rb index b2ec8e1..c496639 100644 --- a/test/controllers/oidc_pkce_controller_test.rb +++ b/test/controllers/oidc_pkce_controller_test.rb @@ -38,7 +38,6 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest end test "authorization endpoint accepts PKCE parameters (S256)" do - code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" code_challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" auth_params = { @@ -56,7 +55,7 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest # Should show consent page (user is already authenticated) assert_response :success - assert_match /consent/, @response.body.downcase + assert_match(/consent/, @response.body.downcase) end test "authorization endpoint accepts PKCE parameters (plain)" do @@ -77,7 +76,7 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest # Should show consent page (user is already authenticated) assert_response :success - assert_match /consent/, @response.body.downcase + assert_match(/consent/, @response.body.downcase) end test "authorization endpoint rejects invalid code_challenge_method" do @@ -478,7 +477,7 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest assert_response :bad_request error = JSON.parse(@response.body) assert_equal "invalid_request", error["error"] - assert_match /PKCE is required for public clients/, error["error_description"] + assert_match(/PKCE is required for public clients/, error["error_description"]) # Cleanup OidcRefreshToken.where(application: public_app).delete_all @@ -525,7 +524,7 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest assert_response :bad_request error = JSON.parse(@response.body) assert_equal "invalid_request", error["error"] - assert_match /PKCE is required/, error["error_description"] + assert_match(/PKCE is required/, error["error_description"]) end # ==================== @@ -697,4 +696,4 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest expected_hash = Base64.urlsafe_encode64(Digest::SHA256.digest(access_token)[0..15], padding: false) assert_equal expected_hash, decoded["at_hash"], "at_hash should match SHA-256 hash of access token" end -end \ No newline at end of file +end diff --git a/test/controllers/passwords_controller_test.rb b/test/controllers/passwords_controller_test.rb index 74cd926..775ac85 100644 --- a/test/controllers/passwords_controller_test.rb +++ b/test/controllers/passwords_controller_test.rb @@ -9,8 +9,8 @@ class PasswordsControllerTest < ActionDispatch::IntegrationTest end test "create" do - post passwords_path, params: { email_address: @user.email_address } - assert_enqueued_email_with PasswordsMailer, :reset, args: [ @user ] + post passwords_path, params: {email_address: @user.email_address} + assert_enqueued_email_with PasswordsMailer, :reset, args: [@user] assert_redirected_to signin_path follow_redirect! @@ -18,7 +18,7 @@ class PasswordsControllerTest < ActionDispatch::IntegrationTest end 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_redirected_to signin_path @@ -41,7 +41,7 @@ class PasswordsControllerTest < ActionDispatch::IntegrationTest test "update" 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 end @@ -52,7 +52,7 @@ class PasswordsControllerTest < ActionDispatch::IntegrationTest test "update with non matching passwords" do token = @user.password_reset_token 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) end @@ -61,7 +61,8 @@ class PasswordsControllerTest < ActionDispatch::IntegrationTest end private - def assert_notice(text) - assert_select "div", /#{text}/ - end + + def assert_notice(text) + assert_select "div", /#{text}/ + end end diff --git a/test/controllers/sessions_controller_test.rb b/test/controllers/sessions_controller_test.rb index 9630ea3..c273f59 100644 --- a/test/controllers/sessions_controller_test.rb +++ b/test/controllers/sessions_controller_test.rb @@ -9,14 +9,14 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest end 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 cookies[:session_id] end 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_nil cookies[:session_id] diff --git a/test/controllers/totp_security_test.rb b/test/controllers/totp_security_test.rb index f3a6501..43d4068 100644 --- a/test/controllers/totp_security_test.rb +++ b/test/controllers/totp_security_test.rb @@ -14,11 +14,11 @@ class TotpSecurityTest < ActionDispatch::IntegrationTest valid_code = totp.now # 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 # 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_redirected_to root_path @@ -50,12 +50,12 @@ class TotpSecurityTest < ActionDispatch::IntegrationTest original_codes = user.reload.backup_codes # 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 # Use a backup code 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 assert_response :redirect @@ -70,11 +70,11 @@ class TotpSecurityTest < ActionDispatch::IntegrationTest assert_response :redirect # 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 # 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 assert_response :redirect @@ -91,13 +91,13 @@ class TotpSecurityTest < ActionDispatch::IntegrationTest # Generate backup codes user.totp_secret = ROTP::Base32.random - backup_codes = user.send(:generate_backup_codes) # Call private method + user.send(:generate_backup_codes) # Call private method user.save! # Check that stored codes are BCrypt hashes (start with $2a$) # backup_codes is already an Array (JSON column), no need to parse user.backup_codes.each do |code| - assert_match /^\$2[aby]\$/, code, "Backup codes should be BCrypt hashed" + assert_match(/^\$2[aby]\$/, code, "Backup codes should be BCrypt hashed") end user.destroy @@ -116,7 +116,7 @@ class TotpSecurityTest < ActionDispatch::IntegrationTest user.save! # 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 # 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 # 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 assert_response :redirect @@ -145,16 +145,16 @@ class TotpSecurityTest < ActionDispatch::IntegrationTest # Verify the TOTP secret exists (sanity check) assert user.totp_secret.present? - totp_secret = user.totp_secret + user.totp_secret # Sign in with TOTP - post signin_path, params: { email_address: "totp_secret_test@example.com", password: "password123" } + post signin_path, params: {email_address: "totp_secret_test@example.com", password: "password123"} assert_redirected_to totp_verification_path # Complete TOTP verification totp = ROTP::TOTP.new(user.totp_secret) valid_code = totp.now - post totp_verification_path, params: { code: valid_code } + post totp_verification_path, params: {code: valid_code} assert_response :redirect # 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) # 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 assert_response :redirect @@ -232,7 +232,7 @@ class TotpSecurityTest < ActionDispatch::IntegrationTest user.save! # 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 # Try invalid formats @@ -245,7 +245,7 @@ class TotpSecurityTest < ActionDispatch::IntegrationTest ] 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_redirected_to totp_verification_path end @@ -266,11 +266,11 @@ class TotpSecurityTest < ActionDispatch::IntegrationTest user.save! # 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 # 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 assert_response :redirect diff --git a/test/integration/forward_auth_integration_test.rb b/test/integration/forward_auth_integration_test.rb index 0ae6e9d..a824f5e 100644 --- a/test/integration/forward_auth_integration_test.rb +++ b/test/integration/forward_auth_integration_test.rb @@ -20,27 +20,27 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest # Basic Authentication Flow Tests test "complete authentication flow: unauthenticated to authenticated" do # 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_match %r{/signin}, response.location assert_equal "No session cookie", response.headers["x-auth-reason"] # 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 # Signin now redirects back with fa_token parameter assert_match(/\?fa_token=/, response.location) assert cookies[:session_id] # 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_equal @user.email_address, response.headers["x-remote-user"] end test "session expiration handling" do # 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) 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) # 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_equal "Session expired", response.headers["x-auth-reason"] end @@ -56,24 +56,24 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest # Domain and Rule Integration Tests test "different domain patterns with same session" do # Create test rules - wildcard_rule = Application.create!(name: "Wildcard App", slug: "wildcard-app", app_type: "forward_auth", domain_pattern: "*.example.com", active: true) - exact_rule = Application.create!(name: "Exact App", slug: "exact-app", app_type: "forward_auth", domain_pattern: "api.example.com", active: true) + Application.create!(name: "Wildcard App", slug: "wildcard-app", app_type: "forward_auth", domain_pattern: "*.example.com", active: true) + Application.create!(name: "Exact App", slug: "exact-app", app_type: "forward_auth", domain_pattern: "api.example.com", active: true) # Sign in - post "/signin", params: { email_address: @user.email_address, password: "password" } + post "/signin", params: {email_address: @user.email_address, password: "password"} # 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_equal @user.email_address, response.headers["x-remote-user"] # 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_equal @user.email_address, response.headers["x-remote-user"] # 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_equal @user.email_address, response.headers["x-remote-user"] end @@ -84,10 +84,10 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest restricted_rule.allowed_groups << @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 - get "/api/verify", headers: { "X-Forwarded-Host" => "restricted.example.com" } + get "/api/verify", headers: {"X-Forwarded-Host" => "restricted.example.com"} assert_response 403 assert_match %r{permission to access this domain}, response.headers["x-auth-reason"] @@ -95,7 +95,7 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest @user.groups << @group # 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_equal @user.email_address, response.headers["x-remote-user"] end @@ -103,18 +103,18 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest # Header Configuration Integration Tests test "different header configurations with same user" do # Create applications with different configs - default_rule = Application.create!(name: "Default App", slug: "default-app", app_type: "forward_auth", domain_pattern: "default.example.com", active: true) - custom_rule = Application.create!( + Application.create!(name: "Default App", slug: "default-app", app_type: "forward_auth", domain_pattern: "default.example.com", active: true) + Application.create!( name: "Custom App", slug: "custom-app", app_type: "forward_auth", domain_pattern: "custom.example.com", active: true, - headers_config: { user: "X-WEBAUTH-USER", groups: "X-WEBAUTH-ROLES" } + headers_config: {user: "X-WEBAUTH-USER", groups: "X-WEBAUTH-ROLES"} ) - no_headers_rule = Application.create!( + Application.create!( name: "No Headers App", slug: "no-headers-app", app_type: "forward_auth", domain_pattern: "noheaders.example.com", active: true, - headers_config: { user: "", email: "", name: "", groups: "", admin: "" } + headers_config: {user: "", email: "", name: "", groups: "", admin: ""} ) # Add user to groups @@ -122,10 +122,10 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest @user.groups << @group2 # 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 - get "/api/verify", headers: { "X-Forwarded-Host" => "default.example.com" } + get "/api/verify", headers: {"X-Forwarded-Host" => "default.example.com"} assert_response 200 # Rails normalizes header keys to lowercase 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"] # 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 # Custom headers are also normalized to lowercase 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"] # 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 # 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) } @@ -174,7 +174,7 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest get "/api/verify", headers: { "X-Forwarded-Host" => "app.example.com", "X-Forwarded-Uri" => "/admin" - }, params: { rd: "https://app.example.com/admin" } + }, params: {rd: "https://app.example.com/admin"} assert_response 302 location = response.location @@ -194,16 +194,16 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest admin_user = users(:two) # Create restricted rule - admin_rule = Application.create!( + Application.create!( name: "Admin App", slug: "admin-app", app_type: "forward_auth", domain_pattern: "admin.example.com", active: true, - headers_config: { user: "X-Admin-User", admin: "X-Admin-Flag" } + headers_config: {user: "X-Admin-User", admin: "X-Admin-Flag"} ) # Test regular user - post "/signin", params: { email_address: regular_user.email_address, password: "password" } - get "/api/verify", headers: { "X-Forwarded-Host" => "admin.example.com" } + post "/signin", params: {email_address: regular_user.email_address, password: "password"} + get "/api/verify", headers: {"X-Forwarded-Host" => "admin.example.com"} assert_response 200 assert_equal regular_user.email_address, response.headers["x-admin-user"] @@ -211,8 +211,8 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest delete "/session" # Test admin user - post "/signin", params: { email_address: admin_user.email_address, password: "password" } - get "/api/verify", headers: { "X-Forwarded-Host" => "admin.example.com" } + post "/signin", params: {email_address: admin_user.email_address, password: "password"} + get "/api/verify", headers: {"X-Forwarded-Host" => "admin.example.com"} assert_response 200 assert_equal admin_user.email_address, response.headers["x-admin-user"] assert_equal "true", response.headers["x-admin-flag"] @@ -221,10 +221,10 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest # Security Integration Tests test "session hijacking prevention" do # 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 - get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" } + get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"} assert_response 200 assert_equal @user.email_address, response.headers["x-remote-user"] user_a_session_id = Session.where(user: @user).last.id @@ -233,10 +233,10 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest reset! # 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 - get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" } + get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"} assert_response 200 assert_equal @admin_user.email_address, response.headers["x-remote-user"] 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_b_session_id), "User B's session should still exist" end - -end \ No newline at end of file +end diff --git a/test/integration/invitation_flow_test.rb b/test/integration/invitation_flow_test.rb index 05c52c5..fe4668e 100644 --- a/test/integration/invitation_flow_test.rb +++ b/test/integration/invitation_flow_test.rb @@ -94,7 +94,7 @@ class InvitationFlowTest < ActionDispatch::IntegrationTest end test "expired invitation token flow" do - user = User.create!( + User.create!( email_address: "expired@example.com", password: "temppassword", status: :pending_invitation @@ -178,4 +178,4 @@ class InvitationFlowTest < ActionDispatch::IntegrationTest assert_not_equal old_session1.id, user.sessions.first.id assert_not_equal old_session2.id, user.sessions.first.id end -end \ No newline at end of file +end diff --git a/test/integration/session_security_test.rb b/test/integration/session_security_test.rb index 99f066f..d9412ec 100644 --- a/test/integration/session_security_test.rb +++ b/test/integration/session_security_test.rb @@ -9,7 +9,7 @@ class SessionSecurityTest < ActionDispatch::IntegrationTest user = User.create!(email_address: "session_test@example.com", password: "password123") # 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 follow_redirect! assert_response :success @@ -75,7 +75,7 @@ class SessionSecurityTest < ActionDispatch::IntegrationTest user = User.create!(email_address: "session_fixation_test@example.com", password: "password123") # 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 # 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") # Create multiple sessions from different devices - session1 = user.sessions.create!( + user.sessions.create!( ip_address: "192.168.1.1", user_agent: "Mozilla/5.0 (Windows)", device_name: "Windows PC", last_activity_at: Time.current ) - session2 = user.sessions.create!( + user.sessions.create!( ip_address: "192.168.1.2", user_agent: "Mozilla/5.0 (iPhone)", device_name: "iPhone", last_activity_at: Time.current ) - session3 = user.sessions.create!( + user.sessions.create!( ip_address: "192.168.1.3", user_agent: "Mozilla/5.0 (Macintosh)", device_name: "MacBook", @@ -157,14 +157,14 @@ class SessionSecurityTest < ActionDispatch::IntegrationTest user = User.create!(email_address: "logout_test@example.com", password: "password123") # Create multiple sessions - session1 = user.sessions.create!( + user.sessions.create!( ip_address: "192.168.1.1", user_agent: "Mozilla/5.0 (Windows)", device_name: "Windows PC", last_activity_at: Time.current ) - session2 = user.sessions.create!( + user.sessions.create!( ip_address: "192.168.1.2", user_agent: "Mozilla/5.0 (iPhone)", device_name: "iPhone", @@ -172,7 +172,7 @@ class SessionSecurityTest < ActionDispatch::IntegrationTest ) # 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 # Should have 3 sessions now @@ -204,7 +204,7 @@ class SessionSecurityTest < ActionDispatch::IntegrationTest ) # Create consent with backchannel logout enabled - consent = OidcUserConsent.create!( + OidcUserConsent.create!( user: user, application: application, scopes_granted: "openid profile", @@ -212,7 +212,7 @@ class SessionSecurityTest < ActionDispatch::IntegrationTest ) # 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 # Sign out @@ -237,8 +237,8 @@ class SessionSecurityTest < ActionDispatch::IntegrationTest user = User.create!(email_address: "hijacking_test@example.com", password: "password123") # Sign in - post signin_path, params: { email_address: "hijacking_test@example.com", password: "password123" }, - headers: { "HTTP_USER_AGENT" => "TestBrowser/1.0" } + post signin_path, params: {email_address: "hijacking_test@example.com", password: "password123"}, + headers: {"HTTP_USER_AGENT" => "TestBrowser/1.0"} assert_response :redirect # Check that session includes IP and user agent @@ -295,7 +295,7 @@ class SessionSecurityTest < ActionDispatch::IntegrationTest # Test forward auth endpoint with valid session 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 assert_response :redirect diff --git a/test/integration/webauthn_credential_enumeration_test.rb b/test/integration/webauthn_credential_enumeration_test.rb index 43d3547..3fb6aea 100644 --- a/test/integration/webauthn_credential_enumeration_test.rb +++ b/test/integration/webauthn_credential_enumeration_test.rb @@ -10,7 +10,7 @@ class WebauthnCredentialEnumerationTest < ActionDispatch::IntegrationTest user2 = User.create!(email_address: "user2@example.com", password: "password123") # Create a credential for user1 - credential1 = user1.webauthn_credentials.create!( + user1.webauthn_credentials.create!( external_id: Base64.urlsafe_encode64("user1_credential"), public_key: Base64.urlsafe_encode64("public_key_1"), sign_count: 0, @@ -28,7 +28,7 @@ class WebauthnCredentialEnumerationTest < ActionDispatch::IntegrationTest ) # 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 follow_redirect! @@ -66,7 +66,7 @@ class WebauthnCredentialEnumerationTest < ActionDispatch::IntegrationTest ) # 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 follow_redirect! diff --git a/test/jobs/application_job_test.rb b/test/jobs/application_job_test.rb index 939457b..e1c9baa 100644 --- a/test/jobs/application_job_test.rb +++ b/test/jobs/application_job_test.rb @@ -37,7 +37,7 @@ class ApplicationJobTest < ActiveJob::TestCase end assert_enqueued_jobs 1 do - test_job.perform_later("arg1", "arg2", { "key" => "value" }) + test_job.perform_later("arg1", "arg2", {"key" => "value"}) end # ActiveJob serializes all hash keys as strings @@ -77,7 +77,7 @@ class ApplicationJobTest < ActiveJob::TestCase args = enqueued_jobs.last[:args] if args.is_a?(Array) && args.first.is_a?(Hash) # GlobalID serialization format - assert_equal user.to_global_id.to_s, args.first['_aj_globalid'] + assert_equal user.to_global_id.to_s, args.first["_aj_globalid"] else # Direct object serialization assert_equal user.id, args.first.id @@ -90,4 +90,4 @@ class ApplicationJobTest < ActiveJob::TestCase assert_respond_to ApplicationJob, :retry_on assert_respond_to ApplicationJob, :discard_on end -end \ No newline at end of file +end diff --git a/test/jobs/invitations_mailer_test.rb b/test/jobs/invitations_mailer_test.rb index 1c6db1e..039bb3b 100644 --- a/test/jobs/invitations_mailer_test.rb +++ b/test/jobs/invitations_mailer_test.rb @@ -118,4 +118,4 @@ class InvitationsMailerTest < ActionMailer::TestCase assert_includes email.content_type, "multipart" assert email.html_part || email.text_part, "Should have html or text part" end -end \ No newline at end of file +end diff --git a/test/jobs/passwords_mailer_test.rb b/test/jobs/passwords_mailer_test.rb index cc133d1..daf0891 100644 --- a/test/jobs/passwords_mailer_test.rb +++ b/test/jobs/passwords_mailer_test.rb @@ -166,7 +166,7 @@ class PasswordsMailerTest < ActionMailer::TestCase # Should not include sensitive data in headers (except Subject which legitimately mentions password) email.header.fields.each do |field| - next if field.name =~ /^subject$/i + next if /^subject$/i.match?(field.name) # Check for actual tokens (not just the word "token" which is common in emails) refute_includes field.value.to_s.downcase, "password" end @@ -197,4 +197,4 @@ class PasswordsMailerTest < ActionMailer::TestCase assert_equal [email_address], email.to end end -end \ No newline at end of file +end diff --git a/test/models/application_user_claim_test.rb b/test/models/application_user_claim_test.rb index 1f7a651..427e502 100644 --- a/test/models/application_user_claim_test.rb +++ b/test/models/application_user_claim_test.rb @@ -10,7 +10,7 @@ class ApplicationUserClaimTest < ActiveSupport::TestCase claim = ApplicationUserClaim.new( user: @user, application: @application, - custom_claims: { "role": "admin" } + custom_claims: {role: "admin"} ) assert claim.valid? assert claim.save @@ -20,13 +20,13 @@ class ApplicationUserClaimTest < ActiveSupport::TestCase ApplicationUserClaim.create!( user: @user, application: @application, - custom_claims: { "role": "admin" } + custom_claims: {role: "admin"} ) duplicate = ApplicationUserClaim.new( user: @user, application: @application, - custom_claims: { "role": "user" } + custom_claims: {role: "user"} ) assert_not duplicate.valid? @@ -37,7 +37,7 @@ class ApplicationUserClaimTest < ActiveSupport::TestCase claim = ApplicationUserClaim.new( user: @user, application: @application, - custom_claims: { "role": "admin", "level": 5 } + custom_claims: {role: "admin", level: 5} ) parsed = claim.parsed_custom_claims @@ -59,7 +59,7 @@ class ApplicationUserClaimTest < ActiveSupport::TestCase claim = ApplicationUserClaim.new( user: @user, application: @application, - custom_claims: { "groups": ["admin"], "role": "user" } + custom_claims: {groups: ["admin"], role: "user"} ) assert_not claim.valid? @@ -70,7 +70,7 @@ class ApplicationUserClaimTest < ActiveSupport::TestCase claim = ApplicationUserClaim.new( user: @user, application: @application, - custom_claims: { "kavita_groups": ["admin"], "role": "user" } + custom_claims: {kavita_groups: ["admin"], role: "user"} ) assert claim.valid? diff --git a/test/models/oidc_access_token_test.rb b/test/models/oidc_access_token_test.rb index a40082d..75dfdd3 100644 --- a/test/models/oidc_access_token_test.rb +++ b/test/models/oidc_access_token_test.rb @@ -27,7 +27,7 @@ class OidcAccessTokenTest < ActiveSupport::TestCase assert_nil new_token.plaintext_token assert new_token.save assert_not_nil new_token.plaintext_token - assert_match /^[A-Za-z0-9_-]+$/, new_token.plaintext_token + assert_match(/^[A-Za-z0-9_-]+$/, new_token.plaintext_token) end test "should set expiry before validation on create" do @@ -144,7 +144,7 @@ class OidcAccessTokenTest < ActiveSupport::TestCase # All tokens should match the expected pattern tokens.each do |token| - assert_match /^[A-Za-z0-9_-]+$/, token + assert_match(/^[A-Za-z0-9_-]+$/, token) # Base64 token length may vary due to padding, just ensure it's reasonable assert token.length >= 43, "Token should be at least 43 characters" assert token.length <= 64, "Token should not exceed 64 characters" @@ -164,7 +164,7 @@ class OidcAccessTokenTest < ActiveSupport::TestCase ) 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 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 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 test "revoked tokens should not appear in valid scope" do diff --git a/test/models/oidc_authorization_code_test.rb b/test/models/oidc_authorization_code_test.rb index bfa3a17..4f81844 100644 --- a/test/models/oidc_authorization_code_test.rb +++ b/test/models/oidc_authorization_code_test.rb @@ -28,7 +28,7 @@ class OidcAuthorizationCodeTest < ActiveSupport::TestCase assert_nil new_code.code_hmac assert new_code.save assert_not_nil new_code.code_hmac - assert_match /^[a-f0-9]{64}$/, new_code.code_hmac # SHA256 hex digest + assert_match(/^[a-f0-9]{64}$/, new_code.code_hmac) # SHA256 hex digest end test "should set expiry before validation on create" do @@ -186,7 +186,7 @@ class OidcAuthorizationCodeTest < ActiveSupport::TestCase # All codes should be SHA256 hex digests codes.each do |code| - assert_match /^[a-f0-9]{64}$/, code + assert_match(/^[a-f0-9]{64}$/, code) assert_equal 64, code.length # SHA256 hex digest end end diff --git a/test/models/oidc_user_consent_test.rb b/test/models/oidc_user_consent_test.rb index 3a1bdce..b7a4e4f 100644 --- a/test/models/oidc_user_consent_test.rb +++ b/test/models/oidc_user_consent_test.rb @@ -218,7 +218,7 @@ class OidcUserConsentTest < ActiveSupport::TestCase # Application requests more than granted assert_not @consent.covers_scopes?(["openid", "profile", "groups"]), - "Should not cover scopes not granted" + "Should not cover scopes not granted" # Application requests subset assert @consent.covers_scopes?(["email"]), "Should cover subset of granted scopes" diff --git a/test/models/pkce_authorization_code_test.rb b/test/models/pkce_authorization_code_test.rb index 39e8a2b..280aa4d 100644 --- a/test/models/pkce_authorization_code_test.rb +++ b/test/models/pkce_authorization_code_test.rb @@ -165,4 +165,4 @@ class PkceAuthorizationCodeTest < ActiveSupport::TestCase # Should be valid even without code_challenge assert auth_code.valid? end -end \ No newline at end of file +end diff --git a/test/models/user_password_management_test.rb b/test/models/user_password_management_test.rb index 75a4ebb..4e652b4 100644 --- a/test/models/user_password_management_test.rb +++ b/test/models/user_password_management_test.rb @@ -73,7 +73,7 @@ class UserPasswordManagementTest < ActiveSupport::TestCase assert_not authenticated_user.authenticate("WrongPassword"), "Should not authenticate with wrong password" # Test password changes invalidate old sessions - old_password_digest = @user.password_digest + @user.password_digest @user.password = "NewPassword123!" @user.save! @@ -102,7 +102,7 @@ class UserPasswordManagementTest < ActiveSupport::TestCase assert new_user.password_digest.length > 50, "Password digest should be substantial" # Test digest format (bcrypt hashes start with $2a$) - assert_match /^\$2a\$/, new_user.password_digest, "Password digest should be bcrypt format" + assert_match(/^\$2a\$/, new_user.password_digest, "Password digest should be bcrypt format") # Test authentication against digest authenticated_user = User.find(new_user.id) @@ -250,4 +250,4 @@ class UserPasswordManagementTest < ActiveSupport::TestCase assert_not_nil @user.last_sign_in_at, "last_sign_in_at should be set after update" assert @user.last_sign_in_at > 1.minute.ago, "last_sign_in_at should be recent" end -end \ No newline at end of file +end diff --git a/test/models/user_test.rb b/test/models/user_test.rb index 103bfa6..b8bec52 100644 --- a/test/models/user_test.rb +++ b/test/models/user_test.rb @@ -33,7 +33,7 @@ class UserTest < ActiveSupport::TestCase end test "does not find user with invalid invitation token" do - user = User.create!( + User.create!( email_address: "test@example.com", password: "password123", status: :pending_invitation @@ -222,7 +222,7 @@ class UserTest < ActiveSupport::TestCase # Should store 10 BCrypt hashes assert_equal 10, stored_hashes.length stored_hashes.each do |hash| - assert hash.start_with?('$2a$'), "Should be BCrypt hash" + assert hash.start_with?("$2a$"), "Should be BCrypt hash" end # Verify each plain code matches its corresponding hash @@ -298,7 +298,7 @@ class UserTest < ActiveSupport::TestCase # Make 5 failed attempts to trigger rate limit 5.times do |i| 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 # Check that the cache is tracking attempts diff --git a/test/services/oidc_jwt_service_test.rb b/test/services/oidc_jwt_service_test.rb index f994fa1..92a34d9 100644 --- a/test/services/oidc_jwt_service_test.rb +++ b/test/services/oidc_jwt_service_test.rb @@ -61,18 +61,18 @@ class OidcJwtServiceTest < ActiveSupport::TestCase assert_not_nil token, "Should generate token" assert token.length > 100, "Token should be substantial" - assert token.include?('.') + assert token.include?(".") # Decode without verification for testing the payload decoded = JWT.decode(token, nil, false).first - assert_equal @application.client_id, decoded['aud'], "Should have correct audience" - assert_equal @user.id.to_s, decoded['sub'], "Should have correct subject" - assert_equal @user.email_address, decoded['email'], "Should have correct email" - assert_equal true, decoded['email_verified'], "Should have email verified" - assert_equal @user.email_address, decoded['preferred_username'], "Should have preferred username" - assert_equal @user.email_address, decoded['name'], "Should have name" - assert_equal @service.issuer_url, decoded['iss'], "Should have correct issuer" - assert_in_delta Time.current.to_i + 3600, decoded['exp'], 5, "Should have correct expiration" + assert_equal @application.client_id, decoded["aud"], "Should have correct audience" + assert_equal @user.id.to_s, decoded["sub"], "Should have correct subject" + assert_equal @user.email_address, decoded["email"], "Should have correct email" + assert_equal true, decoded["email_verified"], "Should have email verified" + assert_equal @user.email_address, decoded["preferred_username"], "Should have preferred username" + assert_equal @user.email_address, decoded["name"], "Should have name" + assert_equal @service.issuer_url, decoded["iss"], "Should have correct issuer" + assert_in_delta Time.current.to_i + 3600, decoded["exp"], 5, "Should have correct expiration" end test "should handle nonce in id token" do @@ -80,8 +80,8 @@ class OidcJwtServiceTest < ActiveSupport::TestCase token = @service.generate_id_token(@user, @application, nonce: nonce) decoded = JWT.decode(token, nil, false).first - assert_equal nonce, decoded['nonce'], "Should preserve nonce in token" - assert_in_delta Time.current.to_i + 3600, decoded['exp'], 5, "Should have correct expiration with nonce" + assert_equal nonce, decoded["nonce"], "Should preserve nonce in token" + assert_in_delta Time.current.to_i + 3600, decoded["exp"], 5, "Should have correct expiration with nonce" end test "should include groups in token when user has groups" do @@ -91,7 +91,7 @@ class OidcJwtServiceTest < ActiveSupport::TestCase token = @service.generate_id_token(@user, @application) decoded = JWT.decode(token, nil, false).first - assert_includes decoded['groups'], "Administrators", "Should include user's groups" + assert_includes decoded["groups"], "Administrators", "Should include user's groups" end test "admin claim should not be included in token" do @@ -100,14 +100,14 @@ class OidcJwtServiceTest < ActiveSupport::TestCase token = @service.generate_id_token(@user, @application) decoded = JWT.decode(token, nil, false).first - refute decoded.key?('admin'), "Admin claim should not be included in ID tokens (use groups instead)" + refute decoded.key?("admin"), "Admin claim should not be included in ID tokens (use groups instead)" end test "should handle missing roles gracefully" do token = @service.generate_id_token(@user, @application) decoded = JWT.decode(token, nil, false).first - refute_includes decoded, 'roles', "Should not have roles when not configured" + refute_includes decoded, "roles", "Should not have roles when not configured" end test "should load RSA private key from environment with escaped newlines" do @@ -168,7 +168,7 @@ class OidcJwtServiceTest < ActiveSupport::TestCase OidcJwtService.send(:private_key) end - assert_match /Invalid OIDC private key format/, error.message + assert_match(/Invalid OIDC private key format/, error.message) ensure # Restore original value and clear cached key ENV["OIDC_PRIVATE_KEY"] = original_value @@ -193,7 +193,7 @@ class OidcJwtServiceTest < ActiveSupport::TestCase OidcJwtService.send(:private_key) end - assert_match /OIDC private key not configured/, error.message + assert_match(/OIDC private key not configured/, error.message) ensure # Restore original environment and clear cached key ENV["OIDC_PRIVATE_KEY"] = original_value if original_value @@ -214,9 +214,9 @@ class OidcJwtServiceTest < ActiveSupport::TestCase assert_not_nil decoded_array, "Should decode valid token" decoded = decoded_array.first # JWT.decode returns an array - assert_equal @user.id.to_s, decoded['sub'], "Should decode subject correctly" - assert_equal @application.client_id, decoded['aud'], "Should decode audience correctly" - assert decoded['exp'] > Time.current.to_i, "Token should not be expired" + assert_equal @user.id.to_s, decoded["sub"], "Should decode subject correctly" + assert_equal @application.client_id, decoded["aud"], "Should decode audience correctly" + assert decoded["exp"] > Time.current.to_i, "Token should not be expired" end test "should reject invalid id tokens" do @@ -252,9 +252,9 @@ class OidcJwtServiceTest < ActiveSupport::TestCase decoded = JWT.decode(token, nil, false).first # ID tokens always include email_verified - assert_includes decoded.keys, 'email_verified' - assert_equal @user.id.to_s, decoded['sub'], "Should decode subject correctly" - assert_equal @application.client_id, decoded['aud'], "Should decode audience correctly" + assert_includes decoded.keys, "email_verified" + assert_equal @user.id.to_s, decoded["sub"], "Should decode subject correctly" + assert_equal @application.client_id, decoded["aud"], "Should decode audience correctly" end test "should validate JWT configuration" do @@ -275,7 +275,7 @@ class OidcJwtServiceTest < ActiveSupport::TestCase ApplicationUserClaim.create!( user: user, application: app, - custom_claims: { "app_groups": ["admin"], "library_access": "all" } + custom_claims: {app_groups: ["admin"], library_access: "all"} ) token = @service.generate_id_token(user, app) @@ -292,17 +292,17 @@ class OidcJwtServiceTest < ActiveSupport::TestCase # Add user to group with claims group = groups(:admin_group) - group.update!(custom_claims: { "role": "viewer", "max_items": 10 }) + group.update!(custom_claims: {role: "viewer", max_items: 10}) user.groups << group # Add user custom claims - user.update!(custom_claims: { "role": "editor", "theme": "dark" }) + user.update!(custom_claims: {role: "editor", theme: "dark"}) # Add app-specific claims (should override both) ApplicationUserClaim.create!( user: user, application: app, - custom_claims: { "role": "admin", "app_specific": true } + custom_claims: {role: "admin", app_specific: true} ) token = @service.generate_id_token(user, app) @@ -324,11 +324,11 @@ class OidcJwtServiceTest < ActiveSupport::TestCase # Group has roles: ["user"] group = groups(:admin_group) - group.update!(custom_claims: { "roles" => ["user"], "permissions" => ["read"] }) + group.update!(custom_claims: {"roles" => ["user"], "permissions" => ["read"]}) user.groups << group # 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) decoded = JWT.decode(token, nil, false).first @@ -349,16 +349,16 @@ class OidcJwtServiceTest < ActiveSupport::TestCase # First group has roles: ["user"] group1 = groups(:admin_group) - group1.update!(custom_claims: { "roles" => ["user"] }) + group1.update!(custom_claims: {"roles" => ["user"]}) user.groups << group1 # Second group has roles: ["moderator"] group2 = Group.create!(name: "moderators", description: "Moderators group") - group2.update!(custom_claims: { "roles" => ["moderator"] }) + group2.update!(custom_claims: {"roles" => ["moderator"]}) user.groups << group2 # User adds roles: ["admin"] - user.update!(custom_claims: { "roles" => ["admin"] }) + user.update!(custom_claims: {"roles" => ["admin"]}) token = @service.generate_id_token(user, app) decoded = JWT.decode(token, nil, false).first @@ -376,11 +376,11 @@ class OidcJwtServiceTest < ActiveSupport::TestCase # Group has roles: ["user", "reader"] group = groups(:admin_group) - group.update!(custom_claims: { "roles" => ["user", "reader"] }) + group.update!(custom_claims: {"roles" => ["user", "reader"]}) user.groups << group # 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) decoded = JWT.decode(token, nil, false).first @@ -398,11 +398,11 @@ class OidcJwtServiceTest < ActiveSupport::TestCase # Group has roles array and max_items scalar 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 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) decoded = JWT.decode(token, nil, false).first @@ -425,7 +425,7 @@ class OidcJwtServiceTest < ActiveSupport::TestCase group.update!(custom_claims: { "config" => { "theme" => "light", - "notifications" => { "email" => true } + "notifications" => {"email" => true} } }) user.groups << group @@ -434,7 +434,7 @@ class OidcJwtServiceTest < ActiveSupport::TestCase user.update!(custom_claims: { "config" => { "language" => "en", - "notifications" => { "sms" => true } + "notifications" => {"sms" => true} } }) @@ -454,17 +454,17 @@ class OidcJwtServiceTest < ActiveSupport::TestCase # Group has roles: ["user"] group = groups(:admin_group) - group.update!(custom_claims: { "roles" => ["user"] }) + group.update!(custom_claims: {"roles" => ["user"]}) user.groups << group # User has roles: ["moderator"] - user.update!(custom_claims: { "roles" => ["moderator"] }) + user.update!(custom_claims: {"roles" => ["moderator"]}) # App-specific has roles: ["app_admin"] ApplicationUserClaim.create!( user: user, application: app, - custom_claims: { "roles" => ["app_admin"] } + custom_claims: {"roles" => ["app_admin"]} ) token = @service.generate_id_token(user, app) @@ -562,4 +562,4 @@ class OidcJwtServiceTest < ActiveSupport::TestCase assert_includes decoded.keys, "azp", "Should include azp claim" assert_equal @application.client_id, decoded["azp"], "azp should be the application's client_id" end -end \ No newline at end of file +end diff --git a/test/system/forward_auth_system_test.rb b/test/system/forward_auth_system_test.rb index c9cba5d..b7015ea 100644 --- a/test/system/forward_auth_system_test.rb +++ b/test/system/forward_auth_system_test.rb @@ -13,13 +13,13 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase # End-to-End Authentication Flow Tests test "complete forward auth flow with default headers" do # Create an application with default headers - rule = Application.create!(name: "App", slug: "app-system-test", app_type: "forward_auth", domain_pattern: "app.example.com", active: true) + Application.create!(name: "App", slug: "app-system-test", app_type: "forward_auth", domain_pattern: "app.example.com", active: true) # Step 1: Unauthenticated request to protected resource get "/api/verify", headers: { "X-Forwarded-Host" => "app.example.com", "X-Forwarded-Uri" => "/dashboard" - }, params: { rd: "https://app.example.com/dashboard" } + }, params: {rd: "https://app.example.com/dashboard"} assert_response 302 location = response.location @@ -30,13 +30,13 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase assert_equal "https://app.example.com/dashboard", session[:return_to_after_authenticating] # 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_redirected_to "https://app.example.com/dashboard" # 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_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 # Create applications for different domains - app_rule = Application.create!(name: "App Domain", slug: "app-domain", app_type: "forward_auth", domain_pattern: "app.example.com", active: true) - grafana_rule = Application.create!( + Application.create!(name: "App Domain", slug: "app-domain", app_type: "forward_auth", domain_pattern: "app.example.com", active: true) + Application.create!( name: "Grafana", slug: "grafana-system-test", app_type: "forward_auth", domain_pattern: "grafana.example.com", active: true, - headers_config: { user: "X-WEBAUTH-USER", email: "X-WEBAUTH-EMAIL" } + headers_config: {user: "X-WEBAUTH-USER", email: "X-WEBAUTH-EMAIL"} ) - metube_rule = Application.create!( + Application.create!( name: "Metube", slug: "metube-system-test", app_type: "forward_auth", domain_pattern: "metube.example.com", active: true, - headers_config: { user: "", email: "", name: "", groups: "", admin: "" } + headers_config: {user: "", email: "", name: "", groups: "", admin: ""} ) # 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_redirected_to "/" # Test access to different applications # 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.headers.key?("x-remote-user") # 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.headers.key?("x-webauth-user") # 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 auth_headers = response.headers.select { |k, v| k.match?(/^x-remote-|^x-webauth-|^x-admin-/i) } assert_empty auth_headers @@ -98,11 +98,11 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase @user.groups << @group # 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 # 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_equal @group.name, response.headers["x-remote-groups"] @@ -110,7 +110,7 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase @user.groups << @group2 # 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 groups_header = response.headers["x-remote-groups"] assert_includes groups_header, @group.name @@ -120,13 +120,13 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase @user.groups.clear # 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 end test "bypass mode when no groups assigned to rule" do # Create bypass application (no groups) - bypass_rule = Application.create!( + Application.create!( name: "Public", slug: "public-system-test", app_type: "forward_auth", domain_pattern: "public.example.com", active: true @@ -136,11 +136,11 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase @user.groups.clear # 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 # 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_equal @user.email_address, response.headers["x-remote-user"] end @@ -148,12 +148,12 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase # Security System Tests test "session security and isolation" do # 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 B signs in 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 A should still be able to access resources @@ -178,11 +178,11 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase test "session expiration and cleanup" do # 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] # 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 # Manually expire session @@ -190,7 +190,7 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase session.update!(expires_at: 1.hour.ago) # 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_equal "Session expired", response.headers["x-auth-reason"] @@ -200,7 +200,7 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase test "concurrent access with rate limiting considerations" do # 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] # Simulate multiple concurrent requests from different IPs @@ -244,23 +244,23 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase apps = [ { 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] }, { 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: [] }, { domain: "logs.example.com", - headers_config: { user: "", email: "", name: "", groups: "", admin: "" }, + headers_config: {user: "", email: "", name: "", groups: "", admin: ""}, groups: [] } ] # Create applications for each app - rules = apps.map.with_index do |app, idx| + apps.map.with_index do |app, idx| rule = Application.create!( name: "Multi App #{idx}", slug: "multi-app-#{idx}", app_type: "forward_auth", domain_pattern: app[:domain], @@ -275,19 +275,19 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase @user.groups << @group # 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 # Test access to each application 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]}" # Verify headers are correct if app[:headers_config][:user].present? assert_equal app[:headers_config][:user], - response.headers.keys.find { |k| k.include?("USER") }, - "Wrong user header for #{app[:domain]}" + response.headers.keys.find { |k| k.include?("USER") }, + "Wrong user header for #{app[:domain]}" assert_equal @user.email_address, response.headers[app[:headers_config][:user]] else # Should have no auth headers @@ -300,24 +300,24 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase test "domain pattern edge cases" do # Test various domain patterns patterns = [ - { 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: "*.*.example.com", domains: ["app.dev.example.com", "api.staging.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: "*.*.example.com", domains: ["app.dev.example.com", "api.staging.example.com"]} ] patterns.each_with_index do |pattern_config, idx| - rule = Application.create!( + Application.create!( name: "Pattern Test #{idx}", slug: "pattern-test-#{idx}", app_type: "forward_auth", domain_pattern: pattern_config[:pattern], active: true ) # 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 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_equal @user.email_address, response.headers["x-remote-user"] end @@ -330,10 +330,10 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase # Performance System Tests test "system performance under load" do # Create test application - rule = Application.create!(name: "Load Test", slug: "loadtest", app_type: "forward_auth", domain_pattern: "loadtest.example.com", active: true) + Application.create!(name: "Load Test", slug: "loadtest", app_type: "forward_auth", domain_pattern: "loadtest.example.com", active: true) # Sign in - post "/signin", params: { email_address: @user.email_address, password: "password" } + post "/signin", params: {email_address: @user.email_address, password: "password"} session_cookie = cookies[:session_id] # Performance test @@ -374,7 +374,7 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase # Error Recovery System Tests test "graceful degradation with database issues" do # 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 # Simulate database connection issue by mocking @@ -387,7 +387,7 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase begin # 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 assert_response 302, "Should gracefully handle database issues" @@ -398,7 +398,7 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase end # 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 end -end \ No newline at end of file +end diff --git a/test/system/webauthn_security_test.rb b/test/system/webauthn_security_test.rb index 41257a1..9fe25b8 100644 --- a/test/system/webauthn_security_test.rb +++ b/test/system/webauthn_security_test.rb @@ -78,7 +78,7 @@ class WebauthnSecurityTest < ActionDispatch::SystemTestCase user = User.create!(email_address: "webauthn_handle_auth_test@example.com", password: "password123") user_handle = SecureRandom.uuid - credential = user.webauthn_credentials.create!( + user.webauthn_credentials.create!( external_id: Base64.urlsafe_encode64("fake_credential_id"), public_key: Base64.urlsafe_encode64("fake_public_key"), sign_count: 0, @@ -99,7 +99,7 @@ class WebauthnSecurityTest < ActionDispatch::SystemTestCase test "WebAuthn request validates origin" do user = User.create!(email_address: "webauthn_origin_test@example.com", password: "password123") - credential = user.webauthn_credentials.create!( + user.webauthn_credentials.create!( external_id: Base64.urlsafe_encode64("fake_credential_id"), public_key: Base64.urlsafe_encode64("fake_public_key"), sign_count: 0, @@ -107,14 +107,14 @@ class WebauthnSecurityTest < ActionDispatch::SystemTestCase ) # Test WebAuthn challenge from valid origin - post webauthn_challenge_path, params: { email: "webauthn_origin_test@example.com" }, - headers: { "HTTP_ORIGIN": "http://localhost:3000" } + post webauthn_challenge_path, params: {email: "webauthn_origin_test@example.com"}, + headers: {HTTP_ORIGIN: "http://localhost:3000"} # Should succeed for valid origin # Test WebAuthn challenge from invalid origin - post webauthn_challenge_path, params: { email: "webauthn_origin_test@example.com" }, - headers: { "HTTP_ORIGIN": "http://evil.com" } + post webauthn_challenge_path, params: {email: "webauthn_origin_test@example.com"}, + headers: {HTTP_ORIGIN: "http://evil.com"} # Should reject invalid origin @@ -125,7 +125,7 @@ class WebauthnSecurityTest < ActionDispatch::SystemTestCase user = User.create!(email_address: "webauthn_verify_origin_test@example.com", password: "password123") user.update!(webauthn_id: SecureRandom.uuid) - credential = user.webauthn_credentials.create!( + user.webauthn_credentials.create!( external_id: Base64.urlsafe_encode64("fake_credential_id"), public_key: Base64.urlsafe_encode64("fake_public_key"), sign_count: 0, @@ -133,10 +133,10 @@ class WebauthnSecurityTest < ActionDispatch::SystemTestCase ) # 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 - challenge = JSON.parse(@response.body)["challenge"] + JSON.parse(@response.body)["challenge"] # Simulate WebAuthn verification with wrong origin # This should fail @@ -155,7 +155,7 @@ class WebauthnSecurityTest < ActionDispatch::SystemTestCase # Standard attestation formats: none, packed, tpm, android-key, android-safetynet, fido-u2f, etc. # Test with 'none' attestation (most common for privacy) - attestation_object = { + { fmt: "none", attStmt: {}, authData: Base64.strict_encode64("fake_auth_data") @@ -170,7 +170,7 @@ class WebauthnSecurityTest < ActionDispatch::SystemTestCase user = User.create!(email_address: "webauthn_invalid_attestation_test@example.com", password: "password123") # Try to register with invalid attestation format - invalid_attestation = { + { fmt: "invalid_format", attStmt: {}, authData: Base64.strict_encode64("fake_auth_data") @@ -263,7 +263,7 @@ class WebauthnSecurityTest < ActionDispatch::SystemTestCase test "WebAuthn requires user presence for authentication" do user = User.create!(email_address: "webauthn_presence_test@example.com", password: "password123") - credential = user.webauthn_credentials.create!( + user.webauthn_credentials.create!( external_id: Base64.urlsafe_encode64("fake_credential_id"), public_key: Base64.urlsafe_encode64("fake_public_key"), sign_count: 0, @@ -291,7 +291,7 @@ class WebauthnSecurityTest < ActionDispatch::SystemTestCase nickname: "USB Key" ) - credential2 = user.webauthn_credentials.create!( + user.webauthn_credentials.create!( external_id: Base64.urlsafe_encode64("credential_2"), public_key: Base64.urlsafe_encode64("public_key_2"), sign_count: 0, @@ -317,7 +317,7 @@ class WebauthnSecurityTest < ActionDispatch::SystemTestCase user.update!(webauthn_enabled: true) # 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 # 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.update!(webauthn_enabled: true) - credential = user.webauthn_credentials.create!( + user.webauthn_credentials.create!( external_id: Base64.urlsafe_encode64("passwordless_credential"), public_key: Base64.urlsafe_encode64("public_key"), sign_count: 0,