StandardRB fixes
This commit is contained in:
@@ -32,13 +32,11 @@ module Admin
|
|||||||
client_secret = @application.generate_new_client_secret!
|
client_secret = @application.generate_new_client_secret!
|
||||||
end
|
end
|
||||||
|
|
||||||
if @application.oidc?
|
|
||||||
flash[:notice] = "Application created successfully."
|
flash[:notice] = "Application created successfully."
|
||||||
|
if @application.oidc?
|
||||||
flash[:client_id] = @application.client_id
|
flash[:client_id] = @application.client_id
|
||||||
flash[:client_secret] = client_secret if client_secret
|
flash[:client_secret] = client_secret if client_secret
|
||||||
flash[:public_client] = true if @application.public_client?
|
flash[:public_client] = true if @application.public_client?
|
||||||
else
|
|
||||||
flash[:notice] = "Application created successfully."
|
|
||||||
end
|
end
|
||||||
|
|
||||||
redirect_to admin_application_path(@application)
|
redirect_to admin_application_path(@application)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ module Api
|
|||||||
def violation_report
|
def violation_report
|
||||||
# Parse CSP violation report
|
# Parse CSP violation report
|
||||||
report_data = JSON.parse(request.body.read)
|
report_data = JSON.parse(request.body.read)
|
||||||
csp_report = report_data['csp-report']
|
csp_report = report_data["csp-report"]
|
||||||
|
|
||||||
# Validate that we have a proper CSP report
|
# Validate that we have a proper CSP report
|
||||||
unless csp_report.is_a?(Hash) && csp_report.present?
|
unless csp_report.is_a?(Hash) && csp_report.present?
|
||||||
@@ -19,28 +19,28 @@ module Api
|
|||||||
|
|
||||||
# Log the violation for security monitoring
|
# Log the violation for security monitoring
|
||||||
Rails.logger.warn "CSP Violation Report:"
|
Rails.logger.warn "CSP Violation Report:"
|
||||||
Rails.logger.warn " Blocked URI: #{csp_report['blocked-uri']}"
|
Rails.logger.warn " Blocked URI: #{csp_report["blocked-uri"]}"
|
||||||
Rails.logger.warn " Document URI: #{csp_report['document-uri']}"
|
Rails.logger.warn " Document URI: #{csp_report["document-uri"]}"
|
||||||
Rails.logger.warn " Referrer: #{csp_report['referrer']}"
|
Rails.logger.warn " Referrer: #{csp_report["referrer"]}"
|
||||||
Rails.logger.warn " Violated Directive: #{csp_report['violated-directive']}"
|
Rails.logger.warn " Violated Directive: #{csp_report["violated-directive"]}"
|
||||||
Rails.logger.warn " Original Policy: #{csp_report['original-policy']}"
|
Rails.logger.warn " Original Policy: #{csp_report["original-policy"]}"
|
||||||
Rails.logger.warn " User Agent: #{request.user_agent}"
|
Rails.logger.warn " User Agent: #{request.user_agent}"
|
||||||
Rails.logger.warn " IP Address: #{request.remote_ip}"
|
Rails.logger.warn " IP Address: #{request.remote_ip}"
|
||||||
|
|
||||||
# Emit structured event for CSP violation
|
# Emit structured event for CSP violation
|
||||||
# This allows multiple subscribers to process the event (Sentry, local logging, etc.)
|
# This allows multiple subscribers to process the event (Sentry, local logging, etc.)
|
||||||
Rails.event.notify("csp.violation", {
|
Rails.event.notify("csp.violation", {
|
||||||
blocked_uri: csp_report['blocked-uri'],
|
blocked_uri: csp_report["blocked-uri"],
|
||||||
document_uri: csp_report['document-uri'],
|
document_uri: csp_report["document-uri"],
|
||||||
referrer: csp_report['referrer'],
|
referrer: csp_report["referrer"],
|
||||||
violated_directive: csp_report['violated-directive'],
|
violated_directive: csp_report["violated-directive"],
|
||||||
original_policy: csp_report['original-policy'],
|
original_policy: csp_report["original-policy"],
|
||||||
disposition: csp_report['disposition'],
|
disposition: csp_report["disposition"],
|
||||||
effective_directive: csp_report['effective-directive'],
|
effective_directive: csp_report["effective-directive"],
|
||||||
source_file: csp_report['source-file'],
|
source_file: csp_report["source-file"],
|
||||||
line_number: csp_report['line-number'],
|
line_number: csp_report["line-number"],
|
||||||
column_number: csp_report['column-number'],
|
column_number: csp_report["column-number"],
|
||||||
status_code: csp_report['status-code'],
|
status_code: csp_report["status-code"],
|
||||||
user_agent: request.user_agent,
|
user_agent: request.user_agent,
|
||||||
ip_address: request.remote_ip,
|
ip_address: request.remote_ip,
|
||||||
current_user_id: Current.user&.id,
|
current_user_id: Current.user&.id,
|
||||||
|
|||||||
@@ -81,7 +81,10 @@ module Api
|
|||||||
|
|
||||||
# User is authenticated and authorized
|
# User is authenticated and authorized
|
||||||
# Return 200 with user information headers using app-specific configuration
|
# Return 200 with user information headers using app-specific configuration
|
||||||
headers = app ? app.headers_for_user(user) : Application::DEFAULT_HEADERS.map { |key, header_name|
|
headers = if app
|
||||||
|
app.headers_for_user(user)
|
||||||
|
else
|
||||||
|
Application::DEFAULT_HEADERS.map { |key, header_name|
|
||||||
case key
|
case key
|
||||||
when :user, :email, :name
|
when :user, :email, :name
|
||||||
[header_name, user.email_address]
|
[header_name, user.email_address]
|
||||||
@@ -91,12 +94,13 @@ module Api
|
|||||||
[header_name, user.admin? ? "true" : "false"]
|
[header_name, user.admin? ? "true" : "false"]
|
||||||
end
|
end
|
||||||
}.compact.to_h
|
}.compact.to_h
|
||||||
|
end
|
||||||
|
|
||||||
headers.each { |key, value| response.headers[key] = value }
|
headers.each { |key, value| response.headers[key] = value }
|
||||||
|
|
||||||
# Log what headers we're sending (helpful for debugging)
|
# Log what headers we're sending (helpful for debugging)
|
||||||
if headers.any?
|
if headers.any?
|
||||||
Rails.logger.debug "ForwardAuth: Headers sent: #{headers.keys.join(', ')}"
|
Rails.logger.debug "ForwardAuth: Headers sent: #{headers.keys.join(", ")}"
|
||||||
else
|
else
|
||||||
Rails.logger.debug "ForwardAuth: No headers sent (access only)"
|
Rails.logger.debug "ForwardAuth: No headers sent (access only)"
|
||||||
end
|
end
|
||||||
@@ -129,8 +133,7 @@ module Api
|
|||||||
def extract_session_id
|
def extract_session_id
|
||||||
# Extract session ID from cookie
|
# Extract session ID from cookie
|
||||||
# Rails uses signed cookies by default
|
# Rails uses signed cookies by default
|
||||||
session_id = cookies.signed[:session_id]
|
cookies.signed[:session_id]
|
||||||
session_id
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def extract_app_from_headers
|
def extract_app_from_headers
|
||||||
@@ -155,7 +158,7 @@ module Api
|
|||||||
original_uri = request.headers["X-Forwarded-Uri"] || request.headers["X-Forwarded-Path"] || "/"
|
original_uri = request.headers["X-Forwarded-Uri"] || request.headers["X-Forwarded-Path"] || "/"
|
||||||
|
|
||||||
# Debug logging to see what headers we're getting
|
# Debug logging to see what headers we're getting
|
||||||
Rails.logger.info "ForwardAuth Headers: Host=#{request.headers['Host']}, X-Forwarded-Host=#{original_host}, X-Forwarded-Uri=#{request.headers['X-Forwarded-Uri']}, X-Forwarded-Path=#{request.headers['X-Forwarded-Path']}"
|
Rails.logger.info "ForwardAuth Headers: Host=#{request.headers["Host"]}, X-Forwarded-Host=#{original_host}, X-Forwarded-Uri=#{request.headers["X-Forwarded-Uri"]}, X-Forwarded-Path=#{request.headers["X-Forwarded-Path"]}"
|
||||||
|
|
||||||
original_url = if original_host
|
original_url = if original_host
|
||||||
# Use the forwarded host and URI (original behavior)
|
# Use the forwarded host and URI (original behavior)
|
||||||
@@ -203,7 +206,7 @@ module Api
|
|||||||
return nil unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
|
return nil unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
|
||||||
|
|
||||||
# Only allow HTTPS in production
|
# Only allow HTTPS in production
|
||||||
return nil unless Rails.env.development? || uri.scheme == 'https'
|
return nil unless Rails.env.development? || uri.scheme == "https"
|
||||||
|
|
||||||
redirect_domain = uri.host.downcase
|
redirect_domain = uri.host.downcase
|
||||||
return nil unless redirect_domain.present?
|
return nil unless redirect_domain.present?
|
||||||
@@ -214,7 +217,6 @@ module Api
|
|||||||
end
|
end
|
||||||
|
|
||||||
matching_app ? url : nil
|
matching_app ? url : nil
|
||||||
|
|
||||||
rescue URI::InvalidURIError
|
rescue URI::InvalidURIError
|
||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
@@ -233,13 +235,13 @@ module Api
|
|||||||
return redirect_url if redirect_url.present?
|
return redirect_url if redirect_url.present?
|
||||||
|
|
||||||
# Try CLINCH_HOST environment variable first
|
# Try CLINCH_HOST environment variable first
|
||||||
if ENV['CLINCH_HOST'].present?
|
if ENV["CLINCH_HOST"].present?
|
||||||
host = ENV['CLINCH_HOST']
|
host = ENV["CLINCH_HOST"]
|
||||||
# Ensure URL has https:// protocol
|
# Ensure URL has https:// protocol
|
||||||
host.match?(/^https?:\/\//) ? host : "https://#{host}"
|
host.match?(/^https?:\/\//) ? host : "https://#{host}"
|
||||||
else
|
else
|
||||||
# Fallback to the request host
|
# Fallback to the request host
|
||||||
request_host = request.host || request.headers['X-Forwarded-Host']
|
request_host = request.host || request.headers["X-Forwarded-Host"]
|
||||||
if request_host.present?
|
if request_host.present?
|
||||||
Rails.logger.warn "ForwardAuth: CLINCH_HOST not set, using request host: #{request_host}"
|
Rails.logger.warn "ForwardAuth: CLINCH_HOST not set, using request host: #{request_host}"
|
||||||
"https://#{request_host}"
|
"https://#{request_host}"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
class ApplicationController < ActionController::Base
|
class ApplicationController < ActionController::Base
|
||||||
include Authentication
|
include Authentication
|
||||||
|
|
||||||
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
|
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
|
||||||
allow_browser versions: :modern
|
allow_browser versions: :modern
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
require 'uri'
|
require "uri"
|
||||||
require 'public_suffix'
|
require "public_suffix"
|
||||||
require 'ipaddr'
|
require "ipaddr"
|
||||||
|
|
||||||
module Authentication
|
module Authentication
|
||||||
extend ActiveSupport::Concern
|
extend ActiveSupport::Concern
|
||||||
@@ -17,6 +17,7 @@ module Authentication
|
|||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def authenticated?
|
def authenticated?
|
||||||
resume_session
|
resume_session
|
||||||
end
|
end
|
||||||
@@ -39,9 +40,8 @@ module Authentication
|
|||||||
end
|
end
|
||||||
|
|
||||||
def after_authentication_url
|
def after_authentication_url
|
||||||
return_url = session[:return_to_after_authenticating]
|
session[:return_to_after_authenticating]
|
||||||
final_url = session.delete(:return_to_after_authenticating) || root_url
|
session.delete(:return_to_after_authenticating) || root_url
|
||||||
final_url
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def start_new_session_for(user, acr: "1")
|
def start_new_session_for(user, acr: "1")
|
||||||
@@ -101,10 +101,14 @@ module Authentication
|
|||||||
return nil if host.blank? || host.match?(/^(localhost|127\.0\.0\.1|::1)$/)
|
return nil if host.blank? || host.match?(/^(localhost|127\.0\.0\.1|::1)$/)
|
||||||
|
|
||||||
# Strip port number for domain parsing
|
# Strip port number for domain parsing
|
||||||
host_without_port = host.split(':').first
|
host_without_port = host.split(":").first
|
||||||
|
|
||||||
# Check if it's an IP address (IPv4 or IPv6) - if so, don't set domain cookie
|
# Check if it's an IP address (IPv4 or IPv6) - if so, don't set domain cookie
|
||||||
return nil if IPAddr.new(host_without_port) rescue false
|
begin
|
||||||
|
return nil if IPAddr.new(host_without_port)
|
||||||
|
rescue
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
# Use Public Suffix List for accurate domain parsing
|
# Use Public Suffix List for accurate domain parsing
|
||||||
domain = PublicSuffix.parse(host_without_port)
|
domain = PublicSuffix.parse(host_without_port)
|
||||||
@@ -138,7 +142,7 @@ module Authentication
|
|||||||
unless uri.path&.start_with?("/oauth/")
|
unless uri.path&.start_with?("/oauth/")
|
||||||
# Add token as query parameter
|
# Add token as query parameter
|
||||||
query_params = URI.decode_www_form(uri.query || "").to_h
|
query_params = URI.decode_www_form(uri.query || "").to_h
|
||||||
query_params['fa_token'] = token
|
query_params["fa_token"] = token
|
||||||
uri.query = URI.encode_www_form(query_params)
|
uri.query = URI.encode_www_form(query_params)
|
||||||
|
|
||||||
# Update the session with the tokenized URL
|
# Update the session with the tokenized URL
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
class InvitationsController < ApplicationController
|
class InvitationsController < ApplicationController
|
||||||
include Authentication
|
include Authentication
|
||||||
|
|
||||||
allow_unauthenticated_access
|
allow_unauthenticated_access
|
||||||
before_action :set_user_by_invitation_token, only: %i[ show update ]
|
before_action :set_user_by_invitation_token, only: %i[show update]
|
||||||
|
|
||||||
def show
|
def show
|
||||||
# Show the password setup form
|
# Show the password setup form
|
||||||
@@ -35,16 +36,16 @@ class InvitationsController < ApplicationController
|
|||||||
# Check if user is still pending invitation
|
# Check if user is still pending invitation
|
||||||
if @user.nil?
|
if @user.nil?
|
||||||
redirect_to signin_path, alert: "Invitation link is invalid or has expired."
|
redirect_to signin_path, alert: "Invitation link is invalid or has expired."
|
||||||
return false
|
false
|
||||||
elsif @user.pending_invitation?
|
elsif @user.pending_invitation?
|
||||||
# User is valid and pending - proceed
|
# User is valid and pending - proceed
|
||||||
return true
|
true
|
||||||
else
|
else
|
||||||
redirect_to signin_path, alert: "This invitation has already been used or is no longer valid."
|
redirect_to signin_path, alert: "This invitation has already been used or is no longer valid."
|
||||||
return false
|
false
|
||||||
end
|
end
|
||||||
rescue ActiveSupport::MessageVerifier::InvalidSignature
|
rescue ActiveSupport::MessageVerifier::InvalidSignature
|
||||||
redirect_to signin_path, alert: "Invitation link is invalid or has expired."
|
redirect_to signin_path, alert: "Invitation link is invalid or has expired."
|
||||||
return false
|
false
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -5,7 +5,7 @@ class OidcController < ApplicationController
|
|||||||
|
|
||||||
# Rate limiting to prevent brute force and abuse
|
# Rate limiting to prevent brute force and abuse
|
||||||
rate_limit to: 60, within: 1.minute, only: [:token, :revoke], with: -> {
|
rate_limit to: 60, within: 1.minute, only: [:token, :revoke], with: -> {
|
||||||
render json: { error: "too_many_requests", error_description: "Rate limit exceeded. Try again later." }, status: :too_many_requests
|
render json: {error: "too_many_requests", error_description: "Rate limit exceeded. Try again later."}, status: :too_many_requests
|
||||||
}
|
}
|
||||||
rate_limit to: 30, within: 1.minute, only: [:authorize, :consent], with: -> {
|
rate_limit to: 30, within: 1.minute, only: [:authorize, :consent], with: -> {
|
||||||
render plain: "Too many authorization attempts. Try again later.", status: :too_many_requests
|
render plain: "Too many authorization attempts. Try again later.", status: :too_many_requests
|
||||||
@@ -63,7 +63,7 @@ class OidcController < ApplicationController
|
|||||||
error_details << "redirect_uri is required" unless redirect_uri.present?
|
error_details << "redirect_uri is required" unless redirect_uri.present?
|
||||||
error_details << "response_type must be 'code'" unless response_type == "code"
|
error_details << "response_type must be 'code'" unless response_type == "code"
|
||||||
|
|
||||||
render plain: "Invalid request: #{error_details.join(', ')}", status: :bad_request
|
render plain: "Invalid request: #{error_details.join(", ")}", status: :bad_request
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -90,7 +90,7 @@ class OidcController < ApplicationController
|
|||||||
Rails.logger.error "OAuth: Available OIDC applications: #{all_oidc_apps.pluck(:id, :client_id, :name)}"
|
Rails.logger.error "OAuth: Available OIDC applications: #{all_oidc_apps.pluck(:id, :client_id, :name)}"
|
||||||
|
|
||||||
error_msg = if Rails.env.development?
|
error_msg = if Rails.env.development?
|
||||||
"Invalid request: Application not found for client_id '#{client_id}'. Available OIDC applications: #{all_oidc_apps.pluck(:name, :client_id).map { |name, id| "#{name} (#{id})" }.join(', ')}"
|
"Invalid request: Application not found for client_id '#{client_id}'. Available OIDC applications: #{all_oidc_apps.pluck(:name, :client_id).map { |name, id| "#{name} (#{id})" }.join(", ")}"
|
||||||
else
|
else
|
||||||
"Invalid request: Application not found"
|
"Invalid request: Application not found"
|
||||||
end
|
end
|
||||||
@@ -105,7 +105,7 @@ class OidcController < ApplicationController
|
|||||||
|
|
||||||
# For development, show detailed error
|
# For development, show detailed error
|
||||||
error_msg = if Rails.env.development?
|
error_msg = if Rails.env.development?
|
||||||
"Invalid request: Redirect URI mismatch. Application is configured for: #{@application.parsed_redirect_uris.join(', ')}, but received: #{redirect_uri}"
|
"Invalid request: Redirect URI mismatch. Application is configured for: #{@application.parsed_redirect_uris.join(", ")}, but received: #{redirect_uri}"
|
||||||
else
|
else
|
||||||
"Invalid request: Redirect URI not registered for this application"
|
"Invalid request: Redirect URI not registered for this application"
|
||||||
end
|
end
|
||||||
@@ -223,22 +223,22 @@ class OidcController < ApplicationController
|
|||||||
# User denied consent
|
# User denied consent
|
||||||
if params[:deny].present?
|
if params[:deny].present?
|
||||||
session.delete(:oauth_params)
|
session.delete(:oauth_params)
|
||||||
error_uri = "#{oauth_params['redirect_uri']}?error=access_denied"
|
error_uri = "#{oauth_params["redirect_uri"]}?error=access_denied"
|
||||||
error_uri += "&state=#{CGI.escape(oauth_params['state'])}" if oauth_params['state']
|
error_uri += "&state=#{CGI.escape(oauth_params["state"])}" if oauth_params["state"]
|
||||||
redirect_to error_uri, allow_other_host: true
|
redirect_to error_uri, allow_other_host: true
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Find the application
|
# Find the application
|
||||||
client_id = oauth_params['client_id']
|
client_id = oauth_params["client_id"]
|
||||||
application = Application.find_by(client_id: client_id, app_type: "oidc")
|
application = Application.find_by(client_id: client_id, app_type: "oidc")
|
||||||
|
|
||||||
# Check if application is active (redirect with OAuth error)
|
# Check if application is active (redirect with OAuth error)
|
||||||
unless application&.active?
|
unless application&.active?
|
||||||
Rails.logger.error "OAuth: Application is not active: #{application&.name || client_id}"
|
Rails.logger.error "OAuth: Application is not active: #{application&.name || client_id}"
|
||||||
session.delete(:oauth_params)
|
session.delete(:oauth_params)
|
||||||
error_uri = "#{oauth_params['redirect_uri']}?error=unauthorized_client&error_description=Application+is+not+active"
|
error_uri = "#{oauth_params["redirect_uri"]}?error=unauthorized_client&error_description=Application+is+not+active"
|
||||||
error_uri += "&state=#{CGI.escape(oauth_params['state'])}" if oauth_params['state'].present?
|
error_uri += "&state=#{CGI.escape(oauth_params["state"])}" if oauth_params["state"].present?
|
||||||
redirect_to error_uri, allow_other_host: true
|
redirect_to error_uri, allow_other_host: true
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -246,9 +246,9 @@ class OidcController < ApplicationController
|
|||||||
user = Current.session.user
|
user = Current.session.user
|
||||||
|
|
||||||
# Record user consent
|
# Record user consent
|
||||||
requested_scopes = oauth_params['scope'].split(' ')
|
requested_scopes = oauth_params["scope"].split(" ")
|
||||||
consent = OidcUserConsent.find_or_initialize_by(user: user, application: application)
|
consent = OidcUserConsent.find_or_initialize_by(user: user, application: application)
|
||||||
consent.scopes_granted = requested_scopes.join(' ')
|
consent.scopes_granted = requested_scopes.join(" ")
|
||||||
consent.granted_at = Time.current
|
consent.granted_at = Time.current
|
||||||
consent.save!
|
consent.save!
|
||||||
|
|
||||||
@@ -256,11 +256,11 @@ class OidcController < ApplicationController
|
|||||||
auth_code = OidcAuthorizationCode.create!(
|
auth_code = OidcAuthorizationCode.create!(
|
||||||
application: application,
|
application: application,
|
||||||
user: user,
|
user: user,
|
||||||
redirect_uri: oauth_params['redirect_uri'],
|
redirect_uri: oauth_params["redirect_uri"],
|
||||||
scope: oauth_params['scope'],
|
scope: oauth_params["scope"],
|
||||||
nonce: oauth_params['nonce'],
|
nonce: oauth_params["nonce"],
|
||||||
code_challenge: oauth_params['code_challenge'],
|
code_challenge: oauth_params["code_challenge"],
|
||||||
code_challenge_method: oauth_params['code_challenge_method'],
|
code_challenge_method: oauth_params["code_challenge_method"],
|
||||||
auth_time: Current.session.created_at.to_i,
|
auth_time: Current.session.created_at.to_i,
|
||||||
acr: Current.session.acr,
|
acr: Current.session.acr,
|
||||||
expires_at: 10.minutes.from_now
|
expires_at: 10.minutes.from_now
|
||||||
@@ -270,8 +270,8 @@ class OidcController < ApplicationController
|
|||||||
session.delete(:oauth_params)
|
session.delete(:oauth_params)
|
||||||
|
|
||||||
# Redirect back to client with authorization code (plaintext)
|
# Redirect back to client with authorization code (plaintext)
|
||||||
redirect_uri = "#{oauth_params['redirect_uri']}?code=#{auth_code.plaintext_code}"
|
redirect_uri = "#{oauth_params["redirect_uri"]}?code=#{auth_code.plaintext_code}"
|
||||||
redirect_uri += "&state=#{CGI.escape(oauth_params['state'])}" if oauth_params['state']
|
redirect_uri += "&state=#{CGI.escape(oauth_params["state"])}" if oauth_params["state"]
|
||||||
|
|
||||||
redirect_to redirect_uri, allow_other_host: true
|
redirect_to redirect_uri, allow_other_host: true
|
||||||
end
|
end
|
||||||
@@ -286,7 +286,7 @@ class OidcController < ApplicationController
|
|||||||
when "refresh_token"
|
when "refresh_token"
|
||||||
handle_refresh_token_grant
|
handle_refresh_token_grant
|
||||||
else
|
else
|
||||||
render json: { error: "unsupported_grant_type" }, status: :bad_request
|
render json: {error: "unsupported_grant_type"}, status: :bad_request
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -295,14 +295,14 @@ class OidcController < ApplicationController
|
|||||||
client_id, client_secret = extract_client_credentials
|
client_id, client_secret = extract_client_credentials
|
||||||
|
|
||||||
unless client_id
|
unless client_id
|
||||||
render json: { error: "invalid_client", error_description: "client_id is required" }, status: :unauthorized
|
render json: {error: "invalid_client", error_description: "client_id is required"}, status: :unauthorized
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Find the application
|
# Find the application
|
||||||
application = Application.find_by(client_id: client_id)
|
application = Application.find_by(client_id: client_id)
|
||||||
unless application
|
unless application
|
||||||
render json: { error: "invalid_client", error_description: "Unknown client" }, status: :unauthorized
|
render json: {error: "invalid_client", error_description: "Unknown client"}, status: :unauthorized
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -313,7 +313,7 @@ class OidcController < ApplicationController
|
|||||||
else
|
else
|
||||||
# Confidential clients MUST provide valid client_secret
|
# Confidential clients MUST provide valid client_secret
|
||||||
unless client_secret.present? && application.authenticate_client_secret(client_secret)
|
unless client_secret.present? && application.authenticate_client_secret(client_secret)
|
||||||
render json: { error: "invalid_client", error_description: "Invalid client credentials" }, status: :unauthorized
|
render json: {error: "invalid_client", error_description: "Invalid client credentials"}, status: :unauthorized
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -321,7 +321,7 @@ class OidcController < ApplicationController
|
|||||||
# Check if application is active
|
# Check if application is active
|
||||||
unless application.active?
|
unless application.active?
|
||||||
Rails.logger.error "OAuth: Token request for inactive application: #{application.name}"
|
Rails.logger.error "OAuth: Token request for inactive application: #{application.name}"
|
||||||
render json: { error: "invalid_client", error_description: "Application is not active" }, status: :forbidden
|
render json: {error: "invalid_client", error_description: "Application is not active"}, status: :forbidden
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -334,7 +334,7 @@ class OidcController < ApplicationController
|
|||||||
auth_code = OidcAuthorizationCode.find_by_plaintext(code)
|
auth_code = OidcAuthorizationCode.find_by_plaintext(code)
|
||||||
|
|
||||||
unless auth_code && auth_code.application == application
|
unless auth_code && auth_code.application == application
|
||||||
render json: { error: "invalid_grant" }, status: :bad_request
|
render json: {error: "invalid_grant"}, status: :bad_request
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -365,13 +365,13 @@ class OidcController < ApplicationController
|
|||||||
|
|
||||||
# Check if code is expired
|
# Check if code is expired
|
||||||
if auth_code.expires_at < Time.current
|
if auth_code.expires_at < Time.current
|
||||||
render json: { error: "invalid_grant", error_description: "Authorization code expired" }, status: :bad_request
|
render json: {error: "invalid_grant", error_description: "Authorization code expired"}, status: :bad_request
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Validate redirect URI matches
|
# Validate redirect URI matches
|
||||||
unless auth_code.redirect_uri == redirect_uri
|
unless auth_code.redirect_uri == redirect_uri
|
||||||
render json: { error: "invalid_grant", error_description: "Redirect URI mismatch" }, status: :bad_request
|
render json: {error: "invalid_grant", error_description: "Redirect URI mismatch"}, status: :bad_request
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -413,7 +413,7 @@ class OidcController < ApplicationController
|
|||||||
|
|
||||||
unless consent
|
unless consent
|
||||||
Rails.logger.error "OIDC Security: Token requested without consent record (user: #{user.id}, app: #{application.id})"
|
Rails.logger.error "OIDC Security: Token requested without consent record (user: #{user.id}, app: #{application.id})"
|
||||||
render json: { error: "invalid_grant", error_description: "Authorization consent not found" }, status: :bad_request
|
render json: {error: "invalid_grant", error_description: "Authorization consent not found"}, status: :bad_request
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -440,7 +440,7 @@ class OidcController < ApplicationController
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
rescue ActiveRecord::RecordNotFound
|
rescue ActiveRecord::RecordNotFound
|
||||||
render json: { error: "invalid_grant" }, status: :bad_request
|
render json: {error: "invalid_grant"}, status: :bad_request
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -449,14 +449,14 @@ class OidcController < ApplicationController
|
|||||||
client_id, client_secret = extract_client_credentials
|
client_id, client_secret = extract_client_credentials
|
||||||
|
|
||||||
unless client_id
|
unless client_id
|
||||||
render json: { error: "invalid_client", error_description: "client_id is required" }, status: :unauthorized
|
render json: {error: "invalid_client", error_description: "client_id is required"}, status: :unauthorized
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Find the application
|
# Find the application
|
||||||
application = Application.find_by(client_id: client_id)
|
application = Application.find_by(client_id: client_id)
|
||||||
unless application
|
unless application
|
||||||
render json: { error: "invalid_client", error_description: "Unknown client" }, status: :unauthorized
|
render json: {error: "invalid_client", error_description: "Unknown client"}, status: :unauthorized
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -467,7 +467,7 @@ class OidcController < ApplicationController
|
|||||||
else
|
else
|
||||||
# Confidential clients MUST provide valid client_secret
|
# Confidential clients MUST provide valid client_secret
|
||||||
unless client_secret.present? && application.authenticate_client_secret(client_secret)
|
unless client_secret.present? && application.authenticate_client_secret(client_secret)
|
||||||
render json: { error: "invalid_client", error_description: "Invalid client credentials" }, status: :unauthorized
|
render json: {error: "invalid_client", error_description: "Invalid client credentials"}, status: :unauthorized
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -475,14 +475,14 @@ class OidcController < ApplicationController
|
|||||||
# Check if application is active
|
# Check if application is active
|
||||||
unless application.active?
|
unless application.active?
|
||||||
Rails.logger.error "OAuth: Refresh token request for inactive application: #{application.name}"
|
Rails.logger.error "OAuth: Refresh token request for inactive application: #{application.name}"
|
||||||
render json: { error: "invalid_client", error_description: "Application is not active" }, status: :forbidden
|
render json: {error: "invalid_client", error_description: "Application is not active"}, status: :forbidden
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Get the refresh token
|
# Get the refresh token
|
||||||
refresh_token = params[:refresh_token]
|
refresh_token = params[:refresh_token]
|
||||||
unless refresh_token.present?
|
unless refresh_token.present?
|
||||||
render json: { error: "invalid_request", error_description: "refresh_token is required" }, status: :bad_request
|
render json: {error: "invalid_request", error_description: "refresh_token is required"}, status: :bad_request
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -491,13 +491,13 @@ class OidcController < ApplicationController
|
|||||||
|
|
||||||
# Verify the token belongs to the correct application
|
# Verify the token belongs to the correct application
|
||||||
unless refresh_token_record && refresh_token_record.application == application
|
unless refresh_token_record && refresh_token_record.application == application
|
||||||
render json: { error: "invalid_grant", error_description: "Invalid refresh token" }, status: :bad_request
|
render json: {error: "invalid_grant", error_description: "Invalid refresh token"}, status: :bad_request
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Check if refresh token is expired
|
# Check if refresh token is expired
|
||||||
if refresh_token_record.expired?
|
if refresh_token_record.expired?
|
||||||
render json: { error: "invalid_grant", error_description: "Refresh token expired" }, status: :bad_request
|
render json: {error: "invalid_grant", error_description: "Refresh token expired"}, status: :bad_request
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -508,7 +508,7 @@ class OidcController < ApplicationController
|
|||||||
Rails.logger.warn "OAuth Security: Revoked refresh token reuse detected for token family #{refresh_token_record.token_family_id}"
|
Rails.logger.warn "OAuth Security: Revoked refresh token reuse detected for token family #{refresh_token_record.token_family_id}"
|
||||||
refresh_token_record.revoke_family!
|
refresh_token_record.revoke_family!
|
||||||
|
|
||||||
render json: { error: "invalid_grant", error_description: "Refresh token has been revoked" }, status: :bad_request
|
render json: {error: "invalid_grant", error_description: "Refresh token has been revoked"}, status: :bad_request
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -541,7 +541,7 @@ class OidcController < ApplicationController
|
|||||||
|
|
||||||
unless consent
|
unless consent
|
||||||
Rails.logger.error "OIDC Security: Refresh token used without consent record (user: #{user.id}, app: #{application.id})"
|
Rails.logger.error "OIDC Security: Refresh token used without consent record (user: #{user.id}, app: #{application.id})"
|
||||||
render json: { error: "invalid_grant", error_description: "Authorization consent not found" }, status: :bad_request
|
render json: {error: "invalid_grant", error_description: "Authorization consent not found"}, status: :bad_request
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -566,7 +566,7 @@ class OidcController < ApplicationController
|
|||||||
scope: refresh_token_record.scope
|
scope: refresh_token_record.scope
|
||||||
}
|
}
|
||||||
rescue ActiveRecord::RecordNotFound
|
rescue ActiveRecord::RecordNotFound
|
||||||
render json: { error: "invalid_grant" }, status: :bad_request
|
render json: {error: "invalid_grant"}, status: :bad_request
|
||||||
end
|
end
|
||||||
|
|
||||||
# GET /oauth/userinfo
|
# GET /oauth/userinfo
|
||||||
@@ -650,7 +650,7 @@ class OidcController < ApplicationController
|
|||||||
|
|
||||||
# Find and validate the application
|
# Find and validate the application
|
||||||
application = Application.find_by(client_id: client_id)
|
application = Application.find_by(client_id: client_id)
|
||||||
unless application && application.authenticate_client_secret(client_secret)
|
unless application&.authenticate_client_secret(client_secret)
|
||||||
Rails.logger.warn "OAuth: Token revocation attempted for invalid application: #{client_id}"
|
Rails.logger.warn "OAuth: Token revocation attempted for invalid application: #{client_id}"
|
||||||
head :ok
|
head :ok
|
||||||
return
|
return
|
||||||
@@ -669,7 +669,7 @@ class OidcController < ApplicationController
|
|||||||
|
|
||||||
unless token.present?
|
unless token.present?
|
||||||
# RFC 7009: Missing token parameter is an error
|
# RFC 7009: Missing token parameter is an error
|
||||||
render json: { error: "invalid_request", error_description: "token parameter is required" }, status: :bad_request
|
render json: {error: "invalid_request", error_description: "token parameter is required"}, status: :bad_request
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -695,7 +695,7 @@ class OidcController < ApplicationController
|
|||||||
if access_token_record
|
if access_token_record
|
||||||
access_token_record.revoke!
|
access_token_record.revoke!
|
||||||
Rails.logger.info "OAuth: Access token revoked for application #{application.name}"
|
Rails.logger.info "OAuth: Access token revoked for application #{application.name}"
|
||||||
revoked = true
|
true
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -709,7 +709,7 @@ class OidcController < ApplicationController
|
|||||||
# OpenID Connect RP-Initiated Logout
|
# OpenID Connect RP-Initiated Logout
|
||||||
# Handle id_token_hint and post_logout_redirect_uri parameters
|
# Handle id_token_hint and post_logout_redirect_uri parameters
|
||||||
|
|
||||||
id_token_hint = params[:id_token_hint]
|
params[:id_token_hint]
|
||||||
post_logout_redirect_uri = params[:post_logout_redirect_uri]
|
post_logout_redirect_uri = params[:post_logout_redirect_uri]
|
||||||
state = params[:state]
|
state = params[:state]
|
||||||
|
|
||||||
@@ -763,7 +763,7 @@ class OidcController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
# Skip validation if no code challenge was stored (legacy clients without PKCE requirement)
|
# Skip validation if no code challenge was stored (legacy clients without PKCE requirement)
|
||||||
return { valid: true } unless pkce_provided
|
return {valid: true} unless pkce_provided
|
||||||
|
|
||||||
# PKCE was provided during authorization but no verifier sent with token request
|
# PKCE was provided during authorization but no verifier sent with token request
|
||||||
unless code_verifier.present?
|
unless code_verifier.present?
|
||||||
@@ -810,7 +810,7 @@ class OidcController < ApplicationController
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
{ valid: true }
|
{valid: true}
|
||||||
end
|
end
|
||||||
|
|
||||||
def extract_client_credentials
|
def extract_client_credentials
|
||||||
@@ -835,7 +835,7 @@ class OidcController < ApplicationController
|
|||||||
return nil unless parsed_uri.is_a?(URI::HTTP) || parsed_uri.is_a?(URI::HTTPS)
|
return nil unless parsed_uri.is_a?(URI::HTTP) || parsed_uri.is_a?(URI::HTTPS)
|
||||||
|
|
||||||
# Only allow HTTPS in production
|
# Only allow HTTPS in production
|
||||||
return nil if Rails.env.production? && parsed_uri.scheme != 'https'
|
return nil if Rails.env.production? && parsed_uri.scheme != "https"
|
||||||
|
|
||||||
# Check if URI matches any registered OIDC application's redirect URIs
|
# Check if URI matches any registered OIDC application's redirect URIs
|
||||||
# According to OIDC spec, post_logout_redirect_uri should be pre-registered
|
# According to OIDC spec, post_logout_redirect_uri should be pre-registered
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
class SessionsController < ApplicationController
|
class SessionsController < ApplicationController
|
||||||
allow_unauthenticated_access only: %i[ new create verify_totp webauthn_challenge webauthn_verify ]
|
allow_unauthenticated_access only: %i[new create verify_totp webauthn_challenge webauthn_verify]
|
||||||
rate_limit to: 20, within: 3.minutes, only: :create, with: -> { redirect_to signin_path, alert: "Too many attempts. Try again later." }
|
rate_limit to: 20, within: 3.minutes, only: :create, with: -> { redirect_to signin_path, alert: "Too many attempts. Try again later." }
|
||||||
rate_limit to: 10, within: 3.minutes, only: :verify_totp, with: -> { redirect_to totp_verification_path, alert: "Too many attempts. Try again later." }
|
rate_limit to: 10, within: 3.minutes, only: :verify_totp, with: -> { redirect_to totp_verification_path, alert: "Too many attempts. Try again later." }
|
||||||
rate_limit to: 10, within: 3.minutes, only: [:webauthn_challenge, :webauthn_verify], with: -> { render json: { error: "Too many attempts. Try again later." }, status: :too_many_requests }
|
rate_limit to: 10, within: 3.minutes, only: [:webauthn_challenge, :webauthn_verify], with: -> { render json: {error: "Too many attempts. Try again later."}, status: :too_many_requests }
|
||||||
|
|
||||||
def new
|
def new
|
||||||
# Redirect to signup if this is first run
|
# Redirect to signup if this is first run
|
||||||
if User.count.zero?
|
if User.count.zero?
|
||||||
respond_to do |format|
|
respond_to do |format|
|
||||||
format.html { redirect_to signup_path }
|
format.html { redirect_to signup_path }
|
||||||
format.json { render json: { error: "No users exist. Please complete initial setup." }, status: :service_unavailable }
|
format.json { render json: {error: "No users exist. Please complete initial setup."}, status: :service_unavailable }
|
||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
respond_to do |format|
|
respond_to do |format|
|
||||||
format.html # render HTML login page
|
format.html # render HTML login page
|
||||||
format.json { render json: { error: "Authentication required" }, status: :unauthorized }
|
format.json { render json: {error: "Authentication required"}, status: :unauthorized }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -127,7 +127,7 @@ class SessionsController < ApplicationController
|
|||||||
|
|
||||||
# Invalid code
|
# Invalid code
|
||||||
redirect_to totp_verification_path, alert: "Invalid verification code. Please try again."
|
redirect_to totp_verification_path, alert: "Invalid verification code. Please try again."
|
||||||
return
|
nil
|
||||||
end
|
end
|
||||||
|
|
||||||
# Just render the form
|
# Just render the form
|
||||||
@@ -155,14 +155,14 @@ class SessionsController < ApplicationController
|
|||||||
email = params[:email]&.strip&.downcase
|
email = params[:email]&.strip&.downcase
|
||||||
|
|
||||||
if email.blank?
|
if email.blank?
|
||||||
render json: { error: "Email is required" }, status: :unprocessable_entity
|
render json: {error: "Email is required"}, status: :unprocessable_entity
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
user = User.find_by(email_address: email)
|
user = User.find_by(email_address: email)
|
||||||
|
|
||||||
if user.nil? || !user.can_authenticate_with_webauthn?
|
if user.nil? || !user.can_authenticate_with_webauthn?
|
||||||
render json: { error: "User not found or WebAuthn not available" }, status: :unprocessable_entity
|
render json: {error: "User not found or WebAuthn not available"}, status: :unprocessable_entity
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -191,10 +191,9 @@ class SessionsController < ApplicationController
|
|||||||
session[:webauthn_challenge] = options.challenge
|
session[:webauthn_challenge] = options.challenge
|
||||||
|
|
||||||
render json: options
|
render json: options
|
||||||
|
|
||||||
rescue => e
|
rescue => e
|
||||||
Rails.logger.error "WebAuthn challenge generation error: #{e.message}"
|
Rails.logger.error "WebAuthn challenge generation error: #{e.message}"
|
||||||
render json: { error: "Failed to generate WebAuthn challenge" }, status: :internal_server_error
|
render json: {error: "Failed to generate WebAuthn challenge"}, status: :internal_server_error
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -202,21 +201,21 @@ class SessionsController < ApplicationController
|
|||||||
# Get pending user from session
|
# Get pending user from session
|
||||||
user_id = session[:pending_webauthn_user_id]
|
user_id = session[:pending_webauthn_user_id]
|
||||||
unless user_id
|
unless user_id
|
||||||
render json: { error: "Session expired. Please try again." }, status: :unprocessable_entity
|
render json: {error: "Session expired. Please try again."}, status: :unprocessable_entity
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
user = User.find_by(id: user_id)
|
user = User.find_by(id: user_id)
|
||||||
unless user
|
unless user
|
||||||
session.delete(:pending_webauthn_user_id)
|
session.delete(:pending_webauthn_user_id)
|
||||||
render json: { error: "Session expired. Please try again." }, status: :unprocessable_entity
|
render json: {error: "Session expired. Please try again."}, status: :unprocessable_entity
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Get the credential and assertion from params
|
# Get the credential and assertion from params
|
||||||
credential_data = params[:credential]
|
credential_data = params[:credential]
|
||||||
if credential_data.blank?
|
if credential_data.blank?
|
||||||
render json: { error: "Credential data is required" }, status: :unprocessable_entity
|
render json: {error: "Credential data is required"}, status: :unprocessable_entity
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -224,7 +223,7 @@ class SessionsController < ApplicationController
|
|||||||
challenge = session.delete(:webauthn_challenge)
|
challenge = session.delete(:webauthn_challenge)
|
||||||
|
|
||||||
if challenge.blank?
|
if challenge.blank?
|
||||||
render json: { error: "Invalid or expired session" }, status: :unprocessable_entity
|
render json: {error: "Invalid or expired session"}, status: :unprocessable_entity
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -237,7 +236,7 @@ class SessionsController < ApplicationController
|
|||||||
stored_credential = user.webauthn_credential_for(external_id)
|
stored_credential = user.webauthn_credential_for(external_id)
|
||||||
|
|
||||||
if stored_credential.nil?
|
if stored_credential.nil?
|
||||||
render json: { error: "Credential not found" }, status: :unprocessable_entity
|
render json: {error: "Credential not found"}, status: :unprocessable_entity
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -276,16 +275,15 @@ class SessionsController < ApplicationController
|
|||||||
redirect_to: after_authentication_url,
|
redirect_to: after_authentication_url,
|
||||||
message: "Signed in successfully with passkey"
|
message: "Signed in successfully with passkey"
|
||||||
}
|
}
|
||||||
|
|
||||||
rescue WebAuthn::Error => e
|
rescue WebAuthn::Error => e
|
||||||
Rails.logger.error "WebAuthn verification error: #{e.message}"
|
Rails.logger.error "WebAuthn verification error: #{e.message}"
|
||||||
render json: { error: "Authentication failed: #{e.message}" }, status: :unprocessable_entity
|
render json: {error: "Authentication failed: #{e.message}"}, status: :unprocessable_entity
|
||||||
rescue JSON::ParserError => e
|
rescue JSON::ParserError => e
|
||||||
Rails.logger.error "WebAuthn JSON parsing error: #{e.message}"
|
Rails.logger.error "WebAuthn JSON parsing error: #{e.message}"
|
||||||
render json: { error: "Invalid credential format" }, status: :unprocessable_entity
|
render json: {error: "Invalid credential format"}, status: :unprocessable_entity
|
||||||
rescue => e
|
rescue => e
|
||||||
Rails.logger.error "Unexpected WebAuthn verification error: #{e.class} - #{e.message}"
|
Rails.logger.error "Unexpected WebAuthn verification error: #{e.class} - #{e.message}"
|
||||||
render json: { error: "An unexpected error occurred" }, status: :internal_server_error
|
render json: {error: "An unexpected error occurred"}, status: :internal_server_error
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -301,7 +299,7 @@ class SessionsController < ApplicationController
|
|||||||
return nil unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
|
return nil unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
|
||||||
|
|
||||||
# Only allow HTTPS in production
|
# Only allow HTTPS in production
|
||||||
return nil unless Rails.env.development? || uri.scheme == 'https'
|
return nil unless Rails.env.development? || uri.scheme == "https"
|
||||||
|
|
||||||
redirect_domain = uri.host.downcase
|
redirect_domain = uri.host.downcase
|
||||||
return nil unless redirect_domain.present?
|
return nil unless redirect_domain.present?
|
||||||
@@ -312,7 +310,6 @@ class SessionsController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
matching_app ? url : nil
|
matching_app ? url : nil
|
||||||
|
|
||||||
rescue URI::InvalidURIError
|
rescue URI::InvalidURIError
|
||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
class UsersController < ApplicationController
|
class UsersController < ApplicationController
|
||||||
allow_unauthenticated_access only: %i[ new create ]
|
allow_unauthenticated_access only: %i[new create]
|
||||||
before_action :ensure_first_run, only: %i[ new create ]
|
before_action :ensure_first_run, only: %i[new create]
|
||||||
|
|
||||||
def new
|
def new
|
||||||
@user = User.new
|
@user = User.new
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ class WebauthnController < ApplicationController
|
|||||||
|
|
||||||
# Rate limit check endpoint to prevent enumeration attacks
|
# Rate limit check endpoint to prevent enumeration attacks
|
||||||
rate_limit to: 10, within: 1.minute, only: [:check], with: -> {
|
rate_limit to: 10, within: 1.minute, only: [:check], with: -> {
|
||||||
render json: { error: "Too many requests. Try again later." }, status: :too_many_requests
|
render json: {error: "Too many requests. Try again later."}, status: :too_many_requests
|
||||||
}
|
}
|
||||||
|
|
||||||
# GET /webauthn/new
|
# GET /webauthn/new
|
||||||
@@ -16,7 +16,7 @@ class WebauthnController < ApplicationController
|
|||||||
# Generate registration challenge for creating a new passkey
|
# Generate registration challenge for creating a new passkey
|
||||||
def challenge
|
def challenge
|
||||||
user = Current.session&.user
|
user = Current.session&.user
|
||||||
return render json: { error: "Not authenticated" }, status: :unauthorized unless user
|
return render json: {error: "Not authenticated"}, status: :unauthorized unless user
|
||||||
|
|
||||||
registration_options = WebAuthn::Credential.options_for_create(
|
registration_options = WebAuthn::Credential.options_for_create(
|
||||||
user: {
|
user: {
|
||||||
@@ -44,7 +44,7 @@ class WebauthnController < ApplicationController
|
|||||||
credential_data, nickname = extract_credential_params
|
credential_data, nickname = extract_credential_params
|
||||||
|
|
||||||
if credential_data.blank? || nickname.blank?
|
if credential_data.blank? || nickname.blank?
|
||||||
render json: { error: "Credential and nickname are required" }, status: :unprocessable_entity
|
render json: {error: "Credential and nickname are required"}, status: :unprocessable_entity
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ class WebauthnController < ApplicationController
|
|||||||
challenge = session.delete(:webauthn_challenge)
|
challenge = session.delete(:webauthn_challenge)
|
||||||
|
|
||||||
if challenge.blank?
|
if challenge.blank?
|
||||||
render json: { error: "Invalid or expired session" }, status: :unprocessable_entity
|
render json: {error: "Invalid or expired session"}, status: :unprocessable_entity
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@ class WebauthnController < ApplicationController
|
|||||||
|
|
||||||
# Store the credential
|
# Store the credential
|
||||||
user = Current.session&.user
|
user = Current.session&.user
|
||||||
return render json: { error: "Not authenticated" }, status: :unauthorized unless user
|
return render json: {error: "Not authenticated"}, status: :unauthorized unless user
|
||||||
|
|
||||||
@webauthn_credential = user.webauthn_credentials.create!(
|
@webauthn_credential = user.webauthn_credentials.create!(
|
||||||
external_id: Base64.urlsafe_encode64(webauthn_credential.id),
|
external_id: Base64.urlsafe_encode64(webauthn_credential.id),
|
||||||
@@ -96,13 +96,12 @@ class WebauthnController < ApplicationController
|
|||||||
message: "Passkey '#{nickname}' registered successfully",
|
message: "Passkey '#{nickname}' registered successfully",
|
||||||
credential_id: @webauthn_credential.id
|
credential_id: @webauthn_credential.id
|
||||||
}
|
}
|
||||||
|
|
||||||
rescue WebAuthn::Error => e
|
rescue WebAuthn::Error => e
|
||||||
Rails.logger.error "WebAuthn registration error: #{e.message}"
|
Rails.logger.error "WebAuthn registration error: #{e.message}"
|
||||||
render json: { error: "Failed to register passkey: #{e.message}" }, status: :unprocessable_entity
|
render json: {error: "Failed to register passkey: #{e.message}"}, status: :unprocessable_entity
|
||||||
rescue => e
|
rescue => e
|
||||||
Rails.logger.error "Unexpected WebAuthn registration error: #{e.class} - #{e.message}"
|
Rails.logger.error "Unexpected WebAuthn registration error: #{e.class} - #{e.message}"
|
||||||
render json: { error: "An unexpected error occurred" }, status: :internal_server_error
|
render json: {error: "An unexpected error occurred"}, status: :internal_server_error
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -133,7 +132,7 @@ class WebauthnController < ApplicationController
|
|||||||
email = params[:email]&.strip&.downcase
|
email = params[:email]&.strip&.downcase
|
||||||
|
|
||||||
if email.blank?
|
if email.blank?
|
||||||
render json: { has_webauthn: false, requires_webauthn: false }
|
render json: {has_webauthn: false, requires_webauthn: false}
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -142,7 +141,7 @@ class WebauthnController < ApplicationController
|
|||||||
# Security: Return identical response for non-existent users
|
# Security: Return identical response for non-existent users
|
||||||
# Combined with rate limiting (10/min), this prevents account enumeration
|
# Combined with rate limiting (10/min), this prevents account enumeration
|
||||||
if user.nil?
|
if user.nil?
|
||||||
render json: { has_webauthn: false, requires_webauthn: false }
|
render json: {has_webauthn: false, requires_webauthn: false}
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -158,7 +157,7 @@ class WebauthnController < ApplicationController
|
|||||||
def extract_credential_params
|
def extract_credential_params
|
||||||
# Use require.permit which is working and reliable
|
# Use require.permit which is working and reliable
|
||||||
# The JavaScript sends params both directly and wrapped in webauthn key
|
# The JavaScript sends params both directly and wrapped in webauthn key
|
||||||
begin
|
|
||||||
# Try direct parameters first
|
# Try direct parameters first
|
||||||
credential_params = params.require(:credential).permit(:id, :rawId, :type, response: {}, clientExtensionResults: {})
|
credential_params = params.require(:credential).permit(:id, :rawId, :type, response: {}, clientExtensionResults: {})
|
||||||
nickname = params.require(:nickname)
|
nickname = params.require(:nickname)
|
||||||
@@ -169,26 +168,25 @@ class WebauthnController < ApplicationController
|
|||||||
webauthn_params = params.require(:webauthn).permit(:nickname, credential: [:id, :rawId, :type, response: {}, clientExtensionResults: {}])
|
webauthn_params = params.require(:webauthn).permit(:nickname, credential: [:id, :rawId, :type, response: {}, clientExtensionResults: {}])
|
||||||
[webauthn_params[:credential], webauthn_params[:nickname]]
|
[webauthn_params[:credential], webauthn_params[:nickname]]
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|
||||||
def set_webauthn_credential
|
def set_webauthn_credential
|
||||||
user = Current.session&.user
|
user = Current.session&.user
|
||||||
return render json: { error: "Not authenticated" }, status: :unauthorized unless user
|
return render json: {error: "Not authenticated"}, status: :unauthorized unless user
|
||||||
@webauthn_credential = user.webauthn_credentials.find(params[:id])
|
@webauthn_credential = user.webauthn_credentials.find(params[:id])
|
||||||
rescue ActiveRecord::RecordNotFound
|
rescue ActiveRecord::RecordNotFound
|
||||||
respond_to do |format|
|
respond_to do |format|
|
||||||
format.html { redirect_to profile_path, alert: "Passkey not found" }
|
format.html { redirect_to profile_path, alert: "Passkey not found" }
|
||||||
format.json { render json: { error: "Passkey not found" }, status: :not_found }
|
format.json { render json: {error: "Passkey not found"}, status: :not_found }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Helper method to convert Base64 to Base64URL if needed
|
# Helper method to convert Base64 to Base64URL if needed
|
||||||
def base64_to_base64url(str)
|
def base64_to_base64url(str)
|
||||||
str.gsub('+', '-').gsub('/', '_').gsub(/=+$/, '')
|
str.tr("+", "-").tr("/", "_").gsub(/=+$/, "")
|
||||||
end
|
end
|
||||||
|
|
||||||
# Helper method to convert Base64URL to Base64 if needed
|
# Helper method to convert Base64URL to Base64 if needed
|
||||||
def base64url_to_base64(str)
|
def base64url_to_base64(str)
|
||||||
str.gsub('-', '+').gsub('_', '/') + '=' * (4 - str.length % 4) % 4
|
str.tr("-", "+").tr("_", "/") + "=" * (4 - str.length % 4) % 4
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -22,11 +22,11 @@ module ApplicationHelper
|
|||||||
|
|
||||||
def border_class_for(type)
|
def border_class_for(type)
|
||||||
case type.to_s
|
case type.to_s
|
||||||
when 'notice' then 'border-green-200'
|
when "notice" then "border-green-200"
|
||||||
when 'alert', 'error' then 'border-red-200'
|
when "alert", "error" then "border-red-200"
|
||||||
when 'warning' then 'border-yellow-200'
|
when "warning" then "border-yellow-200"
|
||||||
when 'info' then 'border-blue-200'
|
when "info" then "border-blue-200"
|
||||||
else 'border-gray-200'
|
else "border-gray-200"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -25,9 +25,7 @@ module ClaimsHelper
|
|||||||
claims = deep_merge_claims(claims, user.parsed_custom_claims)
|
claims = deep_merge_claims(claims, user.parsed_custom_claims)
|
||||||
|
|
||||||
# Merge app-specific claims (arrays are combined)
|
# Merge app-specific claims (arrays are combined)
|
||||||
claims = deep_merge_claims(claims, application.custom_claims_for_user(user))
|
deep_merge_claims(claims, application.custom_claims_for_user(user))
|
||||||
|
|
||||||
claims
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Get claim sources breakdown for display
|
# Get claim sources breakdown for display
|
||||||
|
|||||||
@@ -29,10 +29,10 @@ class BackchannelLogoutJob < ApplicationJob
|
|||||||
uri = URI.parse(application.backchannel_logout_uri)
|
uri = URI.parse(application.backchannel_logout_uri)
|
||||||
|
|
||||||
begin
|
begin
|
||||||
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https', open_timeout: 5, read_timeout: 5) do |http|
|
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 5, read_timeout: 5) do |http|
|
||||||
request = Net::HTTP::Post.new(uri.path.presence || '/')
|
request = Net::HTTP::Post.new(uri.path.presence || "/")
|
||||||
request['Content-Type'] = 'application/x-www-form-urlencoded'
|
request["Content-Type"] = "application/x-www-form-urlencoded"
|
||||||
request.set_form_data({ logout_token: logout_token })
|
request.set_form_data({logout_token: logout_token})
|
||||||
http.request(request)
|
http.request(request)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ class BackchannelLogoutJob < ApplicationJob
|
|||||||
rescue Net::OpenTimeout, Net::ReadTimeout => e
|
rescue Net::OpenTimeout, Net::ReadTimeout => e
|
||||||
Rails.logger.warn "BackchannelLogout: Timeout sending logout to #{application.name} (#{application.backchannel_logout_uri}): #{e.message}"
|
Rails.logger.warn "BackchannelLogout: Timeout sending logout to #{application.name} (#{application.backchannel_logout_uri}): #{e.message}"
|
||||||
raise # Retry on timeout
|
raise # Retry on timeout
|
||||||
rescue StandardError => e
|
rescue => e
|
||||||
Rails.logger.error "BackchannelLogout: Failed to send logout to #{application.name} (#{application.backchannel_logout_uri}): #{e.class} - #{e.message}"
|
Rails.logger.error "BackchannelLogout: Failed to send logout to #{application.name} (#{application.backchannel_logout_uri}): #{e.class} - #{e.message}"
|
||||||
raise # Retry on error
|
raise # Retry on error
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
class ApplicationMailer < ActionMailer::Base
|
class ApplicationMailer < ActionMailer::Base
|
||||||
default from: ENV.fetch('CLINCH_FROM_EMAIL', 'clinch@example.com')
|
default from: ENV.fetch("CLINCH_FROM_EMAIL", "clinch@example.com")
|
||||||
layout "mailer"
|
layout "mailer"
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -19,16 +19,16 @@ class Application < ApplicationRecord
|
|||||||
has_many :oidc_user_consents, dependent: :destroy
|
has_many :oidc_user_consents, dependent: :destroy
|
||||||
|
|
||||||
validates :name, presence: true
|
validates :name, presence: true
|
||||||
validates :slug, presence: true, uniqueness: { case_sensitive: false },
|
validates :slug, presence: true, uniqueness: {case_sensitive: false},
|
||||||
format: { with: /\A[a-z0-9\-]+\z/, message: "only lowercase letters, numbers, and hyphens" }
|
format: {with: /\A[a-z0-9-]+\z/, message: "only lowercase letters, numbers, and hyphens"}
|
||||||
validates :app_type, presence: true,
|
validates :app_type, presence: true,
|
||||||
inclusion: { in: %w[oidc forward_auth] }
|
inclusion: {in: %w[oidc forward_auth]}
|
||||||
validates :client_id, uniqueness: { allow_nil: true }
|
validates :client_id, uniqueness: {allow_nil: true}
|
||||||
validates :client_secret, presence: true, on: :create, if: -> { oidc? && confidential_client? }
|
validates :client_secret, presence: true, on: :create, if: -> { oidc? && confidential_client? }
|
||||||
validates :domain_pattern, presence: true, uniqueness: { case_sensitive: false }, if: :forward_auth?
|
validates :domain_pattern, presence: true, uniqueness: {case_sensitive: false}, if: :forward_auth?
|
||||||
validates :landing_url, format: { with: URI::regexp(%w[http https]), allow_nil: true, message: "must be a valid URL" }
|
validates :landing_url, format: {with: URI::RFC2396_PARSER.make_regexp(%w[http https]), allow_nil: true, message: "must be a valid URL"}
|
||||||
validates :backchannel_logout_uri, format: {
|
validates :backchannel_logout_uri, format: {
|
||||||
with: URI::regexp(%w[http https]),
|
with: URI::RFC2396_PARSER.make_regexp(%w[http https]),
|
||||||
allow_nil: true,
|
allow_nil: true,
|
||||||
message: "must be a valid HTTP or HTTPS URL"
|
message: "must be a valid HTTP or HTTPS URL"
|
||||||
}
|
}
|
||||||
@@ -38,9 +38,9 @@ class Application < ApplicationRecord
|
|||||||
validate :icon_validation, if: -> { icon.attached? }
|
validate :icon_validation, if: -> { icon.attached? }
|
||||||
|
|
||||||
# Token TTL validations (for OIDC apps)
|
# Token TTL validations (for OIDC apps)
|
||||||
validates :access_token_ttl, numericality: { greater_than_or_equal_to: 300, less_than_or_equal_to: 86400 }, if: :oidc? # 5 min - 24 hours
|
validates :access_token_ttl, numericality: {greater_than_or_equal_to: 300, less_than_or_equal_to: 86400}, if: :oidc? # 5 min - 24 hours
|
||||||
validates :refresh_token_ttl, numericality: { greater_than_or_equal_to: 86400, less_than_or_equal_to: 7776000 }, if: :oidc? # 1 day - 90 days
|
validates :refresh_token_ttl, numericality: {greater_than_or_equal_to: 86400, less_than_or_equal_to: 7776000}, if: :oidc? # 1 day - 90 days
|
||||||
validates :id_token_ttl, numericality: { greater_than_or_equal_to: 300, less_than_or_equal_to: 86400 }, if: :oidc? # 5 min - 24 hours
|
validates :id_token_ttl, numericality: {greater_than_or_equal_to: 300, less_than_or_equal_to: 86400}, if: :oidc? # 5 min - 24 hours
|
||||||
|
|
||||||
normalizes :slug, with: ->(slug) { slug.strip.downcase }
|
normalizes :slug, with: ->(slug) { slug.strip.downcase }
|
||||||
normalizes :domain_pattern, with: ->(pattern) {
|
normalizes :domain_pattern, with: ->(pattern) {
|
||||||
@@ -56,11 +56,11 @@ class Application < ApplicationRecord
|
|||||||
|
|
||||||
# Default header configuration for ForwardAuth
|
# Default header configuration for ForwardAuth
|
||||||
DEFAULT_HEADERS = {
|
DEFAULT_HEADERS = {
|
||||||
user: 'X-Remote-User',
|
user: "X-Remote-User",
|
||||||
email: 'X-Remote-Email',
|
email: "X-Remote-Email",
|
||||||
name: 'X-Remote-Name',
|
name: "X-Remote-Name",
|
||||||
groups: 'X-Remote-Groups',
|
groups: "X-Remote-Groups",
|
||||||
admin: 'X-Remote-Admin'
|
admin: "X-Remote-Admin"
|
||||||
}.freeze
|
}.freeze
|
||||||
|
|
||||||
# Scopes
|
# Scopes
|
||||||
@@ -135,8 +135,8 @@ class Application < ApplicationRecord
|
|||||||
def matches_domain?(domain)
|
def matches_domain?(domain)
|
||||||
return false if domain.blank? || !forward_auth?
|
return false if domain.blank? || !forward_auth?
|
||||||
|
|
||||||
pattern = domain_pattern.gsub('.', '\.')
|
pattern = domain_pattern.gsub(".", '\.')
|
||||||
pattern = pattern.gsub('*', '[^.]*')
|
pattern = pattern.gsub("*", "[^.]*")
|
||||||
|
|
||||||
regex = Regexp.new("^#{pattern}$", Regexp::IGNORECASE)
|
regex = Regexp.new("^#{pattern}$", Regexp::IGNORECASE)
|
||||||
regex.match?(domain.downcase)
|
regex.match?(domain.downcase)
|
||||||
@@ -144,18 +144,18 @@ class Application < ApplicationRecord
|
|||||||
|
|
||||||
# Policy determination based on user status (for ForwardAuth)
|
# Policy determination based on user status (for ForwardAuth)
|
||||||
def policy_for_user(user)
|
def policy_for_user(user)
|
||||||
return 'deny' unless active?
|
return "deny" unless active?
|
||||||
return 'deny' unless user.active?
|
return "deny" unless user.active?
|
||||||
|
|
||||||
# If no groups specified, bypass authentication
|
# If no groups specified, bypass authentication
|
||||||
return 'bypass' if allowed_groups.empty?
|
return "bypass" if allowed_groups.empty?
|
||||||
|
|
||||||
# If user is in allowed groups, determine auth level
|
# If user is in allowed groups, determine auth level
|
||||||
if user_allowed?(user)
|
if user_allowed?(user)
|
||||||
# Require 2FA if user has TOTP configured, otherwise one factor
|
# Require 2FA if user has TOTP configured, otherwise one factor
|
||||||
user.totp_enabled? ? 'two_factor' : 'one_factor'
|
user.totp_enabled? ? "two_factor" : "one_factor"
|
||||||
else
|
else
|
||||||
'deny'
|
"deny"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -197,7 +197,7 @@ class Application < ApplicationRecord
|
|||||||
def generate_new_client_secret!
|
def generate_new_client_secret!
|
||||||
secret = SecureRandom.urlsafe_base64(48)
|
secret = SecureRandom.urlsafe_base64(48)
|
||||||
self.client_secret = secret
|
self.client_secret = secret
|
||||||
self.save!
|
save!
|
||||||
secret
|
secret
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -260,14 +260,14 @@ class Application < ApplicationRecord
|
|||||||
return unless icon.attached?
|
return unless icon.attached?
|
||||||
|
|
||||||
# Check content type
|
# Check content type
|
||||||
allowed_types = ['image/png', 'image/jpg', 'image/jpeg', 'image/gif', 'image/svg+xml']
|
allowed_types = ["image/png", "image/jpg", "image/jpeg", "image/gif", "image/svg+xml"]
|
||||||
unless allowed_types.include?(icon.content_type)
|
unless allowed_types.include?(icon.content_type)
|
||||||
errors.add(:icon, 'must be a PNG, JPG, GIF, or SVG image')
|
errors.add(:icon, "must be a PNG, JPG, GIF, or SVG image")
|
||||||
end
|
end
|
||||||
|
|
||||||
# Check file size (2MB limit)
|
# Check file size (2MB limit)
|
||||||
if icon.blob.byte_size > 2.megabytes
|
if icon.blob.byte_size > 2.megabytes
|
||||||
errors.add(:icon, 'must be less than 2MB')
|
errors.add(:icon, "must be less than 2MB")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -302,8 +302,8 @@ class Application < ApplicationRecord
|
|||||||
|
|
||||||
begin
|
begin
|
||||||
uri = URI.parse(backchannel_logout_uri)
|
uri = URI.parse(backchannel_logout_uri)
|
||||||
unless uri.scheme == 'https'
|
unless uri.scheme == "https"
|
||||||
errors.add(:backchannel_logout_uri, 'must use HTTPS in production')
|
errors.add(:backchannel_logout_uri, "must use HTTPS in production")
|
||||||
end
|
end
|
||||||
rescue URI::InvalidURIError
|
rescue URI::InvalidURIError
|
||||||
# Let the format validator handle invalid URIs
|
# Let the format validator handle invalid URIs
|
||||||
|
|||||||
@@ -2,5 +2,5 @@ class ApplicationGroup < ApplicationRecord
|
|||||||
belongs_to :application
|
belongs_to :application
|
||||||
belongs_to :group
|
belongs_to :group
|
||||||
|
|
||||||
validates :application_id, uniqueness: { scope: :group_id }
|
validates :application_id, uniqueness: {scope: :group_id}
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ class ApplicationUserClaim < ApplicationRecord
|
|||||||
groups
|
groups
|
||||||
].freeze
|
].freeze
|
||||||
|
|
||||||
validates :user_id, uniqueness: { scope: :application_id }
|
validates :user_id, uniqueness: {scope: :application_id}
|
||||||
validate :no_reserved_claim_names
|
validate :no_reserved_claim_names
|
||||||
|
|
||||||
# Parse custom_claims JSON field
|
# Parse custom_claims JSON field
|
||||||
@@ -25,7 +25,7 @@ class ApplicationUserClaim < ApplicationRecord
|
|||||||
|
|
||||||
reserved_used = parsed_custom_claims.keys.map(&:to_s) & RESERVED_CLAIMS
|
reserved_used = parsed_custom_claims.keys.map(&:to_s) & RESERVED_CLAIMS
|
||||||
if reserved_used.any?
|
if reserved_used.any?
|
||||||
errors.add(:custom_claims, "cannot override reserved OIDC claims: #{reserved_used.join(', ')}")
|
errors.add(:custom_claims, "cannot override reserved OIDC claims: #{reserved_used.join(", ")}")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class Group < ApplicationRecord
|
|||||||
groups
|
groups
|
||||||
].freeze
|
].freeze
|
||||||
|
|
||||||
validates :name, presence: true, uniqueness: { case_sensitive: false }
|
validates :name, presence: true, uniqueness: {case_sensitive: false}
|
||||||
normalizes :name, with: ->(name) { name.strip.downcase }
|
normalizes :name, with: ->(name) { name.strip.downcase }
|
||||||
validate :no_reserved_claim_names
|
validate :no_reserved_claim_names
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ class Group < ApplicationRecord
|
|||||||
|
|
||||||
reserved_used = parsed_custom_claims.keys.map(&:to_s) & RESERVED_CLAIMS
|
reserved_used = parsed_custom_claims.keys.map(&:to_s) & RESERVED_CLAIMS
|
||||||
if reserved_used.any?
|
if reserved_used.any?
|
||||||
errors.add(:custom_claims, "cannot override reserved OIDC claims: #{reserved_used.join(', ')}")
|
errors.add(:custom_claims, "cannot override reserved OIDC claims: #{reserved_used.join(", ")}")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ class OidcAccessToken < ApplicationRecord
|
|||||||
|
|
||||||
# Compute HMAC for token lookup
|
# Compute HMAC for token lookup
|
||||||
def self.compute_token_hmac(plaintext_token)
|
def self.compute_token_hmac(plaintext_token)
|
||||||
OpenSSL::HMAC.hexdigest('SHA256', TokenHmac::KEY, plaintext_token)
|
OpenSSL::HMAC.hexdigest("SHA256", TokenHmac::KEY, plaintext_token)
|
||||||
end
|
end
|
||||||
|
|
||||||
def expired?
|
def expired?
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ class OidcAuthorizationCode < ApplicationRecord
|
|||||||
|
|
||||||
validates :code_hmac, presence: true, uniqueness: true
|
validates :code_hmac, presence: true, uniqueness: true
|
||||||
validates :redirect_uri, presence: true
|
validates :redirect_uri, presence: true
|
||||||
validates :code_challenge_method, inclusion: { in: %w[plain S256], allow_nil: true }
|
validates :code_challenge_method, inclusion: {in: %w[plain S256], allow_nil: true}
|
||||||
validate :validate_code_challenge_format, if: -> { code_challenge.present? }
|
validate :validate_code_challenge_format, if: -> { code_challenge.present? }
|
||||||
|
|
||||||
scope :valid, -> { where(used: false).where("expires_at > ?", Time.current) }
|
scope :valid, -> { where(used: false).where("expires_at > ?", Time.current) }
|
||||||
@@ -25,7 +25,7 @@ class OidcAuthorizationCode < ApplicationRecord
|
|||||||
|
|
||||||
# Compute HMAC for code lookup
|
# Compute HMAC for code lookup
|
||||||
def self.compute_code_hmac(plaintext_code)
|
def self.compute_code_hmac(plaintext_code)
|
||||||
OpenSSL::HMAC.hexdigest('SHA256', TokenHmac::KEY, plaintext_code)
|
OpenSSL::HMAC.hexdigest("SHA256", TokenHmac::KEY, plaintext_code)
|
||||||
end
|
end
|
||||||
|
|
||||||
def expired?
|
def expired?
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ class OidcRefreshToken < ApplicationRecord
|
|||||||
|
|
||||||
# Compute HMAC for token lookup
|
# Compute HMAC for token lookup
|
||||||
def self.compute_token_hmac(plaintext_token)
|
def self.compute_token_hmac(plaintext_token)
|
||||||
OpenSSL::HMAC.hexdigest('SHA256', TokenHmac::KEY, plaintext_token)
|
OpenSSL::HMAC.hexdigest("SHA256", TokenHmac::KEY, plaintext_token)
|
||||||
end
|
end
|
||||||
|
|
||||||
def expired?
|
def expired?
|
||||||
|
|||||||
@@ -3,19 +3,19 @@ class OidcUserConsent < ApplicationRecord
|
|||||||
belongs_to :application
|
belongs_to :application
|
||||||
|
|
||||||
validates :user, :application, :scopes_granted, :granted_at, presence: true
|
validates :user, :application, :scopes_granted, :granted_at, presence: true
|
||||||
validates :user_id, uniqueness: { scope: :application_id }
|
validates :user_id, uniqueness: {scope: :application_id}
|
||||||
|
|
||||||
before_validation :set_granted_at, on: :create
|
before_validation :set_granted_at, on: :create
|
||||||
before_validation :set_sid, on: :create
|
before_validation :set_sid, on: :create
|
||||||
|
|
||||||
# Parse scopes_granted into an array
|
# Parse scopes_granted into an array
|
||||||
def scopes
|
def scopes
|
||||||
scopes_granted.split(' ')
|
scopes_granted.split(" ")
|
||||||
end
|
end
|
||||||
|
|
||||||
# Set scopes from an array
|
# Set scopes from an array
|
||||||
def scopes=(scope_array)
|
def scopes=(scope_array)
|
||||||
self.scopes_granted = Array(scope_array).uniq.join(' ')
|
self.scopes_granted = Array(scope_array).uniq.join(" ")
|
||||||
end
|
end
|
||||||
|
|
||||||
# Check if this consent covers the requested scopes
|
# Check if this consent covers the requested scopes
|
||||||
@@ -31,18 +31,18 @@ class OidcUserConsent < ApplicationRecord
|
|||||||
def formatted_scopes
|
def formatted_scopes
|
||||||
scopes.map do |scope|
|
scopes.map do |scope|
|
||||||
case scope
|
case scope
|
||||||
when 'openid'
|
when "openid"
|
||||||
'Basic authentication'
|
"Basic authentication"
|
||||||
when 'profile'
|
when "profile"
|
||||||
'Profile information'
|
"Profile information"
|
||||||
when 'email'
|
when "email"
|
||||||
'Email address'
|
"Email address"
|
||||||
when 'groups'
|
when "groups"
|
||||||
'Group membership'
|
"Group membership"
|
||||||
else
|
else
|
||||||
scope.humanize
|
scope.humanize
|
||||||
end
|
end
|
||||||
end.join(', ')
|
end.join(", ")
|
||||||
end
|
end
|
||||||
|
|
||||||
# Find consent by SID
|
# Find consent by SID
|
||||||
|
|||||||
@@ -29,16 +29,16 @@ class User < ApplicationRecord
|
|||||||
groups
|
groups
|
||||||
].freeze
|
].freeze
|
||||||
|
|
||||||
validates :email_address, presence: true, uniqueness: { case_sensitive: false },
|
validates :email_address, presence: true, uniqueness: {case_sensitive: false},
|
||||||
format: { with: URI::MailTo::EMAIL_REGEXP }
|
format: {with: URI::MailTo::EMAIL_REGEXP}
|
||||||
validates :username, uniqueness: { case_sensitive: false }, allow_nil: true,
|
validates :username, uniqueness: {case_sensitive: false}, allow_nil: true,
|
||||||
format: { with: /\A[a-zA-Z0-9_-]+\z/, message: "can only contain letters, numbers, underscores, and hyphens" },
|
format: {with: /\A[a-zA-Z0-9_-]+\z/, message: "can only contain letters, numbers, underscores, and hyphens"},
|
||||||
length: { minimum: 2, maximum: 30 }
|
length: {minimum: 2, maximum: 30}
|
||||||
validates :password, length: { minimum: 8 }, allow_nil: true
|
validates :password, length: {minimum: 8}, allow_nil: true
|
||||||
validate :no_reserved_claim_names
|
validate :no_reserved_claim_names
|
||||||
|
|
||||||
# Enum - automatically creates scopes (User.active, User.disabled, etc.)
|
# Enum - automatically creates scopes (User.active, User.disabled, etc.)
|
||||||
enum :status, { active: 0, disabled: 1, pending_invitation: 2 }
|
enum :status, {active: 0, disabled: 1, pending_invitation: 2}
|
||||||
|
|
||||||
# Scopes
|
# Scopes
|
||||||
scope :admins, -> { where(admin: true) }
|
scope :admins, -> { where(admin: true) }
|
||||||
@@ -122,12 +122,7 @@ class User < ApplicationRecord
|
|||||||
cache_key = "backup_code_failed_attempts_#{id}"
|
cache_key = "backup_code_failed_attempts_#{id}"
|
||||||
attempts = Rails.cache.read(cache_key) || 0
|
attempts = Rails.cache.read(cache_key) || 0
|
||||||
|
|
||||||
if attempts >= 5 # Allow max 5 failed attempts per hour
|
attempts >= 5
|
||||||
true
|
|
||||||
else
|
|
||||||
# Don't increment here - increment only on failed attempts
|
|
||||||
false
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Increment failed attempt counter
|
# Increment failed attempt counter
|
||||||
@@ -231,7 +226,7 @@ class User < ApplicationRecord
|
|||||||
|
|
||||||
reserved_used = parsed_custom_claims.keys.map(&:to_s) & RESERVED_CLAIMS
|
reserved_used = parsed_custom_claims.keys.map(&:to_s) & RESERVED_CLAIMS
|
||||||
if reserved_used.any?
|
if reserved_used.any?
|
||||||
errors.add(:custom_claims, "cannot override reserved OIDC claims: #{reserved_used.join(', ')}")
|
errors.add(:custom_claims, "cannot override reserved OIDC claims: #{reserved_used.join(", ")}")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -2,5 +2,5 @@ class UserGroup < ApplicationRecord
|
|||||||
belongs_to :user
|
belongs_to :user
|
||||||
belongs_to :group
|
belongs_to :group
|
||||||
|
|
||||||
validates :user_id, uniqueness: { scope: :group_id }
|
validates :user_id, uniqueness: {scope: :group_id}
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ class WebauthnCredential < ApplicationRecord
|
|||||||
# Validations
|
# Validations
|
||||||
validates :external_id, presence: true, uniqueness: true
|
validates :external_id, presence: true, uniqueness: true
|
||||||
validates :public_key, presence: true
|
validates :public_key, presence: true
|
||||||
validates :sign_count, presence: true, numericality: { greater_than_or_equal_to: 0, only_integer: true }
|
validates :sign_count, presence: true, numericality: {greater_than_or_equal_to: 0, only_integer: true}
|
||||||
validates :nickname, presence: true
|
validates :nickname, presence: true
|
||||||
validates :authenticator_type, inclusion: { in: %w[platform cross-platform] }
|
validates :authenticator_type, inclusion: {in: %w[platform cross-platform]}
|
||||||
|
|
||||||
# Scopes for querying
|
# Scopes for querying
|
||||||
scope :active, -> { where(nil) } # All credentials are active (we can add revoked_at later if needed)
|
scope :active, -> { where(nil) } # All credentials are active (we can add revoked_at later if needed)
|
||||||
@@ -84,11 +84,11 @@ class WebauthnCredential < ApplicationRecord
|
|||||||
days = hours / 24
|
days = hours / 24
|
||||||
|
|
||||||
if days > 0
|
if days > 0
|
||||||
"#{days.floor} day#{'s' if days > 1} ago"
|
"#{days.floor} day#{"s" if days > 1} ago"
|
||||||
elsif hours > 0
|
elsif hours > 0
|
||||||
"#{hours.floor} hour#{'s' if hours > 1} ago"
|
"#{hours.floor} hour#{"s" if hours > 1} ago"
|
||||||
elsif minutes > 0
|
elsif minutes > 0
|
||||||
"#{minutes.floor} minute#{'s' if minutes > 1} ago"
|
"#{minutes.floor} minute#{"s" if minutes > 1} ago"
|
||||||
else
|
else
|
||||||
"Just now"
|
"Just now"
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -13,20 +13,20 @@ module ClaimsMerger
|
|||||||
result = base.dup
|
result = base.dup
|
||||||
|
|
||||||
incoming.each do |key, value|
|
incoming.each do |key, value|
|
||||||
if result.key?(key)
|
result[key] = if result.key?(key)
|
||||||
# If both values are arrays, combine them (union to avoid duplicates)
|
# If both values are arrays, combine them (union to avoid duplicates)
|
||||||
if result[key].is_a?(Array) && value.is_a?(Array)
|
if result[key].is_a?(Array) && value.is_a?(Array)
|
||||||
result[key] = (result[key] + value).uniq
|
(result[key] + value).uniq
|
||||||
# If both values are hashes, recursively merge them
|
# If both values are hashes, recursively merge them
|
||||||
elsif result[key].is_a?(Hash) && value.is_a?(Hash)
|
elsif result[key].is_a?(Hash) && value.is_a?(Hash)
|
||||||
result[key] = deep_merge_claims(result[key], value)
|
deep_merge_claims(result[key], value)
|
||||||
else
|
else
|
||||||
# Otherwise, incoming value wins (override)
|
# Otherwise, incoming value wins (override)
|
||||||
result[key] = value
|
value
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
# New key, just add it
|
# New key, just add it
|
||||||
result[key] = value
|
value
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ class OidcJwtService
|
|||||||
# Merge app-specific custom claims (highest priority, arrays are combined)
|
# Merge app-specific custom claims (highest priority, arrays are combined)
|
||||||
payload = deep_merge_claims(payload, application.custom_claims_for_user(user))
|
payload = deep_merge_claims(payload, application.custom_claims_for_user(user))
|
||||||
|
|
||||||
JWT.encode(payload, private_key, "RS256", { kid: key_id, typ: "JWT" })
|
JWT.encode(payload, private_key, "RS256", {kid: key_id, typ: "JWT"})
|
||||||
end
|
end
|
||||||
|
|
||||||
# Generate a backchannel logout token (JWT)
|
# Generate a backchannel logout token (JWT)
|
||||||
@@ -84,12 +84,12 @@ class OidcJwtService
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Important: Do NOT include nonce in logout tokens (spec requirement)
|
# Important: Do NOT include nonce in logout tokens (spec requirement)
|
||||||
JWT.encode(payload, private_key, "RS256", { kid: key_id, typ: "JWT" })
|
JWT.encode(payload, private_key, "RS256", {kid: key_id, typ: "JWT"})
|
||||||
end
|
end
|
||||||
|
|
||||||
# Decode and verify an ID token
|
# Decode and verify an ID token
|
||||||
def decode_id_token(token)
|
def decode_id_token(token)
|
||||||
JWT.decode(token, public_key, true, { algorithm: "RS256" })
|
JWT.decode(token, public_key, true, {algorithm: "RS256"})
|
||||||
end
|
end
|
||||||
|
|
||||||
# Get the public key in JWK format for the JWKS endpoint
|
# Get the public key in JWK format for the JWKS endpoint
|
||||||
|
|||||||
@@ -27,13 +27,13 @@ module Clinch
|
|||||||
# Configure SMTP settings using environment variables
|
# Configure SMTP settings using environment variables
|
||||||
config.action_mailer.delivery_method = :smtp
|
config.action_mailer.delivery_method = :smtp
|
||||||
config.action_mailer.smtp_settings = {
|
config.action_mailer.smtp_settings = {
|
||||||
address: ENV.fetch('SMTP_ADDRESS', 'localhost'),
|
address: ENV.fetch("SMTP_ADDRESS", "localhost"),
|
||||||
port: ENV.fetch('SMTP_PORT', 587),
|
port: ENV.fetch("SMTP_PORT", 587),
|
||||||
domain: ENV.fetch('SMTP_DOMAIN', 'localhost'),
|
domain: ENV.fetch("SMTP_DOMAIN", "localhost"),
|
||||||
user_name: ENV.fetch('SMTP_USERNAME', nil),
|
user_name: ENV.fetch("SMTP_USERNAME", nil),
|
||||||
password: ENV.fetch('SMTP_PASSWORD', nil),
|
password: ENV.fetch("SMTP_PASSWORD", nil),
|
||||||
authentication: ENV.fetch('SMTP_AUTHENTICATION', 'plain').to_sym,
|
authentication: ENV.fetch("SMTP_AUTHENTICATION", "plain").to_sym,
|
||||||
enable_starttls_auto: ENV.fetch('SMTP_STARTTLS_AUTO', 'true') == 'true',
|
enable_starttls_auto: ENV.fetch("SMTP_STARTTLS_AUTO", "true") == "true",
|
||||||
openssl_verify_mode: OpenSSL::SSL::VERIFY_PEER
|
openssl_verify_mode: OpenSSL::SSL::VERIFY_PEER
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ Rails.application.configure do
|
|||||||
if Rails.root.join("tmp/caching-dev.txt").exist?
|
if Rails.root.join("tmp/caching-dev.txt").exist?
|
||||||
config.action_controller.perform_caching = true
|
config.action_controller.perform_caching = true
|
||||||
config.action_controller.enable_fragment_cache_logging = true
|
config.action_controller.enable_fragment_cache_logging = true
|
||||||
config.public_file_server.headers = { "cache-control" => "public, max-age=#{2.days.to_i}" }
|
config.public_file_server.headers = {"cache-control" => "public, max-age=#{2.days.to_i}"}
|
||||||
else
|
else
|
||||||
config.action_controller.perform_caching = false
|
config.action_controller.perform_caching = false
|
||||||
end
|
end
|
||||||
@@ -39,10 +39,10 @@ Rails.application.configure do
|
|||||||
config.action_mailer.perform_caching = false
|
config.action_mailer.perform_caching = false
|
||||||
|
|
||||||
# Set localhost to be used by links generated in mailer templates.
|
# Set localhost to be used by links generated in mailer templates.
|
||||||
config.action_mailer.default_url_options = { host: "localhost", port: 3000 }
|
config.action_mailer.default_url_options = {host: "localhost", port: 3000}
|
||||||
|
|
||||||
# Log with request_id as a tag (same as production).
|
# Log with request_id as a tag (same as production).
|
||||||
config.log_tags = [ :request_id ]
|
config.log_tags = [:request_id]
|
||||||
|
|
||||||
# Print deprecation notices to the Rails logger.
|
# Print deprecation notices to the Rails logger.
|
||||||
config.active_support.deprecation = :log
|
config.active_support.deprecation = :log
|
||||||
@@ -62,7 +62,6 @@ Rails.application.configure do
|
|||||||
# Use async processor for background jobs in development
|
# Use async processor for background jobs in development
|
||||||
config.active_job.queue_adapter = :async
|
config.active_job.queue_adapter = :async
|
||||||
|
|
||||||
|
|
||||||
# Highlight code that triggered redirect in logs.
|
# Highlight code that triggered redirect in logs.
|
||||||
config.action_dispatch.verbose_redirect_logs = true
|
config.action_dispatch.verbose_redirect_logs = true
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ Rails.application.configure do
|
|||||||
config.action_controller.perform_caching = true
|
config.action_controller.perform_caching = true
|
||||||
|
|
||||||
# Cache assets for far-future expiry since they are all digest stamped.
|
# Cache assets for far-future expiry since they are all digest stamped.
|
||||||
config.public_file_server.headers = { "cache-control" => "public, max-age=#{1.year.to_i}" }
|
config.public_file_server.headers = {"cache-control" => "public, max-age=#{1.year.to_i}"}
|
||||||
|
|
||||||
# Enable serving of images, stylesheets, and JavaScripts from an asset server.
|
# Enable serving of images, stylesheets, and JavaScripts from an asset server.
|
||||||
# config.asset_host = "http://assets.example.com"
|
# config.asset_host = "http://assets.example.com"
|
||||||
@@ -34,16 +34,16 @@ Rails.application.configure do
|
|||||||
# Note: Rails already sets X-Content-Type-Options: nosniff by default
|
# Note: Rails already sets X-Content-Type-Options: nosniff by default
|
||||||
# Note: Permissions-Policy is configured in config/initializers/permissions_policy.rb
|
# Note: Permissions-Policy is configured in config/initializers/permissions_policy.rb
|
||||||
config.action_dispatch.default_headers.merge!(
|
config.action_dispatch.default_headers.merge!(
|
||||||
'X-Frame-Options' => 'DENY', # Override default SAMEORIGIN to prevent clickjacking
|
"X-Frame-Options" => "DENY", # Override default SAMEORIGIN to prevent clickjacking
|
||||||
'Referrer-Policy' => 'strict-origin-when-cross-origin' # Control referrer information
|
"Referrer-Policy" => "strict-origin-when-cross-origin" # Control referrer information
|
||||||
)
|
)
|
||||||
|
|
||||||
# Skip http-to-https redirect for the default health check endpoint.
|
# Skip http-to-https redirect for the default health check endpoint.
|
||||||
# config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } }
|
# config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } }
|
||||||
|
|
||||||
# Log to STDOUT with the current request id as a default log tag.
|
# Log to STDOUT with the current request id as a default log tag.
|
||||||
config.log_tags = [ :request_id ]
|
config.log_tags = [:request_id]
|
||||||
config.logger = ActiveSupport::TaggedLogging.logger(STDOUT)
|
config.logger = ActiveSupport::TaggedLogging.logger($stdout)
|
||||||
|
|
||||||
# Change to "debug" to log everything (including potentially personally-identifiable information!).
|
# Change to "debug" to log everything (including potentially personally-identifiable information!).
|
||||||
config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info")
|
config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info")
|
||||||
@@ -66,7 +66,7 @@ Rails.application.configure do
|
|||||||
|
|
||||||
# Set host to be used by links generated in mailer templates.
|
# Set host to be used by links generated in mailer templates.
|
||||||
config.action_mailer.default_url_options = {
|
config.action_mailer.default_url_options = {
|
||||||
host: ENV.fetch('CLINCH_HOST', 'example.com')
|
host: ENV.fetch("CLINCH_HOST", "example.com")
|
||||||
}
|
}
|
||||||
|
|
||||||
# Specify outgoing SMTP server. Remember to add smtp/* credentials via bin/rails credentials:edit.
|
# Specify outgoing SMTP server. Remember to add smtp/* credentials via bin/rails credentials:edit.
|
||||||
@@ -86,13 +86,13 @@ Rails.application.configure do
|
|||||||
config.active_record.dump_schema_after_migration = false
|
config.active_record.dump_schema_after_migration = false
|
||||||
|
|
||||||
# Only use :id for inspections in production.
|
# Only use :id for inspections in production.
|
||||||
config.active_record.attributes_for_inspect = [ :id ]
|
config.active_record.attributes_for_inspect = [:id]
|
||||||
|
|
||||||
# Helper method to extract domain from CLINCH_HOST (removes protocol if present)
|
# Helper method to extract domain from CLINCH_HOST (removes protocol if present)
|
||||||
def self.extract_domain(host)
|
def self.extract_domain(host)
|
||||||
return host if host.blank?
|
return host if host.blank?
|
||||||
# Remove protocol (http:// or https://) if present
|
# Remove protocol (http:// or https://) if present
|
||||||
host.gsub(/^https?:\/\//, '')
|
host.gsub(/^https?:\/\//, "")
|
||||||
end
|
end
|
||||||
|
|
||||||
# Helper method to ensure URL has https:// protocol
|
# Helper method to ensure URL has https:// protocol
|
||||||
@@ -105,11 +105,11 @@ Rails.application.configure do
|
|||||||
# Enable DNS rebinding protection and other `Host` header attacks.
|
# Enable DNS rebinding protection and other `Host` header attacks.
|
||||||
# Configure allowed hosts based on deployment scenario
|
# Configure allowed hosts based on deployment scenario
|
||||||
allowed_hosts = [
|
allowed_hosts = [
|
||||||
extract_domain(ENV.fetch('CLINCH_HOST', 'auth.example.com')), # External domain (auth service itself)
|
extract_domain(ENV.fetch("CLINCH_HOST", "auth.example.com")) # External domain (auth service itself)
|
||||||
]
|
]
|
||||||
|
|
||||||
# Use PublicSuffix to extract registrable domain and allow all subdomains
|
# Use PublicSuffix to extract registrable domain and allow all subdomains
|
||||||
host_domain = extract_domain(ENV.fetch('CLINCH_HOST', 'auth.example.com'))
|
host_domain = extract_domain(ENV.fetch("CLINCH_HOST", "auth.example.com"))
|
||||||
if host_domain.present?
|
if host_domain.present?
|
||||||
begin
|
begin
|
||||||
# Use PublicSuffix to properly extract the domain
|
# Use PublicSuffix to properly extract the domain
|
||||||
@@ -123,20 +123,20 @@ Rails.application.configure do
|
|||||||
rescue PublicSuffix::DomainInvalid
|
rescue PublicSuffix::DomainInvalid
|
||||||
# Fallback to simple domain extraction if PublicSuffix fails
|
# Fallback to simple domain extraction if PublicSuffix fails
|
||||||
Rails.logger.warn "Could not parse domain '#{host_domain}' with PublicSuffix, using fallback"
|
Rails.logger.warn "Could not parse domain '#{host_domain}' with PublicSuffix, using fallback"
|
||||||
base_domain = host_domain.split('.').last(2).join('.')
|
base_domain = host_domain.split(".").last(2).join(".")
|
||||||
allowed_hosts << /.*#{Regexp.escape(base_domain)}/
|
allowed_hosts << /.*#{Regexp.escape(base_domain)}/
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Allow Docker service names if running in same compose
|
# Allow Docker service names if running in same compose
|
||||||
if ENV['CLINCH_DOCKER_SERVICE_NAME']
|
if ENV["CLINCH_DOCKER_SERVICE_NAME"]
|
||||||
allowed_hosts << ENV['CLINCH_DOCKER_SERVICE_NAME']
|
allowed_hosts << ENV["CLINCH_DOCKER_SERVICE_NAME"]
|
||||||
end
|
end
|
||||||
|
|
||||||
# Allow internal IP access for cross-compose or host networking
|
# Allow internal IP access for cross-compose or host networking
|
||||||
if ENV['CLINCH_ALLOW_INTERNAL_IPS'] == 'true'
|
if ENV["CLINCH_ALLOW_INTERNAL_IPS"] == "true"
|
||||||
# Specific host IP
|
# Specific host IP
|
||||||
allowed_hosts << '192.168.2.246'
|
allowed_hosts << "192.168.2.246"
|
||||||
|
|
||||||
# Private IP ranges for internal network access
|
# Private IP ranges for internal network access
|
||||||
allowed_hosts += [
|
allowed_hosts += [
|
||||||
@@ -147,14 +147,14 @@ Rails.application.configure do
|
|||||||
end
|
end
|
||||||
|
|
||||||
# Local development fallbacks
|
# Local development fallbacks
|
||||||
if ENV['CLINCH_ALLOW_LOCALHOST'] == 'true'
|
if ENV["CLINCH_ALLOW_LOCALHOST"] == "true"
|
||||||
allowed_hosts += ['localhost', '127.0.0.1', '0.0.0.0']
|
allowed_hosts += ["localhost", "127.0.0.1", "0.0.0.0"]
|
||||||
end
|
end
|
||||||
|
|
||||||
config.hosts = allowed_hosts
|
config.hosts = allowed_hosts
|
||||||
|
|
||||||
# Skip DNS rebinding protection for the default health check endpoint.
|
# Skip DNS rebinding protection for the default health check endpoint.
|
||||||
config.host_authorization = { exclude: ->(request) { request.path == "/up" } }
|
config.host_authorization = {exclude: ->(request) { request.path == "/up" }}
|
||||||
|
|
||||||
# Sentry configuration for production
|
# Sentry configuration for production
|
||||||
# Only enabled if SENTRY_DSN environment variable is set
|
# Only enabled if SENTRY_DSN environment variable is set
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ Rails.application.configure do
|
|||||||
config.eager_load = ENV["CI"].present?
|
config.eager_load = ENV["CI"].present?
|
||||||
|
|
||||||
# Configure public file server for tests with cache-control for performance.
|
# Configure public file server for tests with cache-control for performance.
|
||||||
config.public_file_server.headers = { "cache-control" => "public, max-age=3600" }
|
config.public_file_server.headers = {"cache-control" => "public, max-age=3600"}
|
||||||
|
|
||||||
# Show full error reports.
|
# Show full error reports.
|
||||||
config.consider_all_requests_local = true
|
config.consider_all_requests_local = true
|
||||||
@@ -37,7 +37,7 @@ Rails.application.configure do
|
|||||||
config.action_mailer.delivery_method = :test
|
config.action_mailer.delivery_method = :test
|
||||||
|
|
||||||
# Set host to be used by links generated in mailer templates.
|
# Set host to be used by links generated in mailer templates.
|
||||||
config.action_mailer.default_url_options = { host: "example.com" }
|
config.action_mailer.default_url_options = {host: "example.com"}
|
||||||
|
|
||||||
# Print deprecation notices to the stderr.
|
# Print deprecation notices to the stderr.
|
||||||
config.active_support.deprecation = :stderr
|
config.active_support.deprecation = :stderr
|
||||||
|
|||||||
@@ -8,14 +8,14 @@
|
|||||||
# - ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT
|
# - ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT
|
||||||
|
|
||||||
# Use env vars if set, otherwise derive from SECRET_KEY_BASE (deterministic)
|
# Use env vars if set, otherwise derive from SECRET_KEY_BASE (deterministic)
|
||||||
primary_key = ENV.fetch('ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY') do
|
primary_key = ENV.fetch("ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY") do
|
||||||
Rails.application.key_generator.generate_key('active_record_encryption_primary', 32)
|
Rails.application.key_generator.generate_key("active_record_encryption_primary", 32)
|
||||||
end
|
end
|
||||||
deterministic_key = ENV.fetch('ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY') do
|
deterministic_key = ENV.fetch("ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY") do
|
||||||
Rails.application.key_generator.generate_key('active_record_encryption_deterministic', 32)
|
Rails.application.key_generator.generate_key("active_record_encryption_deterministic", 32)
|
||||||
end
|
end
|
||||||
key_derivation_salt = ENV.fetch('ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT') do
|
key_derivation_salt = ENV.fetch("ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT") do
|
||||||
Rails.application.key_generator.generate_key('active_record_encryption_salt', 32)
|
Rails.application.key_generator.generate_key("active_record_encryption_salt", 32)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Configure Rails 7.1+ ActiveRecord encryption
|
# Configure Rails 7.1+ ActiveRecord encryption
|
||||||
|
|||||||
@@ -59,7 +59,6 @@ Rails.application.configure do
|
|||||||
policy.report_uri "/api/csp-violation-report"
|
policy.report_uri "/api/csp-violation-report"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
||||||
# Start with CSP in report-only mode for testing
|
# Start with CSP in report-only mode for testing
|
||||||
# Set to false after verifying everything works in production
|
# Set to false after verifying everything works in production
|
||||||
config.content_security_policy_report_only = Rails.env.development?
|
config.content_security_policy_report_only = Rails.env.development?
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ Rails.application.config.after_initialize do
|
|||||||
# Configure log rotation
|
# Configure log rotation
|
||||||
csp_logger = Logger.new(
|
csp_logger = Logger.new(
|
||||||
csp_log_path,
|
csp_log_path,
|
||||||
'daily', # Rotate daily
|
"daily", # Rotate daily
|
||||||
30 # Keep 30 old log files
|
30 # Keep 30 old log files
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ Rails.application.config.after_initialize do
|
|||||||
|
|
||||||
# Format: [TIMESTAMP] LEVEL MESSAGE
|
# Format: [TIMESTAMP] LEVEL MESSAGE
|
||||||
csp_logger.formatter = proc do |severity, datetime, progname, msg|
|
csp_logger.formatter = proc do |severity, datetime, progname, msg|
|
||||||
"[#{datetime.strftime('%Y-%m-%d %H:%M:%S')}] #{severity} #{msg}\n"
|
"[#{datetime.strftime("%Y-%m-%d %H:%M:%S")}] #{severity} #{msg}\n"
|
||||||
end
|
end
|
||||||
|
|
||||||
module CspViolationLocalLogger
|
module CspViolationLocalLogger
|
||||||
@@ -69,7 +69,6 @@ Rails.application.config.after_initialize do
|
|||||||
|
|
||||||
# Also log to main Rails logger for visibility
|
# Also log to main Rails logger for visibility
|
||||||
Rails.logger.info "CSP violation logged to csp_violations.log: #{violated_directive} - #{blocked_uri}"
|
Rails.logger.info "CSP violation logged to csp_violations.log: #{violated_directive} - #{blocked_uri}"
|
||||||
|
|
||||||
rescue => e
|
rescue => e
|
||||||
# Ensure logger errors don't break the CSP reporting flow
|
# Ensure logger errors don't break the CSP reporting flow
|
||||||
Rails.logger.error "Failed to log CSP violation to file: #{e.message}"
|
Rails.logger.error "Failed to log CSP violation to file: #{e.message}"
|
||||||
@@ -81,12 +80,12 @@ Rails.application.config.after_initialize do
|
|||||||
csp_log_path = Rails.root.join("log", "csp_violations.log")
|
csp_log_path = Rails.root.join("log", "csp_violations.log")
|
||||||
logger = Logger.new(
|
logger = Logger.new(
|
||||||
csp_log_path,
|
csp_log_path,
|
||||||
'daily', # Rotate daily
|
"daily", # Rotate daily
|
||||||
30 # Keep 30 old log files
|
30 # Keep 30 old log files
|
||||||
)
|
)
|
||||||
logger.level = Logger::INFO
|
logger.level = Logger::INFO
|
||||||
logger.formatter = proc do |severity, datetime, progname, msg|
|
logger.formatter = proc do |severity, datetime, progname, msg|
|
||||||
"[#{datetime.strftime('%Y-%m-%d %H:%M:%S')}] #{severity} #{msg}\n"
|
"[#{datetime.strftime("%Y-%m-%d %H:%M:%S")}] #{severity} #{msg}\n"
|
||||||
end
|
end
|
||||||
logger
|
logger
|
||||||
end
|
end
|
||||||
@@ -120,7 +119,6 @@ Rails.application.config.after_initialize do
|
|||||||
|
|
||||||
# Test write to ensure permissions are correct
|
# Test write to ensure permissions are correct
|
||||||
csp_logger.info "CSP Logger initialized at #{Time.current}"
|
csp_logger.info "CSP Logger initialized at #{Time.current}"
|
||||||
|
|
||||||
rescue => e
|
rescue => e
|
||||||
Rails.logger.error "Failed to initialize CSP local logger: #{e.message}"
|
Rails.logger.error "Failed to initialize CSP local logger: #{e.message}"
|
||||||
Rails.logger.error "CSP violations will only be sent to Sentry (if configured)"
|
Rails.logger.error "CSP violations will only be sent to Sentry (if configured)"
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ Rails.application.config.after_initialize do
|
|||||||
timestamp: csp_data[:timestamp]
|
timestamp: csp_data[:timestamp]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
user: csp_data[:current_user_id] ? { id: csp_data[:current_user_id] } : nil
|
user: csp_data[:current_user_id] ? {id: csp_data[:current_user_id]} : nil
|
||||||
)
|
)
|
||||||
|
|
||||||
# Log to Rails logger for redundancy
|
# Log to Rails logger for redundancy
|
||||||
@@ -69,10 +69,10 @@ Rails.application.config.after_initialize do
|
|||||||
parsed.host
|
parsed.host
|
||||||
rescue URI::InvalidURIError
|
rescue URI::InvalidURIError
|
||||||
# Handle cases where URI might be malformed or just a path
|
# Handle cases where URI might be malformed or just a path
|
||||||
if uri.start_with?('/')
|
if uri.start_with?("/")
|
||||||
nil # It's a relative path, no domain
|
nil # It's a relative path, no domain
|
||||||
else
|
else
|
||||||
uri.split('/').first # Best effort extraction
|
uri.split("/").first # Best effort extraction
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -3,5 +3,5 @@
|
|||||||
# Derived from SECRET_KEY_BASE - no storage needed, deterministic output
|
# Derived from SECRET_KEY_BASE - no storage needed, deterministic output
|
||||||
# Optional: Set OIDC_TOKEN_PREFIX_HMAC env var to override with explicit key
|
# Optional: Set OIDC_TOKEN_PREFIX_HMAC env var to override with explicit key
|
||||||
module TokenHmac
|
module TokenHmac
|
||||||
KEY = ENV['OIDC_TOKEN_PREFIX_HMAC'] || Rails.application.key_generator.generate_key('oidc_token_prefix', 32)
|
KEY = ENV["OIDC_TOKEN_PREFIX_HMAC"] || Rails.application.key_generator.generate_key("oidc_token_prefix", 32)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ threads threads_count, threads_count
|
|||||||
# Specifies the `port` that Puma will listen on to receive requests; default is 3000.
|
# Specifies the `port` that Puma will listen on to receive requests; default is 3000.
|
||||||
port ENV.fetch("PORT", 3000)
|
port ENV.fetch("PORT", 3000)
|
||||||
|
|
||||||
|
|
||||||
# Allow puma to be restarted by `bin/rails restart` command.
|
# Allow puma to be restarted by `bin/rails restart` command.
|
||||||
plugin :tmp_restart
|
plugin :tmp_restart
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ Rails.application.routes.draw do
|
|||||||
|
|
||||||
# Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.
|
# Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.
|
||||||
# Can be used by load balancers and uptime monitors to verify that the app is live.
|
# Can be used by load balancers and uptime monitors to verify that the app is live.
|
||||||
get "up" => "rails/health#show", as: :rails_health_check
|
get "up" => "rails/health#show", :as => :rails_health_check
|
||||||
|
|
||||||
# Authentication routes
|
# Authentication routes
|
||||||
get "/signup", to: "users#new", as: :signup
|
get "/signup", to: "users#new", as: :signup
|
||||||
@@ -61,21 +61,21 @@ Rails.application.routes.draw do
|
|||||||
end
|
end
|
||||||
|
|
||||||
# TOTP (2FA) routes
|
# TOTP (2FA) routes
|
||||||
get '/totp/new', to: 'totp#new', as: :new_totp
|
get "/totp/new", to: "totp#new", as: :new_totp
|
||||||
post '/totp', to: 'totp#create', as: :totp
|
post "/totp", to: "totp#create", as: :totp
|
||||||
delete '/totp', to: 'totp#destroy'
|
delete "/totp", to: "totp#destroy"
|
||||||
get '/totp/backup_codes', to: 'totp#backup_codes', as: :backup_codes_totp
|
get "/totp/backup_codes", to: "totp#backup_codes", as: :backup_codes_totp
|
||||||
post '/totp/verify_password', to: 'totp#verify_password', as: :verify_password_totp
|
post "/totp/verify_password", to: "totp#verify_password", as: :verify_password_totp
|
||||||
get '/totp/regenerate_backup_codes', to: 'totp#regenerate_backup_codes', as: :regenerate_backup_codes_totp
|
get "/totp/regenerate_backup_codes", to: "totp#regenerate_backup_codes", as: :regenerate_backup_codes_totp
|
||||||
post '/totp/regenerate_backup_codes', to: 'totp#create_new_backup_codes', as: :create_new_backup_codes_totp
|
post "/totp/regenerate_backup_codes", to: "totp#create_new_backup_codes", as: :create_new_backup_codes_totp
|
||||||
post '/totp/complete_setup', to: 'totp#complete_setup', as: :complete_totp_setup
|
post "/totp/complete_setup", to: "totp#complete_setup", as: :complete_totp_setup
|
||||||
|
|
||||||
# WebAuthn (Passkeys) routes
|
# WebAuthn (Passkeys) routes
|
||||||
get '/webauthn/new', to: 'webauthn#new', as: :new_webauthn
|
get "/webauthn/new", to: "webauthn#new", as: :new_webauthn
|
||||||
post '/webauthn/challenge', to: 'webauthn#challenge'
|
post "/webauthn/challenge", to: "webauthn#challenge"
|
||||||
post '/webauthn/create', to: 'webauthn#create'
|
post "/webauthn/create", to: "webauthn#create"
|
||||||
delete '/webauthn/:id', to: 'webauthn#destroy', as: :webauthn_credential
|
delete "/webauthn/:id", to: "webauthn#destroy", as: :webauthn_credential
|
||||||
get '/webauthn/check', to: 'webauthn#check'
|
get "/webauthn/check", to: "webauthn#check"
|
||||||
|
|
||||||
# Admin routes
|
# Admin routes
|
||||||
namespace :admin do
|
namespace :admin do
|
||||||
|
|||||||
@@ -7,6 +7,6 @@ class CreateUserGroups < ActiveRecord::Migration[8.1]
|
|||||||
t.timestamps
|
t.timestamps
|
||||||
end
|
end
|
||||||
|
|
||||||
add_index :user_groups, [ :user_id, :group_id ], unique: true
|
add_index :user_groups, [:user_id, :group_id], unique: true
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -7,6 +7,6 @@ class CreateApplicationGroups < ActiveRecord::Migration[8.1]
|
|||||||
t.timestamps
|
t.timestamps
|
||||||
end
|
end
|
||||||
|
|
||||||
add_index :application_groups, [ :application_id, :group_id ], unique: true
|
add_index :application_groups, [:application_id, :group_id], unique: true
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -13,6 +13,6 @@ class CreateOidcAuthorizationCodes < ActiveRecord::Migration[8.1]
|
|||||||
end
|
end
|
||||||
add_index :oidc_authorization_codes, :code, unique: true
|
add_index :oidc_authorization_codes, :code, unique: true
|
||||||
add_index :oidc_authorization_codes, :expires_at
|
add_index :oidc_authorization_codes, :expires_at
|
||||||
add_index :oidc_authorization_codes, [ :application_id, :user_id ]
|
add_index :oidc_authorization_codes, [:application_id, :user_id]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -11,6 +11,6 @@ class CreateOidcAccessTokens < ActiveRecord::Migration[8.1]
|
|||||||
end
|
end
|
||||||
add_index :oidc_access_tokens, :token, unique: true
|
add_index :oidc_access_tokens, :token, unique: true
|
||||||
add_index :oidc_access_tokens, :expires_at
|
add_index :oidc_access_tokens, :expires_at
|
||||||
add_index :oidc_access_tokens, [ :application_id, :user_id ]
|
add_index :oidc_access_tokens, [:application_id, :user_id]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
class AddRoleMappingToApplications < ActiveRecord::Migration[8.1]
|
class AddRoleMappingToApplications < ActiveRecord::Migration[8.1]
|
||||||
def change
|
def change
|
||||||
add_column :applications, :role_mapping_mode, :string, default: 'disabled', null: false
|
add_column :applications, :role_mapping_mode, :string, default: "disabled", null: false
|
||||||
add_column :applications, :role_prefix, :string
|
add_column :applications, :role_prefix, :string
|
||||||
add_column :applications, :managed_permissions, :json, default: {}
|
add_column :applications, :managed_permissions, :json, default: {}
|
||||||
add_column :applications, :role_claim_name, :string, default: 'roles'
|
add_column :applications, :role_claim_name, :string, default: "roles"
|
||||||
|
|
||||||
create_table :application_roles do |t|
|
create_table :application_roles do |t|
|
||||||
t.references :application, null: false, foreign_key: true
|
t.references :application, null: false, foreign_key: true
|
||||||
@@ -21,7 +21,7 @@ class AddRoleMappingToApplications < ActiveRecord::Migration[8.1]
|
|||||||
create_table :user_role_assignments do |t|
|
create_table :user_role_assignments do |t|
|
||||||
t.references :user, null: false, foreign_key: true
|
t.references :user, null: false, foreign_key: true
|
||||||
t.references :application_role, null: false, foreign_key: true
|
t.references :application_role, null: false, foreign_key: true
|
||||||
t.string :source, default: 'oidc' # 'oidc', 'manual', 'group_sync'
|
t.string :source, default: "oidc" # 'oidc', 'manual', 'group_sync'
|
||||||
t.json :metadata, default: {}
|
t.json :metadata, default: {}
|
||||||
|
|
||||||
t.timestamps
|
t.timestamps
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ class MigrateForwardAuthRulesToApplications < ActiveRecord::Migration[8.1]
|
|||||||
app = application_class.create!(
|
app = application_class.create!(
|
||||||
name: rule.domain_pattern.titleize,
|
name: rule.domain_pattern.titleize,
|
||||||
slug: rule.domain_pattern.parameterize.presence || "forward-auth-#{rule.id}",
|
slug: rule.domain_pattern.parameterize.presence || "forward-auth-#{rule.id}",
|
||||||
app_type: 'forward_auth',
|
app_type: "forward_auth",
|
||||||
domain_pattern: rule.domain_pattern,
|
domain_pattern: rule.domain_pattern,
|
||||||
headers_config: rule.headers_config || {},
|
headers_config: rule.headers_config || {},
|
||||||
active: rule.active
|
active: rule.active
|
||||||
@@ -59,7 +59,7 @@ class MigrateForwardAuthRulesToApplications < ActiveRecord::Migration[8.1]
|
|||||||
|
|
||||||
def down
|
def down
|
||||||
# Remove all forward_auth applications created by this migration
|
# Remove all forward_auth applications created by this migration
|
||||||
Application.where(app_type: 'forward_auth').destroy_all
|
Application.where(app_type: "forward_auth").destroy_all
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ class CreateWebauthnCredentials < ActiveRecord::Migration[8.1]
|
|||||||
t.references :user, null: false, foreign_key: true, index: true
|
t.references :user, null: false, foreign_key: true, index: true
|
||||||
|
|
||||||
# WebAuthn specification fields
|
# WebAuthn specification fields
|
||||||
t.string :external_id, null: false, index: { unique: true } # credential ID (base64)
|
t.string :external_id, null: false, index: {unique: true} # credential ID (base64)
|
||||||
t.string :public_key, null: false # public key (base64)
|
t.string :public_key, null: false # public key (base64)
|
||||||
t.integer :sign_count, null: false, default: 0 # signature counter (clone detection)
|
t.integer :sign_count, null: false, default: 0 # signature counter (clone detection)
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,6 @@ class CreateOidcRefreshTokens < ActiveRecord::Migration[8.1]
|
|||||||
add_index :oidc_refresh_tokens, :expires_at
|
add_index :oidc_refresh_tokens, :expires_at
|
||||||
add_index :oidc_refresh_tokens, :revoked_at
|
add_index :oidc_refresh_tokens, :revoked_at
|
||||||
add_index :oidc_refresh_tokens, :token_family_id
|
add_index :oidc_refresh_tokens, :token_family_id
|
||||||
add_index :oidc_refresh_tokens, [ :application_id, :user_id ]
|
add_index :oidc_refresh_tokens, [:application_id, :user_id]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
class CreateApplicationUserClaims < ActiveRecord::Migration[8.1]
|
class CreateApplicationUserClaims < ActiveRecord::Migration[8.1]
|
||||||
def change
|
def change
|
||||||
create_table :application_user_claims do |t|
|
create_table :application_user_claims do |t|
|
||||||
t.references :application, null: false, foreign_key: { on_delete: :cascade }
|
t.references :application, null: false, foreign_key: {on_delete: :cascade}
|
||||||
t.references :user, null: false, foreign_key: { on_delete: :cascade }
|
t.references :user, null: false, foreign_key: {on_delete: :cascade}
|
||||||
t.json :custom_claims, default: {}, null: false
|
t.json :custom_claims, default: {}, null: false
|
||||||
|
|
||||||
t.timestamps
|
t.timestamps
|
||||||
end
|
end
|
||||||
|
|
||||||
add_index :application_user_claims, [:application_id, :user_id], unique: true, name: 'index_app_user_claims_unique'
|
add_index :application_user_claims, [:application_id, :user_id], unique: true, name: "index_app_user_claims_unique"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class CreateActiveStorageTables < ActiveRecord::Migration[7.0]
|
|||||||
t.datetime :created_at, null: false
|
t.datetime :created_at, null: false
|
||||||
end
|
end
|
||||||
|
|
||||||
t.index [ :key ], unique: true
|
t.index [:key], unique: true
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table :active_storage_attachments, id: primary_key_type do |t|
|
create_table :active_storage_attachments, id: primary_key_type do |t|
|
||||||
@@ -33,7 +33,7 @@ class CreateActiveStorageTables < ActiveRecord::Migration[7.0]
|
|||||||
t.datetime :created_at, null: false
|
t.datetime :created_at, null: false
|
||||||
end
|
end
|
||||||
|
|
||||||
t.index [ :record_type, :record_id, :name, :blob_id ], name: :index_active_storage_attachments_uniqueness, unique: true
|
t.index [:record_type, :record_id, :name, :blob_id], name: :index_active_storage_attachments_uniqueness, unique: true
|
||||||
t.foreign_key :active_storage_blobs, column: :blob_id
|
t.foreign_key :active_storage_blobs, column: :blob_id
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -41,17 +41,18 @@ class CreateActiveStorageTables < ActiveRecord::Migration[7.0]
|
|||||||
t.belongs_to :blob, null: false, index: false, type: foreign_key_type
|
t.belongs_to :blob, null: false, index: false, type: foreign_key_type
|
||||||
t.string :variation_digest, null: false
|
t.string :variation_digest, null: false
|
||||||
|
|
||||||
t.index [ :blob_id, :variation_digest ], name: :index_active_storage_variant_records_uniqueness, unique: true
|
t.index [:blob_id, :variation_digest], name: :index_active_storage_variant_records_uniqueness, unique: true
|
||||||
t.foreign_key :active_storage_blobs, column: :blob_id
|
t.foreign_key :active_storage_blobs, column: :blob_id
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def primary_and_foreign_key_types
|
def primary_and_foreign_key_types
|
||||||
config = Rails.configuration.generators
|
config = Rails.configuration.generators
|
||||||
setting = config.options[config.orm][:primary_key_type]
|
setting = config.options[config.orm][:primary_key_type]
|
||||||
primary_key_type = setting || :primary_key
|
primary_key_type = setting || :primary_key
|
||||||
foreign_key_type = setting || :bigint
|
foreign_key_type = setting || :bigint
|
||||||
[ primary_key_type, foreign_key_type ]
|
[primary_key_type, foreign_key_type]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
require "test_helper"
|
require "test_helper"
|
||||||
|
|
||||||
class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
|
class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
|
||||||
driven_by :selenium, using: :headless_chrome, screen_size: [ 1400, 1400 ]
|
driven_by :selenium, using: :headless_chrome, screen_size: [1400, 1400]
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ module Api
|
|||||||
|
|
||||||
# Authentication Tests
|
# Authentication Tests
|
||||||
test "should redirect to login when no session cookie" do
|
test "should redirect to login when no session cookie" do
|
||||||
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
|
||||||
|
|
||||||
assert_response 302
|
assert_response 302
|
||||||
assert_match %r{/signin}, response.location
|
assert_match %r{/signin}, response.location
|
||||||
@@ -23,7 +23,7 @@ module Api
|
|||||||
test "should redirect when user is inactive" do
|
test "should redirect when user is inactive" do
|
||||||
sign_in_as(@inactive_user)
|
sign_in_as(@inactive_user)
|
||||||
|
|
||||||
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
|
||||||
|
|
||||||
assert_response 302
|
assert_response 302
|
||||||
assert_equal "User account is not active", response.headers["x-auth-reason"]
|
assert_equal "User account is not active", response.headers["x-auth-reason"]
|
||||||
@@ -32,7 +32,7 @@ module Api
|
|||||||
test "should return 200 when user is authenticated" do
|
test "should return 200 when user is authenticated" do
|
||||||
sign_in_as(@user)
|
sign_in_as(@user)
|
||||||
|
|
||||||
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
|
||||||
|
|
||||||
assert_response 200
|
assert_response 200
|
||||||
end
|
end
|
||||||
@@ -41,7 +41,7 @@ module Api
|
|||||||
test "should return 200 when matching rule exists" do
|
test "should return 200 when matching rule exists" do
|
||||||
sign_in_as(@user)
|
sign_in_as(@user)
|
||||||
|
|
||||||
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
|
||||||
|
|
||||||
assert_response 200
|
assert_response 200
|
||||||
end
|
end
|
||||||
@@ -49,7 +49,7 @@ module Api
|
|||||||
test "should return 403 when no rule matches (fail-closed security)" do
|
test "should return 403 when no rule matches (fail-closed security)" do
|
||||||
sign_in_as(@user)
|
sign_in_as(@user)
|
||||||
|
|
||||||
get "/api/verify", headers: { "X-Forwarded-Host" => "unknown.example.com" }
|
get "/api/verify", headers: {"X-Forwarded-Host" => "unknown.example.com"}
|
||||||
|
|
||||||
assert_response 403
|
assert_response 403
|
||||||
assert_equal "No authentication rule configured for this domain", response.headers["x-auth-reason"]
|
assert_equal "No authentication rule configured for this domain", response.headers["x-auth-reason"]
|
||||||
@@ -58,7 +58,7 @@ module Api
|
|||||||
test "should return 403 when rule exists but is inactive" do
|
test "should return 403 when rule exists but is inactive" do
|
||||||
sign_in_as(@user)
|
sign_in_as(@user)
|
||||||
|
|
||||||
get "/api/verify", headers: { "X-Forwarded-Host" => "inactive.example.com" }
|
get "/api/verify", headers: {"X-Forwarded-Host" => "inactive.example.com"}
|
||||||
|
|
||||||
assert_response 403
|
assert_response 403
|
||||||
assert_equal "No authentication rule configured for this domain", response.headers["x-auth-reason"]
|
assert_equal "No authentication rule configured for this domain", response.headers["x-auth-reason"]
|
||||||
@@ -68,7 +68,7 @@ module Api
|
|||||||
@rule.allowed_groups << @group
|
@rule.allowed_groups << @group
|
||||||
sign_in_as(@user) # User not in group
|
sign_in_as(@user) # User not in group
|
||||||
|
|
||||||
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
|
||||||
|
|
||||||
assert_response 403
|
assert_response 403
|
||||||
assert_match %r{permission to access this domain}, response.headers["x-auth-reason"]
|
assert_match %r{permission to access this domain}, response.headers["x-auth-reason"]
|
||||||
@@ -79,35 +79,35 @@ module Api
|
|||||||
@user.groups << @group
|
@user.groups << @group
|
||||||
sign_in_as(@user)
|
sign_in_as(@user)
|
||||||
|
|
||||||
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
|
||||||
|
|
||||||
assert_response 200
|
assert_response 200
|
||||||
end
|
end
|
||||||
|
|
||||||
# Domain Pattern Tests
|
# Domain Pattern Tests
|
||||||
test "should match wildcard domains correctly" do
|
test "should match wildcard domains correctly" do
|
||||||
wildcard_rule = Application.create!(name: "Wildcard App", slug: "wildcard-app", app_type: "forward_auth", domain_pattern: "*.example.com", active: true)
|
Application.create!(name: "Wildcard App", slug: "wildcard-app", app_type: "forward_auth", domain_pattern: "*.example.com", active: true)
|
||||||
sign_in_as(@user)
|
sign_in_as(@user)
|
||||||
|
|
||||||
get "/api/verify", headers: { "X-Forwarded-Host" => "app.example.com" }
|
get "/api/verify", headers: {"X-Forwarded-Host" => "app.example.com"}
|
||||||
assert_response 200
|
assert_response 200
|
||||||
|
|
||||||
get "/api/verify", headers: { "X-Forwarded-Host" => "api.example.com" }
|
get "/api/verify", headers: {"X-Forwarded-Host" => "api.example.com"}
|
||||||
assert_response 200
|
assert_response 200
|
||||||
|
|
||||||
get "/api/verify", headers: { "X-Forwarded-Host" => "other.com" }
|
get "/api/verify", headers: {"X-Forwarded-Host" => "other.com"}
|
||||||
assert_response 403 # No rule configured - fail-closed
|
assert_response 403 # No rule configured - fail-closed
|
||||||
assert_equal "No authentication rule configured for this domain", response.headers["x-auth-reason"]
|
assert_equal "No authentication rule configured for this domain", response.headers["x-auth-reason"]
|
||||||
end
|
end
|
||||||
|
|
||||||
test "should match exact domains correctly" do
|
test "should match exact domains correctly" do
|
||||||
exact_rule = Application.create!(name: "Exact App", slug: "exact-app", app_type: "forward_auth", domain_pattern: "api.example.com", active: true)
|
Application.create!(name: "Exact App", slug: "exact-app", app_type: "forward_auth", domain_pattern: "api.example.com", active: true)
|
||||||
sign_in_as(@user)
|
sign_in_as(@user)
|
||||||
|
|
||||||
get "/api/verify", headers: { "X-Forwarded-Host" => "api.example.com" }
|
get "/api/verify", headers: {"X-Forwarded-Host" => "api.example.com"}
|
||||||
assert_response 200
|
assert_response 200
|
||||||
|
|
||||||
get "/api/verify", headers: { "X-Forwarded-Host" => "app.api.example.com" }
|
get "/api/verify", headers: {"X-Forwarded-Host" => "app.api.example.com"}
|
||||||
assert_response 403 # No rule configured - fail-closed
|
assert_response 403 # No rule configured - fail-closed
|
||||||
assert_equal "No authentication rule configured for this domain", response.headers["x-auth-reason"]
|
assert_equal "No authentication rule configured for this domain", response.headers["x-auth-reason"]
|
||||||
end
|
end
|
||||||
@@ -116,7 +116,7 @@ module Api
|
|||||||
test "should return default headers when rule has no custom config" do
|
test "should return default headers when rule has no custom config" do
|
||||||
sign_in_as(@user)
|
sign_in_as(@user)
|
||||||
|
|
||||||
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
|
||||||
|
|
||||||
assert_response 200
|
assert_response 200
|
||||||
assert_equal @user.email_address, response.headers["x-remote-user"]
|
assert_equal @user.email_address, response.headers["x-remote-user"]
|
||||||
@@ -126,7 +126,7 @@ module Api
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "should return custom headers when configured" do
|
test "should return custom headers when configured" do
|
||||||
custom_rule = Application.create!(
|
Application.create!(
|
||||||
name: "Custom App",
|
name: "Custom App",
|
||||||
slug: "custom-app",
|
slug: "custom-app",
|
||||||
app_type: "forward_auth",
|
app_type: "forward_auth",
|
||||||
@@ -140,7 +140,7 @@ module Api
|
|||||||
)
|
)
|
||||||
sign_in_as(@user)
|
sign_in_as(@user)
|
||||||
|
|
||||||
get "/api/verify", headers: { "X-Forwarded-Host" => "custom.example.com" }
|
get "/api/verify", headers: {"X-Forwarded-Host" => "custom.example.com"}
|
||||||
|
|
||||||
assert_response 200
|
assert_response 200
|
||||||
assert_equal @user.email_address, response.headers["x-webauth-user"]
|
assert_equal @user.email_address, response.headers["x-webauth-user"]
|
||||||
@@ -151,17 +151,17 @@ module Api
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "should return no headers when all headers disabled" do
|
test "should return no headers when all headers disabled" do
|
||||||
no_headers_rule = Application.create!(
|
Application.create!(
|
||||||
name: "No Headers App",
|
name: "No Headers App",
|
||||||
slug: "no-headers-app",
|
slug: "no-headers-app",
|
||||||
app_type: "forward_auth",
|
app_type: "forward_auth",
|
||||||
domain_pattern: "noheaders.example.com",
|
domain_pattern: "noheaders.example.com",
|
||||||
active: true,
|
active: true,
|
||||||
headers_config: { user: "", email: "", name: "", groups: "", admin: "" }
|
headers_config: {user: "", email: "", name: "", groups: "", admin: ""}
|
||||||
)
|
)
|
||||||
sign_in_as(@user)
|
sign_in_as(@user)
|
||||||
|
|
||||||
get "/api/verify", headers: { "X-Forwarded-Host" => "noheaders.example.com" }
|
get "/api/verify", headers: {"X-Forwarded-Host" => "noheaders.example.com"}
|
||||||
|
|
||||||
assert_response 200
|
assert_response 200
|
||||||
# Check that auth-specific headers are not present (exclude Rails security headers)
|
# Check that auth-specific headers are not present (exclude Rails security headers)
|
||||||
@@ -173,7 +173,7 @@ module Api
|
|||||||
@user.groups << @group
|
@user.groups << @group
|
||||||
sign_in_as(@user)
|
sign_in_as(@user)
|
||||||
|
|
||||||
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
|
||||||
|
|
||||||
assert_response 200
|
assert_response 200
|
||||||
groups_header = response.headers["x-remote-groups"]
|
groups_header = response.headers["x-remote-groups"]
|
||||||
@@ -186,7 +186,7 @@ module Api
|
|||||||
@user.groups.clear # Remove fixture groups
|
@user.groups.clear # Remove fixture groups
|
||||||
sign_in_as(@user)
|
sign_in_as(@user)
|
||||||
|
|
||||||
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
|
||||||
|
|
||||||
assert_response 200
|
assert_response 200
|
||||||
assert_nil response.headers["x-remote-groups"]
|
assert_nil response.headers["x-remote-groups"]
|
||||||
@@ -195,7 +195,7 @@ module Api
|
|||||||
test "should include admin header correctly" do
|
test "should include admin header correctly" do
|
||||||
sign_in_as(@admin_user) # Assuming users(:two) is admin
|
sign_in_as(@admin_user) # Assuming users(:two) is admin
|
||||||
|
|
||||||
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
|
||||||
|
|
||||||
assert_response 200
|
assert_response 200
|
||||||
assert_equal "true", response.headers["x-remote-admin"]
|
assert_equal "true", response.headers["x-remote-admin"]
|
||||||
@@ -207,7 +207,7 @@ module Api
|
|||||||
@user.groups << group2
|
@user.groups << group2
|
||||||
sign_in_as(@user)
|
sign_in_as(@user)
|
||||||
|
|
||||||
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
|
||||||
|
|
||||||
assert_response 200
|
assert_response 200
|
||||||
groups_header = response.headers["x-remote-groups"]
|
groups_header = response.headers["x-remote-groups"]
|
||||||
@@ -219,7 +219,7 @@ module Api
|
|||||||
test "should fall back to Host header when X-Forwarded-Host is missing" do
|
test "should fall back to Host header when X-Forwarded-Host is missing" do
|
||||||
sign_in_as(@user)
|
sign_in_as(@user)
|
||||||
|
|
||||||
get "/api/verify", headers: { "Host" => "test.example.com" }
|
get "/api/verify", headers: {"Host" => "test.example.com"}
|
||||||
|
|
||||||
assert_response 200
|
assert_response 200
|
||||||
end
|
end
|
||||||
@@ -239,7 +239,7 @@ module Api
|
|||||||
long_domain = "a" * 250 + ".example.com"
|
long_domain = "a" * 250 + ".example.com"
|
||||||
sign_in_as(@user)
|
sign_in_as(@user)
|
||||||
|
|
||||||
get "/api/verify", headers: { "X-Forwarded-Host" => long_domain }
|
get "/api/verify", headers: {"X-Forwarded-Host" => long_domain}
|
||||||
|
|
||||||
assert_response 403 # No rule configured - fail-closed
|
assert_response 403 # No rule configured - fail-closed
|
||||||
assert_equal "No authentication rule configured for this domain", response.headers["x-auth-reason"]
|
assert_equal "No authentication rule configured for this domain", response.headers["x-auth-reason"]
|
||||||
@@ -248,7 +248,7 @@ module Api
|
|||||||
test "should handle case insensitive domain matching" do
|
test "should handle case insensitive domain matching" do
|
||||||
sign_in_as(@user)
|
sign_in_as(@user)
|
||||||
|
|
||||||
get "/api/verify", headers: { "X-Forwarded-Host" => "TEST.Example.COM" }
|
get "/api/verify", headers: {"X-Forwarded-Host" => "TEST.Example.COM"}
|
||||||
|
|
||||||
assert_response 200
|
assert_response 200
|
||||||
end
|
end
|
||||||
@@ -262,7 +262,7 @@ module Api
|
|||||||
get "/api/verify", headers: {
|
get "/api/verify", headers: {
|
||||||
"X-Forwarded-Host" => "test.example.com",
|
"X-Forwarded-Host" => "test.example.com",
|
||||||
"X-Forwarded-Uri" => "/admin"
|
"X-Forwarded-Uri" => "/admin"
|
||||||
}, params: { rd: evil_url }
|
}, params: {rd: evil_url}
|
||||||
|
|
||||||
assert_response 302
|
assert_response 302
|
||||||
assert_match %r{/signin}, response.location
|
assert_match %r{/signin}, response.location
|
||||||
@@ -292,8 +292,8 @@ module Api
|
|||||||
# This should be allowed (domain has ForwardAuthRule)
|
# This should be allowed (domain has ForwardAuthRule)
|
||||||
allowed_url = "https://test.example.com/dashboard"
|
allowed_url = "https://test.example.com/dashboard"
|
||||||
|
|
||||||
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" },
|
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"},
|
||||||
params: { rd: allowed_url }
|
params: {rd: allowed_url}
|
||||||
|
|
||||||
assert_response 302
|
assert_response 302
|
||||||
assert_match allowed_url, response.location
|
assert_match allowed_url, response.location
|
||||||
@@ -305,8 +305,8 @@ module Api
|
|||||||
# This should be rejected (no ForwardAuthRule for evil-site.com)
|
# This should be rejected (no ForwardAuthRule for evil-site.com)
|
||||||
evil_url = "https://evil-site.com/steal-credentials"
|
evil_url = "https://evil-site.com/steal-credentials"
|
||||||
|
|
||||||
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" },
|
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"},
|
||||||
params: { rd: evil_url }
|
params: {rd: evil_url}
|
||||||
|
|
||||||
assert_response 302
|
assert_response 302
|
||||||
# Should redirect to login page or default URL, NOT to evil_url
|
# Should redirect to login page or default URL, NOT to evil_url
|
||||||
@@ -320,8 +320,8 @@ module Api
|
|||||||
# This should be rejected (HTTP not HTTPS)
|
# This should be rejected (HTTP not HTTPS)
|
||||||
http_url = "http://test.example.com/dashboard"
|
http_url = "http://test.example.com/dashboard"
|
||||||
|
|
||||||
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" },
|
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"},
|
||||||
params: { rd: http_url }
|
params: {rd: http_url}
|
||||||
|
|
||||||
assert_response 302
|
assert_response 302
|
||||||
# Should redirect to login page or default URL, NOT to HTTP URL
|
# Should redirect to login page or default URL, NOT to HTTP URL
|
||||||
@@ -340,8 +340,8 @@ module Api
|
|||||||
]
|
]
|
||||||
|
|
||||||
dangerous_schemes.each do |dangerous_url|
|
dangerous_schemes.each do |dangerous_url|
|
||||||
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" },
|
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"},
|
||||||
params: { rd: dangerous_url }
|
params: {rd: dangerous_url}
|
||||||
|
|
||||||
assert_response 302, "Should reject dangerous URL: #{dangerous_url}"
|
assert_response 302, "Should reject dangerous URL: #{dangerous_url}"
|
||||||
# Should redirect to login page or default URL, NOT to dangerous URL
|
# Should redirect to login page or default URL, NOT to dangerous URL
|
||||||
@@ -355,7 +355,7 @@ module Api
|
|||||||
sign_in_as(@user)
|
sign_in_as(@user)
|
||||||
|
|
||||||
# Authenticated GET requests should return 200
|
# Authenticated GET requests should return 200
|
||||||
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
|
||||||
assert_response 200
|
assert_response 200
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -461,11 +461,11 @@ module Api
|
|||||||
sign_in_as(@user)
|
sign_in_as(@user)
|
||||||
|
|
||||||
# First request
|
# First request
|
||||||
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
|
||||||
assert_response 200
|
assert_response 200
|
||||||
|
|
||||||
# Second request with same session
|
# Second request with same session
|
||||||
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
|
||||||
assert_response 200
|
assert_response 200
|
||||||
|
|
||||||
# Should maintain user identity across requests
|
# Should maintain user identity across requests
|
||||||
@@ -481,8 +481,8 @@ module Api
|
|||||||
|
|
||||||
5.times do |i|
|
5.times do |i|
|
||||||
threads << Thread.new do
|
threads << Thread.new do
|
||||||
get "/api/verify", headers: { "X-Forwarded-Host" => "app#{i}.example.com" }
|
get "/api/verify", headers: {"X-Forwarded-Host" => "app#{i}.example.com"}
|
||||||
results << { status: response.status }
|
results << {status: response.status}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -524,7 +524,7 @@ module Api
|
|||||||
request_count = 10
|
request_count = 10
|
||||||
|
|
||||||
request_count.times do |i|
|
request_count.times do |i|
|
||||||
get "/api/verify", headers: { "X-Forwarded-Host" => "app#{i}.example.com" }
|
get "/api/verify", headers: {"X-Forwarded-Host" => "app#{i}.example.com"}
|
||||||
assert_response 403 # No rules configured for these domains
|
assert_response 403 # No rules configured for these domains
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -10,10 +10,14 @@ class AuthenticationTest < ActiveSupport::TestCase
|
|||||||
return nil if host.blank? || host.match?(/^(localhost|127\.0\.0\.1|::1)$/)
|
return nil if host.blank? || host.match?(/^(localhost|127\.0\.0\.1|::1)$/)
|
||||||
|
|
||||||
# Strip port number for domain parsing
|
# Strip port number for domain parsing
|
||||||
host_without_port = host.split(':').first
|
host_without_port = host.split(":").first
|
||||||
|
|
||||||
# Check if it's an IP address (IPv4 or IPv6) - if so, don't set domain cookie
|
# Check if it's an IP address (IPv4 or IPv6) - if so, don't set domain cookie
|
||||||
return nil if IPAddr.new(host_without_port) rescue false
|
begin
|
||||||
|
return nil if IPAddr.new(host_without_port)
|
||||||
|
rescue
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
# Use Public Suffix List for accurate domain parsing
|
# Use Public Suffix List for accurate domain parsing
|
||||||
domain = PublicSuffix.parse(host_without_port)
|
domain = PublicSuffix.parse(host_without_port)
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ class InputValidationTest < ActionDispatch::IntegrationTest
|
|||||||
user = User.create!(email_address: "xss_test@example.com", password: "password123", name: xss_payload)
|
user = User.create!(email_address: "xss_test@example.com", password: "password123", name: xss_payload)
|
||||||
|
|
||||||
# Sign in
|
# Sign in
|
||||||
post signin_path, params: { email_address: "xss_test@example.com", password: "password123" }
|
post signin_path, params: {email_address: "xss_test@example.com", password: "password123"}
|
||||||
assert_response :redirect
|
assert_response :redirect
|
||||||
|
|
||||||
# Get a page that displays user name
|
# Get a page that displays user name
|
||||||
@@ -59,7 +59,7 @@ class InputValidationTest < ActionDispatch::IntegrationTest
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Sign in
|
# Sign in
|
||||||
post signin_path, params: { email_address: "oauth_tamper_test@example.com", password: "password123" }
|
post signin_path, params: {email_address: "oauth_tamper_test@example.com", password: "password123"}
|
||||||
assert_response :redirect
|
assert_response :redirect
|
||||||
|
|
||||||
# Try to tamper with OAuth authorization parameters
|
# Try to tamper with OAuth authorization parameters
|
||||||
@@ -112,7 +112,7 @@ class InputValidationTest < ActionDispatch::IntegrationTest
|
|||||||
test "JSON input validation prevents malicious payloads" do
|
test "JSON input validation prevents malicious payloads" do
|
||||||
# Try to send malformed JSON
|
# Try to send malformed JSON
|
||||||
post "/oauth/token", params: '{"grant_type":"authorization_code",}'.to_json,
|
post "/oauth/token", params: '{"grant_type":"authorization_code",}'.to_json,
|
||||||
headers: { "CONTENT_TYPE" => "application/json" }
|
headers: {"CONTENT_TYPE" => "application/json"}
|
||||||
|
|
||||||
# Should handle malformed JSON gracefully
|
# Should handle malformed JSON gracefully
|
||||||
assert_includes [400, 422], response.status
|
assert_includes [400, 422], response.status
|
||||||
@@ -124,9 +124,9 @@ class InputValidationTest < ActionDispatch::IntegrationTest
|
|||||||
grant_type: "authorization_code",
|
grant_type: "authorization_code",
|
||||||
code: "test_code",
|
code: "test_code",
|
||||||
redirect_uri: "http://localhost:4000/callback",
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
nested: { __proto__: "tampered", constructor: { prototype: "tampered" } }
|
nested: {__proto__: "tampered", constructor: {prototype: "tampered"}}
|
||||||
}.to_json,
|
}.to_json,
|
||||||
headers: { "CONTENT_TYPE" => "application/json" }
|
headers: {"CONTENT_TYPE" => "application/json"}
|
||||||
|
|
||||||
# Should sanitize or reject prototype pollution attempts
|
# Should sanitize or reject prototype pollution attempts
|
||||||
# The request should be handled (either accept or reject, not crash)
|
# The request should be handled (either accept or reject, not crash)
|
||||||
@@ -165,7 +165,7 @@ class InputValidationTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
malicious_paths.each do |malicious_path|
|
malicious_paths.each do |malicious_path|
|
||||||
# Try to access files with path traversal
|
# Try to access files with path traversal
|
||||||
get root_path, params: { file: malicious_path }
|
get root_path, params: {file: malicious_path}
|
||||||
|
|
||||||
# Should prevent access to files outside public directory
|
# Should prevent access to files outside public directory
|
||||||
assert_response :redirect, "Should reject path traversal attempt"
|
assert_response :redirect, "Should reject path traversal attempt"
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ class InvitationsControllerTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
test "should destroy existing sessions when accepting invitation" do
|
test "should destroy existing sessions when accepting invitation" do
|
||||||
# Create an existing session for the user
|
# Create an existing session for the user
|
||||||
existing_session = @user.sessions.create!
|
@user.sessions.create!
|
||||||
|
|
||||||
put invitation_path(@token), params: {
|
put invitation_path(@token), params: {
|
||||||
password: "newpassword123",
|
password: "newpassword123",
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
test "prevents authorization code reuse - sequential attempts" do
|
test "prevents authorization code reuse - sequential attempts" do
|
||||||
# Create consent
|
# Create consent
|
||||||
consent = OidcUserConsent.create!(
|
OidcUserConsent.create!(
|
||||||
user: @user,
|
user: @user,
|
||||||
application: @application,
|
application: @application,
|
||||||
scopes_granted: "openid profile",
|
scopes_granted: "openid profile",
|
||||||
@@ -81,7 +81,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
test "revokes existing tokens when authorization code is reused" do
|
test "revokes existing tokens when authorization code is reused" do
|
||||||
# Create consent
|
# Create consent
|
||||||
consent = OidcUserConsent.create!(
|
OidcUserConsent.create!(
|
||||||
user: @user,
|
user: @user,
|
||||||
application: @application,
|
application: @application,
|
||||||
scopes_granted: "openid profile",
|
scopes_granted: "openid profile",
|
||||||
@@ -135,7 +135,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
test "rejects already used authorization code" do
|
test "rejects already used authorization code" do
|
||||||
# Create consent
|
# Create consent
|
||||||
consent = OidcUserConsent.create!(
|
OidcUserConsent.create!(
|
||||||
user: @user,
|
user: @user,
|
||||||
application: @application,
|
application: @application,
|
||||||
scopes_granted: "openid profile",
|
scopes_granted: "openid profile",
|
||||||
@@ -171,7 +171,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
test "rejects expired authorization code" do
|
test "rejects expired authorization code" do
|
||||||
# Create consent
|
# Create consent
|
||||||
consent = OidcUserConsent.create!(
|
OidcUserConsent.create!(
|
||||||
user: @user,
|
user: @user,
|
||||||
application: @application,
|
application: @application,
|
||||||
scopes_granted: "openid profile",
|
scopes_granted: "openid profile",
|
||||||
@@ -206,7 +206,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
test "rejects authorization code with mismatched redirect_uri" do
|
test "rejects authorization code with mismatched redirect_uri" do
|
||||||
# Create consent
|
# Create consent
|
||||||
consent = OidcUserConsent.create!(
|
OidcUserConsent.create!(
|
||||||
user: @user,
|
user: @user,
|
||||||
application: @application,
|
application: @application,
|
||||||
scopes_granted: "openid profile",
|
scopes_granted: "openid profile",
|
||||||
@@ -256,7 +256,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
test "rejects authorization code for different application" do
|
test "rejects authorization code for different application" do
|
||||||
# Create consent for the first application
|
# Create consent for the first application
|
||||||
consent = OidcUserConsent.create!(
|
OidcUserConsent.create!(
|
||||||
user: @user,
|
user: @user,
|
||||||
application: @application,
|
application: @application,
|
||||||
scopes_granted: "openid profile",
|
scopes_granted: "openid profile",
|
||||||
@@ -308,7 +308,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
test "rejects invalid client_id in Basic auth" do
|
test "rejects invalid client_id in Basic auth" do
|
||||||
# Create consent
|
# Create consent
|
||||||
consent = OidcUserConsent.create!(
|
OidcUserConsent.create!(
|
||||||
user: @user,
|
user: @user,
|
||||||
application: @application,
|
application: @application,
|
||||||
scopes_granted: "openid profile",
|
scopes_granted: "openid profile",
|
||||||
@@ -341,7 +341,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
test "rejects invalid client_secret in Basic auth" do
|
test "rejects invalid client_secret in Basic auth" do
|
||||||
# Create consent
|
# Create consent
|
||||||
consent = OidcUserConsent.create!(
|
OidcUserConsent.create!(
|
||||||
user: @user,
|
user: @user,
|
||||||
application: @application,
|
application: @application,
|
||||||
scopes_granted: "openid profile",
|
scopes_granted: "openid profile",
|
||||||
@@ -374,7 +374,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
test "accepts client credentials in POST body" do
|
test "accepts client credentials in POST body" do
|
||||||
# Create consent
|
# Create consent
|
||||||
consent = OidcUserConsent.create!(
|
OidcUserConsent.create!(
|
||||||
user: @user,
|
user: @user,
|
||||||
application: @application,
|
application: @application,
|
||||||
scopes_granted: "openid profile",
|
scopes_granted: "openid profile",
|
||||||
@@ -408,7 +408,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
test "rejects request with no client authentication" do
|
test "rejects request with no client authentication" do
|
||||||
# Create consent
|
# Create consent
|
||||||
consent = OidcUserConsent.create!(
|
OidcUserConsent.create!(
|
||||||
user: @user,
|
user: @user,
|
||||||
application: @application,
|
application: @application,
|
||||||
scopes_granted: "openid profile",
|
scopes_granted: "openid profile",
|
||||||
@@ -474,7 +474,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
test "client authentication uses constant-time comparison" do
|
test "client authentication uses constant-time comparison" do
|
||||||
# Create consent
|
# Create consent
|
||||||
consent = OidcUserConsent.create!(
|
OidcUserConsent.create!(
|
||||||
user: @user,
|
user: @user,
|
||||||
application: @application,
|
application: @application,
|
||||||
scopes_granted: "openid profile",
|
scopes_granted: "openid profile",
|
||||||
@@ -546,7 +546,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Sign in first
|
# Sign in first
|
||||||
post signin_path, params: { email_address: "security_test@example.com", password: "password123" }
|
post signin_path, params: {email_address: "security_test@example.com", password: "password123"}
|
||||||
|
|
||||||
# Test authorization with state parameter
|
# Test authorization with state parameter
|
||||||
get "/oauth/authorize", params: {
|
get "/oauth/authorize", params: {
|
||||||
@@ -573,7 +573,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Sign in first
|
# Sign in first
|
||||||
post signin_path, params: { email_address: "security_test@example.com", password: "password123" }
|
post signin_path, params: {email_address: "security_test@example.com", password: "password123"}
|
||||||
|
|
||||||
# Test authorization without state parameter
|
# Test authorization without state parameter
|
||||||
get "/oauth/authorize", params: {
|
get "/oauth/authorize", params: {
|
||||||
@@ -593,7 +593,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
test "nonce parameter is included in ID token" do
|
test "nonce parameter is included in ID token" do
|
||||||
# Create consent
|
# Create consent
|
||||||
consent = OidcUserConsent.create!(
|
OidcUserConsent.create!(
|
||||||
user: @user,
|
user: @user,
|
||||||
application: @application,
|
application: @application,
|
||||||
scopes_granted: "openid profile",
|
scopes_granted: "openid profile",
|
||||||
@@ -637,7 +637,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
test "access tokens are not exposed in referer header" do
|
test "access tokens are not exposed in referer header" do
|
||||||
# Create consent and authorization code
|
# Create consent and authorization code
|
||||||
consent = OidcUserConsent.create!(
|
OidcUserConsent.create!(
|
||||||
user: @user,
|
user: @user,
|
||||||
application: @application,
|
application: @application,
|
||||||
scopes_granted: "openid profile",
|
scopes_granted: "openid profile",
|
||||||
@@ -664,7 +664,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
assert_response :success
|
assert_response :success
|
||||||
response_body = JSON.parse(@response.body)
|
response_body = JSON.parse(@response.body)
|
||||||
access_token = response_body["access_token"]
|
response_body["access_token"]
|
||||||
|
|
||||||
# Verify token is not in response headers (especially Referer)
|
# Verify token is not in response headers (especially Referer)
|
||||||
assert_nil response.headers["Referer"], "Access token should not leak in Referer header"
|
assert_nil response.headers["Referer"], "Access token should not leak in Referer header"
|
||||||
@@ -677,7 +677,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
test "PKCE code_verifier is required when code_challenge was provided" do
|
test "PKCE code_verifier is required when code_challenge was provided" do
|
||||||
# Create consent
|
# Create consent
|
||||||
consent = OidcUserConsent.create!(
|
OidcUserConsent.create!(
|
||||||
user: @user,
|
user: @user,
|
||||||
application: @application,
|
application: @application,
|
||||||
scopes_granted: "openid profile",
|
scopes_granted: "openid profile",
|
||||||
@@ -716,7 +716,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
test "PKCE with S256 method validates correctly" do
|
test "PKCE with S256 method validates correctly" do
|
||||||
# Create consent
|
# Create consent
|
||||||
consent = OidcUserConsent.create!(
|
OidcUserConsent.create!(
|
||||||
user: @user,
|
user: @user,
|
||||||
application: @application,
|
application: @application,
|
||||||
scopes_granted: "openid profile",
|
scopes_granted: "openid profile",
|
||||||
@@ -755,7 +755,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
test "PKCE rejects invalid code_verifier" do
|
test "PKCE rejects invalid code_verifier" do
|
||||||
# Create consent
|
# Create consent
|
||||||
consent = OidcUserConsent.create!(
|
OidcUserConsent.create!(
|
||||||
user: @user,
|
user: @user,
|
||||||
application: @application,
|
application: @application,
|
||||||
scopes_granted: "openid profile",
|
scopes_granted: "openid profile",
|
||||||
@@ -798,7 +798,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
test "refresh token rotation is enforced" do
|
test "refresh token rotation is enforced" do
|
||||||
# Create consent for the refresh token endpoint
|
# Create consent for the refresh token endpoint
|
||||||
consent = OidcUserConsent.create!(
|
OidcUserConsent.create!(
|
||||||
user: @user,
|
user: @user,
|
||||||
application: @application,
|
application: @application,
|
||||||
scopes_granted: "openid profile",
|
scopes_granted: "openid profile",
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "authorization endpoint accepts PKCE parameters (S256)" do
|
test "authorization endpoint accepts PKCE parameters (S256)" do
|
||||||
code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
|
||||||
code_challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
|
code_challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
|
||||||
|
|
||||||
auth_params = {
|
auth_params = {
|
||||||
@@ -56,7 +55,7 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
# Should show consent page (user is already authenticated)
|
# Should show consent page (user is already authenticated)
|
||||||
assert_response :success
|
assert_response :success
|
||||||
assert_match /consent/, @response.body.downcase
|
assert_match(/consent/, @response.body.downcase)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "authorization endpoint accepts PKCE parameters (plain)" do
|
test "authorization endpoint accepts PKCE parameters (plain)" do
|
||||||
@@ -77,7 +76,7 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
# Should show consent page (user is already authenticated)
|
# Should show consent page (user is already authenticated)
|
||||||
assert_response :success
|
assert_response :success
|
||||||
assert_match /consent/, @response.body.downcase
|
assert_match(/consent/, @response.body.downcase)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "authorization endpoint rejects invalid code_challenge_method" do
|
test "authorization endpoint rejects invalid code_challenge_method" do
|
||||||
@@ -478,7 +477,7 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
|
|||||||
assert_response :bad_request
|
assert_response :bad_request
|
||||||
error = JSON.parse(@response.body)
|
error = JSON.parse(@response.body)
|
||||||
assert_equal "invalid_request", error["error"]
|
assert_equal "invalid_request", error["error"]
|
||||||
assert_match /PKCE is required for public clients/, error["error_description"]
|
assert_match(/PKCE is required for public clients/, error["error_description"])
|
||||||
|
|
||||||
# Cleanup
|
# Cleanup
|
||||||
OidcRefreshToken.where(application: public_app).delete_all
|
OidcRefreshToken.where(application: public_app).delete_all
|
||||||
@@ -525,7 +524,7 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
|
|||||||
assert_response :bad_request
|
assert_response :bad_request
|
||||||
error = JSON.parse(@response.body)
|
error = JSON.parse(@response.body)
|
||||||
assert_equal "invalid_request", error["error"]
|
assert_equal "invalid_request", error["error"]
|
||||||
assert_match /PKCE is required/, error["error_description"]
|
assert_match(/PKCE is required/, error["error_description"])
|
||||||
end
|
end
|
||||||
|
|
||||||
# ====================
|
# ====================
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ class PasswordsControllerTest < ActionDispatch::IntegrationTest
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "create" do
|
test "create" do
|
||||||
post passwords_path, params: { email_address: @user.email_address }
|
post passwords_path, params: {email_address: @user.email_address}
|
||||||
assert_enqueued_email_with PasswordsMailer, :reset, args: [ @user ]
|
assert_enqueued_email_with PasswordsMailer, :reset, args: [@user]
|
||||||
assert_redirected_to signin_path
|
assert_redirected_to signin_path
|
||||||
|
|
||||||
follow_redirect!
|
follow_redirect!
|
||||||
@@ -18,7 +18,7 @@ class PasswordsControllerTest < ActionDispatch::IntegrationTest
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "create for an unknown user redirects but sends no mail" do
|
test "create for an unknown user redirects but sends no mail" do
|
||||||
post passwords_path, params: { email_address: "missing-user@example.com" }
|
post passwords_path, params: {email_address: "missing-user@example.com"}
|
||||||
assert_enqueued_emails 0
|
assert_enqueued_emails 0
|
||||||
assert_redirected_to signin_path
|
assert_redirected_to signin_path
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ class PasswordsControllerTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
test "update" do
|
test "update" do
|
||||||
assert_changes -> { @user.reload.password_digest } do
|
assert_changes -> { @user.reload.password_digest } do
|
||||||
put password_path(@user.generate_token_for(:password_reset)), params: { password: "newpassword", password_confirmation: "newpassword" }
|
put password_path(@user.generate_token_for(:password_reset)), params: {password: "newpassword", password_confirmation: "newpassword"}
|
||||||
assert_redirected_to signin_path
|
assert_redirected_to signin_path
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ class PasswordsControllerTest < ActionDispatch::IntegrationTest
|
|||||||
test "update with non matching passwords" do
|
test "update with non matching passwords" do
|
||||||
token = @user.password_reset_token
|
token = @user.password_reset_token
|
||||||
assert_no_changes -> { @user.reload.password_digest } do
|
assert_no_changes -> { @user.reload.password_digest } do
|
||||||
put password_path(token), params: { password: "no", password_confirmation: "match" }
|
put password_path(token), params: {password: "no", password_confirmation: "match"}
|
||||||
assert_redirected_to edit_password_path(token)
|
assert_redirected_to edit_password_path(token)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -61,6 +61,7 @@ class PasswordsControllerTest < ActionDispatch::IntegrationTest
|
|||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def assert_notice(text)
|
def assert_notice(text)
|
||||||
assert_select "div", /#{text}/
|
assert_select "div", /#{text}/
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -9,14 +9,14 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "create with valid credentials" do
|
test "create with valid credentials" do
|
||||||
post session_path, params: { email_address: @user.email_address, password: "password" }
|
post session_path, params: {email_address: @user.email_address, password: "password"}
|
||||||
|
|
||||||
assert_redirected_to root_path
|
assert_redirected_to root_path
|
||||||
assert cookies[:session_id]
|
assert cookies[:session_id]
|
||||||
end
|
end
|
||||||
|
|
||||||
test "create with invalid credentials" do
|
test "create with invalid credentials" do
|
||||||
post session_path, params: { email_address: @user.email_address, password: "wrong" }
|
post session_path, params: {email_address: @user.email_address, password: "wrong"}
|
||||||
|
|
||||||
assert_redirected_to signin_path
|
assert_redirected_to signin_path
|
||||||
assert_nil cookies[:session_id]
|
assert_nil cookies[:session_id]
|
||||||
|
|||||||
@@ -14,11 +14,11 @@ class TotpSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
valid_code = totp.now
|
valid_code = totp.now
|
||||||
|
|
||||||
# Set up pending TOTP session
|
# Set up pending TOTP session
|
||||||
post signin_path, params: { email_address: "totp_replay_test@example.com", password: "password123" }
|
post signin_path, params: {email_address: "totp_replay_test@example.com", password: "password123"}
|
||||||
assert_redirected_to totp_verification_path
|
assert_redirected_to totp_verification_path
|
||||||
|
|
||||||
# First use of the code should succeed
|
# First use of the code should succeed
|
||||||
post totp_verification_path, params: { code: valid_code }
|
post totp_verification_path, params: {code: valid_code}
|
||||||
assert_response :redirect
|
assert_response :redirect
|
||||||
assert_redirected_to root_path
|
assert_redirected_to root_path
|
||||||
|
|
||||||
@@ -50,12 +50,12 @@ class TotpSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
original_codes = user.reload.backup_codes
|
original_codes = user.reload.backup_codes
|
||||||
|
|
||||||
# Set up pending TOTP session
|
# Set up pending TOTP session
|
||||||
post signin_path, params: { email_address: "backup_code_test@example.com", password: "password123" }
|
post signin_path, params: {email_address: "backup_code_test@example.com", password: "password123"}
|
||||||
assert_redirected_to totp_verification_path
|
assert_redirected_to totp_verification_path
|
||||||
|
|
||||||
# Use a backup code
|
# Use a backup code
|
||||||
backup_code = backup_codes.first
|
backup_code = backup_codes.first
|
||||||
post totp_verification_path, params: { code: backup_code }
|
post totp_verification_path, params: {code: backup_code}
|
||||||
|
|
||||||
# Should successfully sign in
|
# Should successfully sign in
|
||||||
assert_response :redirect
|
assert_response :redirect
|
||||||
@@ -70,11 +70,11 @@ class TotpSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
assert_response :redirect
|
assert_response :redirect
|
||||||
|
|
||||||
# Sign in again
|
# Sign in again
|
||||||
post signin_path, params: { email_address: "backup_code_test@example.com", password: "password123" }
|
post signin_path, params: {email_address: "backup_code_test@example.com", password: "password123"}
|
||||||
assert_redirected_to totp_verification_path
|
assert_redirected_to totp_verification_path
|
||||||
|
|
||||||
# Try the same backup code
|
# Try the same backup code
|
||||||
post totp_verification_path, params: { code: backup_code }
|
post totp_verification_path, params: {code: backup_code}
|
||||||
|
|
||||||
# Should fail - backup code already used
|
# Should fail - backup code already used
|
||||||
assert_response :redirect
|
assert_response :redirect
|
||||||
@@ -91,13 +91,13 @@ class TotpSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
# Generate backup codes
|
# Generate backup codes
|
||||||
user.totp_secret = ROTP::Base32.random
|
user.totp_secret = ROTP::Base32.random
|
||||||
backup_codes = user.send(:generate_backup_codes) # Call private method
|
user.send(:generate_backup_codes) # Call private method
|
||||||
user.save!
|
user.save!
|
||||||
|
|
||||||
# Check that stored codes are BCrypt hashes (start with $2a$)
|
# Check that stored codes are BCrypt hashes (start with $2a$)
|
||||||
# backup_codes is already an Array (JSON column), no need to parse
|
# backup_codes is already an Array (JSON column), no need to parse
|
||||||
user.backup_codes.each do |code|
|
user.backup_codes.each do |code|
|
||||||
assert_match /^\$2[aby]\$/, code, "Backup codes should be BCrypt hashed"
|
assert_match(/^\$2[aby]\$/, code, "Backup codes should be BCrypt hashed")
|
||||||
end
|
end
|
||||||
|
|
||||||
user.destroy
|
user.destroy
|
||||||
@@ -116,7 +116,7 @@ class TotpSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
user.save!
|
user.save!
|
||||||
|
|
||||||
# Set up pending TOTP session
|
# Set up pending TOTP session
|
||||||
post signin_path, params: { email_address: "totp_time_test@example.com", password: "password123" }
|
post signin_path, params: {email_address: "totp_time_test@example.com", password: "password123"}
|
||||||
assert_redirected_to totp_verification_path
|
assert_redirected_to totp_verification_path
|
||||||
|
|
||||||
# Generate a TOTP code for a time far in the future (outside valid window)
|
# Generate a TOTP code for a time far in the future (outside valid window)
|
||||||
@@ -124,7 +124,7 @@ class TotpSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
future_code = totp.at(Time.now.to_i + 300) # 5 minutes in the future
|
future_code = totp.at(Time.now.to_i + 300) # 5 minutes in the future
|
||||||
|
|
||||||
# Try to use the future code
|
# Try to use the future code
|
||||||
post totp_verification_path, params: { code: future_code }
|
post totp_verification_path, params: {code: future_code}
|
||||||
|
|
||||||
# Should fail - code is outside valid time window
|
# Should fail - code is outside valid time window
|
||||||
assert_response :redirect
|
assert_response :redirect
|
||||||
@@ -145,16 +145,16 @@ class TotpSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
# Verify the TOTP secret exists (sanity check)
|
# Verify the TOTP secret exists (sanity check)
|
||||||
assert user.totp_secret.present?
|
assert user.totp_secret.present?
|
||||||
totp_secret = user.totp_secret
|
user.totp_secret
|
||||||
|
|
||||||
# Sign in with TOTP
|
# Sign in with TOTP
|
||||||
post signin_path, params: { email_address: "totp_secret_test@example.com", password: "password123" }
|
post signin_path, params: {email_address: "totp_secret_test@example.com", password: "password123"}
|
||||||
assert_redirected_to totp_verification_path
|
assert_redirected_to totp_verification_path
|
||||||
|
|
||||||
# Complete TOTP verification
|
# Complete TOTP verification
|
||||||
totp = ROTP::TOTP.new(user.totp_secret)
|
totp = ROTP::TOTP.new(user.totp_secret)
|
||||||
valid_code = totp.now
|
valid_code = totp.now
|
||||||
post totp_verification_path, params: { code: valid_code }
|
post totp_verification_path, params: {code: valid_code}
|
||||||
assert_response :redirect
|
assert_response :redirect
|
||||||
|
|
||||||
# The TOTP secret should never be exposed in the response body or headers
|
# The TOTP secret should never be exposed in the response body or headers
|
||||||
@@ -210,7 +210,7 @@ class TotpSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
user.update!(totp_required: true, totp_secret: nil)
|
user.update!(totp_required: true, totp_secret: nil)
|
||||||
|
|
||||||
# Sign in
|
# Sign in
|
||||||
post signin_path, params: { email_address: "totp_setup_test@example.com", password: "password123" }
|
post signin_path, params: {email_address: "totp_setup_test@example.com", password: "password123"}
|
||||||
|
|
||||||
# Should redirect to TOTP setup, not verification
|
# Should redirect to TOTP setup, not verification
|
||||||
assert_response :redirect
|
assert_response :redirect
|
||||||
@@ -232,7 +232,7 @@ class TotpSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
user.save!
|
user.save!
|
||||||
|
|
||||||
# Set up pending TOTP session
|
# Set up pending TOTP session
|
||||||
post signin_path, params: { email_address: "totp_format_test@example.com", password: "password123" }
|
post signin_path, params: {email_address: "totp_format_test@example.com", password: "password123"}
|
||||||
assert_redirected_to totp_verification_path
|
assert_redirected_to totp_verification_path
|
||||||
|
|
||||||
# Try invalid formats
|
# Try invalid formats
|
||||||
@@ -245,7 +245,7 @@ class TotpSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
]
|
]
|
||||||
|
|
||||||
invalid_codes.each do |invalid_code|
|
invalid_codes.each do |invalid_code|
|
||||||
post totp_verification_path, params: { code: invalid_code }
|
post totp_verification_path, params: {code: invalid_code}
|
||||||
assert_response :redirect
|
assert_response :redirect
|
||||||
assert_redirected_to totp_verification_path
|
assert_redirected_to totp_verification_path
|
||||||
end
|
end
|
||||||
@@ -266,11 +266,11 @@ class TotpSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
user.save!
|
user.save!
|
||||||
|
|
||||||
# Sign in
|
# Sign in
|
||||||
post signin_path, params: { email_address: "totp_recovery_test@example.com", password: "password123" }
|
post signin_path, params: {email_address: "totp_recovery_test@example.com", password: "password123"}
|
||||||
assert_redirected_to totp_verification_path
|
assert_redirected_to totp_verification_path
|
||||||
|
|
||||||
# Use backup code instead of TOTP
|
# Use backup code instead of TOTP
|
||||||
post totp_verification_path, params: { code: backup_codes.first }
|
post totp_verification_path, params: {code: backup_codes.first}
|
||||||
|
|
||||||
# Should successfully sign in
|
# Should successfully sign in
|
||||||
assert_response :redirect
|
assert_response :redirect
|
||||||
|
|||||||
@@ -20,27 +20,27 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
|
|||||||
# Basic Authentication Flow Tests
|
# Basic Authentication Flow Tests
|
||||||
test "complete authentication flow: unauthenticated to authenticated" do
|
test "complete authentication flow: unauthenticated to authenticated" do
|
||||||
# Step 1: Unauthenticated request should redirect
|
# Step 1: Unauthenticated request should redirect
|
||||||
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
|
||||||
assert_response 302
|
assert_response 302
|
||||||
assert_match %r{/signin}, response.location
|
assert_match %r{/signin}, response.location
|
||||||
assert_equal "No session cookie", response.headers["x-auth-reason"]
|
assert_equal "No session cookie", response.headers["x-auth-reason"]
|
||||||
|
|
||||||
# Step 2: Sign in
|
# Step 2: Sign in
|
||||||
post "/signin", params: { email_address: @user.email_address, password: "password" }
|
post "/signin", params: {email_address: @user.email_address, password: "password"}
|
||||||
assert_response 302
|
assert_response 302
|
||||||
# Signin now redirects back with fa_token parameter
|
# Signin now redirects back with fa_token parameter
|
||||||
assert_match(/\?fa_token=/, response.location)
|
assert_match(/\?fa_token=/, response.location)
|
||||||
assert cookies[:session_id]
|
assert cookies[:session_id]
|
||||||
|
|
||||||
# Step 3: Authenticated request should succeed
|
# Step 3: Authenticated request should succeed
|
||||||
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
|
||||||
assert_response 200
|
assert_response 200
|
||||||
assert_equal @user.email_address, response.headers["x-remote-user"]
|
assert_equal @user.email_address, response.headers["x-remote-user"]
|
||||||
end
|
end
|
||||||
|
|
||||||
test "session expiration handling" do
|
test "session expiration handling" do
|
||||||
# Sign in
|
# Sign in
|
||||||
post "/signin", params: { email_address: @user.email_address, password: "password" }
|
post "/signin", params: {email_address: @user.email_address, password: "password"}
|
||||||
|
|
||||||
# Manually expire the session (get the most recent session for this user)
|
# Manually expire the session (get the most recent session for this user)
|
||||||
session = Session.where(user: @user).order(created_at: :desc).first
|
session = Session.where(user: @user).order(created_at: :desc).first
|
||||||
@@ -48,7 +48,7 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
|
|||||||
session.update!(expires_at: 1.hour.ago)
|
session.update!(expires_at: 1.hour.ago)
|
||||||
|
|
||||||
# Request should fail and redirect to login
|
# Request should fail and redirect to login
|
||||||
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
|
||||||
assert_response 302
|
assert_response 302
|
||||||
assert_equal "Session expired", response.headers["x-auth-reason"]
|
assert_equal "Session expired", response.headers["x-auth-reason"]
|
||||||
end
|
end
|
||||||
@@ -56,24 +56,24 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
|
|||||||
# Domain and Rule Integration Tests
|
# Domain and Rule Integration Tests
|
||||||
test "different domain patterns with same session" do
|
test "different domain patterns with same session" do
|
||||||
# Create test rules
|
# Create test rules
|
||||||
wildcard_rule = Application.create!(name: "Wildcard App", slug: "wildcard-app", app_type: "forward_auth", domain_pattern: "*.example.com", active: true)
|
Application.create!(name: "Wildcard App", slug: "wildcard-app", app_type: "forward_auth", domain_pattern: "*.example.com", active: true)
|
||||||
exact_rule = Application.create!(name: "Exact App", slug: "exact-app", app_type: "forward_auth", domain_pattern: "api.example.com", active: true)
|
Application.create!(name: "Exact App", slug: "exact-app", app_type: "forward_auth", domain_pattern: "api.example.com", active: true)
|
||||||
|
|
||||||
# Sign in
|
# Sign in
|
||||||
post "/signin", params: { email_address: @user.email_address, password: "password" }
|
post "/signin", params: {email_address: @user.email_address, password: "password"}
|
||||||
|
|
||||||
# Test wildcard domain
|
# Test wildcard domain
|
||||||
get "/api/verify", headers: { "X-Forwarded-Host" => "app.example.com" }
|
get "/api/verify", headers: {"X-Forwarded-Host" => "app.example.com"}
|
||||||
assert_response 200
|
assert_response 200
|
||||||
assert_equal @user.email_address, response.headers["x-remote-user"]
|
assert_equal @user.email_address, response.headers["x-remote-user"]
|
||||||
|
|
||||||
# Test exact domain
|
# Test exact domain
|
||||||
get "/api/verify", headers: { "X-Forwarded-Host" => "api.example.com" }
|
get "/api/verify", headers: {"X-Forwarded-Host" => "api.example.com"}
|
||||||
assert_response 200
|
assert_response 200
|
||||||
assert_equal @user.email_address, response.headers["x-remote-user"]
|
assert_equal @user.email_address, response.headers["x-remote-user"]
|
||||||
|
|
||||||
# Test non-matching domain (should use defaults)
|
# Test non-matching domain (should use defaults)
|
||||||
get "/api/verify", headers: { "X-Forwarded-Host" => "other.example.com" }
|
get "/api/verify", headers: {"X-Forwarded-Host" => "other.example.com"}
|
||||||
assert_response 200
|
assert_response 200
|
||||||
assert_equal @user.email_address, response.headers["x-remote-user"]
|
assert_equal @user.email_address, response.headers["x-remote-user"]
|
||||||
end
|
end
|
||||||
@@ -84,10 +84,10 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
|
|||||||
restricted_rule.allowed_groups << @group
|
restricted_rule.allowed_groups << @group
|
||||||
|
|
||||||
# Sign in user without group
|
# Sign in user without group
|
||||||
post "/signin", params: { email_address: @user.email_address, password: "password" }
|
post "/signin", params: {email_address: @user.email_address, password: "password"}
|
||||||
|
|
||||||
# Should be denied access
|
# Should be denied access
|
||||||
get "/api/verify", headers: { "X-Forwarded-Host" => "restricted.example.com" }
|
get "/api/verify", headers: {"X-Forwarded-Host" => "restricted.example.com"}
|
||||||
assert_response 403
|
assert_response 403
|
||||||
assert_match %r{permission to access this domain}, response.headers["x-auth-reason"]
|
assert_match %r{permission to access this domain}, response.headers["x-auth-reason"]
|
||||||
|
|
||||||
@@ -95,7 +95,7 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
|
|||||||
@user.groups << @group
|
@user.groups << @group
|
||||||
|
|
||||||
# Should now be allowed
|
# Should now be allowed
|
||||||
get "/api/verify", headers: { "X-Forwarded-Host" => "restricted.example.com" }
|
get "/api/verify", headers: {"X-Forwarded-Host" => "restricted.example.com"}
|
||||||
assert_response 200
|
assert_response 200
|
||||||
assert_equal @user.email_address, response.headers["x-remote-user"]
|
assert_equal @user.email_address, response.headers["x-remote-user"]
|
||||||
end
|
end
|
||||||
@@ -103,18 +103,18 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
|
|||||||
# Header Configuration Integration Tests
|
# Header Configuration Integration Tests
|
||||||
test "different header configurations with same user" do
|
test "different header configurations with same user" do
|
||||||
# Create applications with different configs
|
# Create applications with different configs
|
||||||
default_rule = Application.create!(name: "Default App", slug: "default-app", app_type: "forward_auth", domain_pattern: "default.example.com", active: true)
|
Application.create!(name: "Default App", slug: "default-app", app_type: "forward_auth", domain_pattern: "default.example.com", active: true)
|
||||||
custom_rule = Application.create!(
|
Application.create!(
|
||||||
name: "Custom App", slug: "custom-app", app_type: "forward_auth",
|
name: "Custom App", slug: "custom-app", app_type: "forward_auth",
|
||||||
domain_pattern: "custom.example.com",
|
domain_pattern: "custom.example.com",
|
||||||
active: true,
|
active: true,
|
||||||
headers_config: { user: "X-WEBAUTH-USER", groups: "X-WEBAUTH-ROLES" }
|
headers_config: {user: "X-WEBAUTH-USER", groups: "X-WEBAUTH-ROLES"}
|
||||||
)
|
)
|
||||||
no_headers_rule = Application.create!(
|
Application.create!(
|
||||||
name: "No Headers App", slug: "no-headers-app", app_type: "forward_auth",
|
name: "No Headers App", slug: "no-headers-app", app_type: "forward_auth",
|
||||||
domain_pattern: "noheaders.example.com",
|
domain_pattern: "noheaders.example.com",
|
||||||
active: true,
|
active: true,
|
||||||
headers_config: { user: "", email: "", name: "", groups: "", admin: "" }
|
headers_config: {user: "", email: "", name: "", groups: "", admin: ""}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add user to groups
|
# Add user to groups
|
||||||
@@ -122,10 +122,10 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
|
|||||||
@user.groups << @group2
|
@user.groups << @group2
|
||||||
|
|
||||||
# Sign in
|
# Sign in
|
||||||
post "/signin", params: { email_address: @user.email_address, password: "password" }
|
post "/signin", params: {email_address: @user.email_address, password: "password"}
|
||||||
|
|
||||||
# Test default headers
|
# Test default headers
|
||||||
get "/api/verify", headers: { "X-Forwarded-Host" => "default.example.com" }
|
get "/api/verify", headers: {"X-Forwarded-Host" => "default.example.com"}
|
||||||
assert_response 200
|
assert_response 200
|
||||||
# Rails normalizes header keys to lowercase
|
# Rails normalizes header keys to lowercase
|
||||||
assert_equal @user.email_address, response.headers["x-remote-user"]
|
assert_equal @user.email_address, response.headers["x-remote-user"]
|
||||||
@@ -133,7 +133,7 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
|
|||||||
assert_equal "Group Two,Group One", response.headers["x-remote-groups"]
|
assert_equal "Group Two,Group One", response.headers["x-remote-groups"]
|
||||||
|
|
||||||
# Test custom headers
|
# Test custom headers
|
||||||
get "/api/verify", headers: { "X-Forwarded-Host" => "custom.example.com" }
|
get "/api/verify", headers: {"X-Forwarded-Host" => "custom.example.com"}
|
||||||
assert_response 200
|
assert_response 200
|
||||||
# Custom headers are also normalized to lowercase
|
# Custom headers are also normalized to lowercase
|
||||||
assert_equal @user.email_address, response.headers["x-webauth-user"]
|
assert_equal @user.email_address, response.headers["x-webauth-user"]
|
||||||
@@ -141,7 +141,7 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
|
|||||||
assert_equal "Group Two,Group One", response.headers["x-webauth-roles"]
|
assert_equal "Group Two,Group One", response.headers["x-webauth-roles"]
|
||||||
|
|
||||||
# Test no headers
|
# Test no headers
|
||||||
get "/api/verify", headers: { "X-Forwarded-Host" => "noheaders.example.com" }
|
get "/api/verify", headers: {"X-Forwarded-Host" => "noheaders.example.com"}
|
||||||
assert_response 200
|
assert_response 200
|
||||||
# Check that no auth-related headers are present (excluding security headers)
|
# Check that no auth-related headers are present (excluding security headers)
|
||||||
auth_headers = response.headers.select { |k, v| k.match?(/^x-remote-|^x-webauth-|^x-admin-/i) }
|
auth_headers = response.headers.select { |k, v| k.match?(/^x-remote-|^x-webauth-|^x-admin-/i) }
|
||||||
@@ -174,7 +174,7 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
|
|||||||
get "/api/verify", headers: {
|
get "/api/verify", headers: {
|
||||||
"X-Forwarded-Host" => "app.example.com",
|
"X-Forwarded-Host" => "app.example.com",
|
||||||
"X-Forwarded-Uri" => "/admin"
|
"X-Forwarded-Uri" => "/admin"
|
||||||
}, params: { rd: "https://app.example.com/admin" }
|
}, params: {rd: "https://app.example.com/admin"}
|
||||||
|
|
||||||
assert_response 302
|
assert_response 302
|
||||||
location = response.location
|
location = response.location
|
||||||
@@ -194,16 +194,16 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
|
|||||||
admin_user = users(:two)
|
admin_user = users(:two)
|
||||||
|
|
||||||
# Create restricted rule
|
# Create restricted rule
|
||||||
admin_rule = Application.create!(
|
Application.create!(
|
||||||
name: "Admin App", slug: "admin-app", app_type: "forward_auth",
|
name: "Admin App", slug: "admin-app", app_type: "forward_auth",
|
||||||
domain_pattern: "admin.example.com",
|
domain_pattern: "admin.example.com",
|
||||||
active: true,
|
active: true,
|
||||||
headers_config: { user: "X-Admin-User", admin: "X-Admin-Flag" }
|
headers_config: {user: "X-Admin-User", admin: "X-Admin-Flag"}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Test regular user
|
# Test regular user
|
||||||
post "/signin", params: { email_address: regular_user.email_address, password: "password" }
|
post "/signin", params: {email_address: regular_user.email_address, password: "password"}
|
||||||
get "/api/verify", headers: { "X-Forwarded-Host" => "admin.example.com" }
|
get "/api/verify", headers: {"X-Forwarded-Host" => "admin.example.com"}
|
||||||
assert_response 200
|
assert_response 200
|
||||||
assert_equal regular_user.email_address, response.headers["x-admin-user"]
|
assert_equal regular_user.email_address, response.headers["x-admin-user"]
|
||||||
|
|
||||||
@@ -211,8 +211,8 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
|
|||||||
delete "/session"
|
delete "/session"
|
||||||
|
|
||||||
# Test admin user
|
# Test admin user
|
||||||
post "/signin", params: { email_address: admin_user.email_address, password: "password" }
|
post "/signin", params: {email_address: admin_user.email_address, password: "password"}
|
||||||
get "/api/verify", headers: { "X-Forwarded-Host" => "admin.example.com" }
|
get "/api/verify", headers: {"X-Forwarded-Host" => "admin.example.com"}
|
||||||
assert_response 200
|
assert_response 200
|
||||||
assert_equal admin_user.email_address, response.headers["x-admin-user"]
|
assert_equal admin_user.email_address, response.headers["x-admin-user"]
|
||||||
assert_equal "true", response.headers["x-admin-flag"]
|
assert_equal "true", response.headers["x-admin-flag"]
|
||||||
@@ -221,10 +221,10 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
|
|||||||
# Security Integration Tests
|
# Security Integration Tests
|
||||||
test "session hijacking prevention" do
|
test "session hijacking prevention" do
|
||||||
# User A signs in
|
# User A signs in
|
||||||
post "/signin", params: { email_address: @user.email_address, password: "password" }
|
post "/signin", params: {email_address: @user.email_address, password: "password"}
|
||||||
|
|
||||||
# Verify User A can access protected resources
|
# Verify User A can access protected resources
|
||||||
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
|
||||||
assert_response 200
|
assert_response 200
|
||||||
assert_equal @user.email_address, response.headers["x-remote-user"]
|
assert_equal @user.email_address, response.headers["x-remote-user"]
|
||||||
user_a_session_id = Session.where(user: @user).last.id
|
user_a_session_id = Session.where(user: @user).last.id
|
||||||
@@ -233,10 +233,10 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
|
|||||||
reset!
|
reset!
|
||||||
|
|
||||||
# User B signs in (creates a new session)
|
# User B signs in (creates a new session)
|
||||||
post "/signin", params: { email_address: @admin_user.email_address, password: "password" }
|
post "/signin", params: {email_address: @admin_user.email_address, password: "password"}
|
||||||
|
|
||||||
# Verify User B can access protected resources
|
# Verify User B can access protected resources
|
||||||
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
|
||||||
assert_response 200
|
assert_response 200
|
||||||
assert_equal @admin_user.email_address, response.headers["x-remote-user"]
|
assert_equal @admin_user.email_address, response.headers["x-remote-user"]
|
||||||
user_b_session_id = Session.where(user: @admin_user).last.id
|
user_b_session_id = Session.where(user: @admin_user).last.id
|
||||||
@@ -245,5 +245,4 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
|
|||||||
assert Session.exists?(user_a_session_id), "User A's session should still exist"
|
assert Session.exists?(user_a_session_id), "User A's session should still exist"
|
||||||
assert Session.exists?(user_b_session_id), "User B's session should still exist"
|
assert Session.exists?(user_b_session_id), "User B's session should still exist"
|
||||||
end
|
end
|
||||||
|
|
||||||
end
|
end
|
||||||
@@ -94,7 +94,7 @@ class InvitationFlowTest < ActionDispatch::IntegrationTest
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "expired invitation token flow" do
|
test "expired invitation token flow" do
|
||||||
user = User.create!(
|
User.create!(
|
||||||
email_address: "expired@example.com",
|
email_address: "expired@example.com",
|
||||||
password: "temppassword",
|
password: "temppassword",
|
||||||
status: :pending_invitation
|
status: :pending_invitation
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ class SessionSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
user = User.create!(email_address: "session_test@example.com", password: "password123")
|
user = User.create!(email_address: "session_test@example.com", password: "password123")
|
||||||
|
|
||||||
# Sign in
|
# Sign in
|
||||||
post signin_path, params: { email_address: "session_test@example.com", password: "password123" }
|
post signin_path, params: {email_address: "session_test@example.com", password: "password123"}
|
||||||
assert_response :redirect
|
assert_response :redirect
|
||||||
follow_redirect!
|
follow_redirect!
|
||||||
assert_response :success
|
assert_response :success
|
||||||
@@ -75,7 +75,7 @@ class SessionSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
user = User.create!(email_address: "session_fixation_test@example.com", password: "password123")
|
user = User.create!(email_address: "session_fixation_test@example.com", password: "password123")
|
||||||
|
|
||||||
# Sign in creates a new session
|
# Sign in creates a new session
|
||||||
post signin_path, params: { email_address: "session_fixation_test@example.com", password: "password123" }
|
post signin_path, params: {email_address: "session_fixation_test@example.com", password: "password123"}
|
||||||
assert_response :redirect
|
assert_response :redirect
|
||||||
|
|
||||||
# User should be authenticated after sign in
|
# User should be authenticated after sign in
|
||||||
@@ -92,21 +92,21 @@ class SessionSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
user = User.create!(email_address: "concurrent_session_test@example.com", password: "password123")
|
user = User.create!(email_address: "concurrent_session_test@example.com", password: "password123")
|
||||||
|
|
||||||
# Create multiple sessions from different devices
|
# Create multiple sessions from different devices
|
||||||
session1 = user.sessions.create!(
|
user.sessions.create!(
|
||||||
ip_address: "192.168.1.1",
|
ip_address: "192.168.1.1",
|
||||||
user_agent: "Mozilla/5.0 (Windows)",
|
user_agent: "Mozilla/5.0 (Windows)",
|
||||||
device_name: "Windows PC",
|
device_name: "Windows PC",
|
||||||
last_activity_at: Time.current
|
last_activity_at: Time.current
|
||||||
)
|
)
|
||||||
|
|
||||||
session2 = user.sessions.create!(
|
user.sessions.create!(
|
||||||
ip_address: "192.168.1.2",
|
ip_address: "192.168.1.2",
|
||||||
user_agent: "Mozilla/5.0 (iPhone)",
|
user_agent: "Mozilla/5.0 (iPhone)",
|
||||||
device_name: "iPhone",
|
device_name: "iPhone",
|
||||||
last_activity_at: Time.current
|
last_activity_at: Time.current
|
||||||
)
|
)
|
||||||
|
|
||||||
session3 = user.sessions.create!(
|
user.sessions.create!(
|
||||||
ip_address: "192.168.1.3",
|
ip_address: "192.168.1.3",
|
||||||
user_agent: "Mozilla/5.0 (Macintosh)",
|
user_agent: "Mozilla/5.0 (Macintosh)",
|
||||||
device_name: "MacBook",
|
device_name: "MacBook",
|
||||||
@@ -157,14 +157,14 @@ class SessionSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
user = User.create!(email_address: "logout_test@example.com", password: "password123")
|
user = User.create!(email_address: "logout_test@example.com", password: "password123")
|
||||||
|
|
||||||
# Create multiple sessions
|
# Create multiple sessions
|
||||||
session1 = user.sessions.create!(
|
user.sessions.create!(
|
||||||
ip_address: "192.168.1.1",
|
ip_address: "192.168.1.1",
|
||||||
user_agent: "Mozilla/5.0 (Windows)",
|
user_agent: "Mozilla/5.0 (Windows)",
|
||||||
device_name: "Windows PC",
|
device_name: "Windows PC",
|
||||||
last_activity_at: Time.current
|
last_activity_at: Time.current
|
||||||
)
|
)
|
||||||
|
|
||||||
session2 = user.sessions.create!(
|
user.sessions.create!(
|
||||||
ip_address: "192.168.1.2",
|
ip_address: "192.168.1.2",
|
||||||
user_agent: "Mozilla/5.0 (iPhone)",
|
user_agent: "Mozilla/5.0 (iPhone)",
|
||||||
device_name: "iPhone",
|
device_name: "iPhone",
|
||||||
@@ -172,7 +172,7 @@ class SessionSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Sign in (creates a new session via the sign-in flow)
|
# Sign in (creates a new session via the sign-in flow)
|
||||||
post signin_path, params: { email_address: "logout_test@example.com", password: "password123" }
|
post signin_path, params: {email_address: "logout_test@example.com", password: "password123"}
|
||||||
assert_response :redirect
|
assert_response :redirect
|
||||||
|
|
||||||
# Should have 3 sessions now
|
# Should have 3 sessions now
|
||||||
@@ -204,7 +204,7 @@ class SessionSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Create consent with backchannel logout enabled
|
# Create consent with backchannel logout enabled
|
||||||
consent = OidcUserConsent.create!(
|
OidcUserConsent.create!(
|
||||||
user: user,
|
user: user,
|
||||||
application: application,
|
application: application,
|
||||||
scopes_granted: "openid profile",
|
scopes_granted: "openid profile",
|
||||||
@@ -212,7 +212,7 @@ class SessionSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Sign in
|
# Sign in
|
||||||
post signin_path, params: { email_address: "logout_notification_test@example.com", password: "password123" }
|
post signin_path, params: {email_address: "logout_notification_test@example.com", password: "password123"}
|
||||||
assert_response :redirect
|
assert_response :redirect
|
||||||
|
|
||||||
# Sign out
|
# Sign out
|
||||||
@@ -237,8 +237,8 @@ class SessionSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
user = User.create!(email_address: "hijacking_test@example.com", password: "password123")
|
user = User.create!(email_address: "hijacking_test@example.com", password: "password123")
|
||||||
|
|
||||||
# Sign in
|
# Sign in
|
||||||
post signin_path, params: { email_address: "hijacking_test@example.com", password: "password123" },
|
post signin_path, params: {email_address: "hijacking_test@example.com", password: "password123"},
|
||||||
headers: { "HTTP_USER_AGENT" => "TestBrowser/1.0" }
|
headers: {"HTTP_USER_AGENT" => "TestBrowser/1.0"}
|
||||||
assert_response :redirect
|
assert_response :redirect
|
||||||
|
|
||||||
# Check that session includes IP and user agent
|
# Check that session includes IP and user agent
|
||||||
@@ -295,7 +295,7 @@ class SessionSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
# Test forward auth endpoint with valid session
|
# Test forward auth endpoint with valid session
|
||||||
get api_verify_path(rd: "https://test.example.com/protected"),
|
get api_verify_path(rd: "https://test.example.com/protected"),
|
||||||
headers: { cookie: "_session_id=#{user_session.id}" }
|
headers: {cookie: "_session_id=#{user_session.id}"}
|
||||||
|
|
||||||
# Should accept the request and redirect back
|
# Should accept the request and redirect back
|
||||||
assert_response :redirect
|
assert_response :redirect
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ class WebauthnCredentialEnumerationTest < ActionDispatch::IntegrationTest
|
|||||||
user2 = User.create!(email_address: "user2@example.com", password: "password123")
|
user2 = User.create!(email_address: "user2@example.com", password: "password123")
|
||||||
|
|
||||||
# Create a credential for user1
|
# Create a credential for user1
|
||||||
credential1 = user1.webauthn_credentials.create!(
|
user1.webauthn_credentials.create!(
|
||||||
external_id: Base64.urlsafe_encode64("user1_credential"),
|
external_id: Base64.urlsafe_encode64("user1_credential"),
|
||||||
public_key: Base64.urlsafe_encode64("public_key_1"),
|
public_key: Base64.urlsafe_encode64("public_key_1"),
|
||||||
sign_count: 0,
|
sign_count: 0,
|
||||||
@@ -28,7 +28,7 @@ class WebauthnCredentialEnumerationTest < ActionDispatch::IntegrationTest
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Sign in as user1
|
# Sign in as user1
|
||||||
post signin_path, params: { email_address: "user1@example.com", password: "password123" }
|
post signin_path, params: {email_address: "user1@example.com", password: "password123"}
|
||||||
assert_response :redirect
|
assert_response :redirect
|
||||||
follow_redirect!
|
follow_redirect!
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ class WebauthnCredentialEnumerationTest < ActionDispatch::IntegrationTest
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Sign in
|
# Sign in
|
||||||
post signin_path, params: { email_address: "user@example.com", password: "password123" }
|
post signin_path, params: {email_address: "user@example.com", password: "password123"}
|
||||||
assert_response :redirect
|
assert_response :redirect
|
||||||
follow_redirect!
|
follow_redirect!
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ class ApplicationJobTest < ActiveJob::TestCase
|
|||||||
end
|
end
|
||||||
|
|
||||||
assert_enqueued_jobs 1 do
|
assert_enqueued_jobs 1 do
|
||||||
test_job.perform_later("arg1", "arg2", { "key" => "value" })
|
test_job.perform_later("arg1", "arg2", {"key" => "value"})
|
||||||
end
|
end
|
||||||
|
|
||||||
# ActiveJob serializes all hash keys as strings
|
# ActiveJob serializes all hash keys as strings
|
||||||
@@ -77,7 +77,7 @@ class ApplicationJobTest < ActiveJob::TestCase
|
|||||||
args = enqueued_jobs.last[:args]
|
args = enqueued_jobs.last[:args]
|
||||||
if args.is_a?(Array) && args.first.is_a?(Hash)
|
if args.is_a?(Array) && args.first.is_a?(Hash)
|
||||||
# GlobalID serialization format
|
# GlobalID serialization format
|
||||||
assert_equal user.to_global_id.to_s, args.first['_aj_globalid']
|
assert_equal user.to_global_id.to_s, args.first["_aj_globalid"]
|
||||||
else
|
else
|
||||||
# Direct object serialization
|
# Direct object serialization
|
||||||
assert_equal user.id, args.first.id
|
assert_equal user.id, args.first.id
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ class PasswordsMailerTest < ActionMailer::TestCase
|
|||||||
|
|
||||||
# Should not include sensitive data in headers (except Subject which legitimately mentions password)
|
# Should not include sensitive data in headers (except Subject which legitimately mentions password)
|
||||||
email.header.fields.each do |field|
|
email.header.fields.each do |field|
|
||||||
next if field.name =~ /^subject$/i
|
next if /^subject$/i.match?(field.name)
|
||||||
# Check for actual tokens (not just the word "token" which is common in emails)
|
# Check for actual tokens (not just the word "token" which is common in emails)
|
||||||
refute_includes field.value.to_s.downcase, "password"
|
refute_includes field.value.to_s.downcase, "password"
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ class ApplicationUserClaimTest < ActiveSupport::TestCase
|
|||||||
claim = ApplicationUserClaim.new(
|
claim = ApplicationUserClaim.new(
|
||||||
user: @user,
|
user: @user,
|
||||||
application: @application,
|
application: @application,
|
||||||
custom_claims: { "role": "admin" }
|
custom_claims: {role: "admin"}
|
||||||
)
|
)
|
||||||
assert claim.valid?
|
assert claim.valid?
|
||||||
assert claim.save
|
assert claim.save
|
||||||
@@ -20,13 +20,13 @@ class ApplicationUserClaimTest < ActiveSupport::TestCase
|
|||||||
ApplicationUserClaim.create!(
|
ApplicationUserClaim.create!(
|
||||||
user: @user,
|
user: @user,
|
||||||
application: @application,
|
application: @application,
|
||||||
custom_claims: { "role": "admin" }
|
custom_claims: {role: "admin"}
|
||||||
)
|
)
|
||||||
|
|
||||||
duplicate = ApplicationUserClaim.new(
|
duplicate = ApplicationUserClaim.new(
|
||||||
user: @user,
|
user: @user,
|
||||||
application: @application,
|
application: @application,
|
||||||
custom_claims: { "role": "user" }
|
custom_claims: {role: "user"}
|
||||||
)
|
)
|
||||||
|
|
||||||
assert_not duplicate.valid?
|
assert_not duplicate.valid?
|
||||||
@@ -37,7 +37,7 @@ class ApplicationUserClaimTest < ActiveSupport::TestCase
|
|||||||
claim = ApplicationUserClaim.new(
|
claim = ApplicationUserClaim.new(
|
||||||
user: @user,
|
user: @user,
|
||||||
application: @application,
|
application: @application,
|
||||||
custom_claims: { "role": "admin", "level": 5 }
|
custom_claims: {role: "admin", level: 5}
|
||||||
)
|
)
|
||||||
|
|
||||||
parsed = claim.parsed_custom_claims
|
parsed = claim.parsed_custom_claims
|
||||||
@@ -59,7 +59,7 @@ class ApplicationUserClaimTest < ActiveSupport::TestCase
|
|||||||
claim = ApplicationUserClaim.new(
|
claim = ApplicationUserClaim.new(
|
||||||
user: @user,
|
user: @user,
|
||||||
application: @application,
|
application: @application,
|
||||||
custom_claims: { "groups": ["admin"], "role": "user" }
|
custom_claims: {groups: ["admin"], role: "user"}
|
||||||
)
|
)
|
||||||
|
|
||||||
assert_not claim.valid?
|
assert_not claim.valid?
|
||||||
@@ -70,7 +70,7 @@ class ApplicationUserClaimTest < ActiveSupport::TestCase
|
|||||||
claim = ApplicationUserClaim.new(
|
claim = ApplicationUserClaim.new(
|
||||||
user: @user,
|
user: @user,
|
||||||
application: @application,
|
application: @application,
|
||||||
custom_claims: { "kavita_groups": ["admin"], "role": "user" }
|
custom_claims: {kavita_groups: ["admin"], role: "user"}
|
||||||
)
|
)
|
||||||
|
|
||||||
assert claim.valid?
|
assert claim.valid?
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ class OidcAccessTokenTest < ActiveSupport::TestCase
|
|||||||
assert_nil new_token.plaintext_token
|
assert_nil new_token.plaintext_token
|
||||||
assert new_token.save
|
assert new_token.save
|
||||||
assert_not_nil new_token.plaintext_token
|
assert_not_nil new_token.plaintext_token
|
||||||
assert_match /^[A-Za-z0-9_-]+$/, new_token.plaintext_token
|
assert_match(/^[A-Za-z0-9_-]+$/, new_token.plaintext_token)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "should set expiry before validation on create" do
|
test "should set expiry before validation on create" do
|
||||||
@@ -144,7 +144,7 @@ class OidcAccessTokenTest < ActiveSupport::TestCase
|
|||||||
|
|
||||||
# All tokens should match the expected pattern
|
# All tokens should match the expected pattern
|
||||||
tokens.each do |token|
|
tokens.each do |token|
|
||||||
assert_match /^[A-Za-z0-9_-]+$/, token
|
assert_match(/^[A-Za-z0-9_-]+$/, token)
|
||||||
# Base64 token length may vary due to padding, just ensure it's reasonable
|
# Base64 token length may vary due to padding, just ensure it's reasonable
|
||||||
assert token.length >= 43, "Token should be at least 43 characters"
|
assert token.length >= 43, "Token should be at least 43 characters"
|
||||||
assert token.length <= 64, "Token should not exceed 64 characters"
|
assert token.length <= 64, "Token should not exceed 64 characters"
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ class OidcAuthorizationCodeTest < ActiveSupport::TestCase
|
|||||||
assert_nil new_code.code_hmac
|
assert_nil new_code.code_hmac
|
||||||
assert new_code.save
|
assert new_code.save
|
||||||
assert_not_nil new_code.code_hmac
|
assert_not_nil new_code.code_hmac
|
||||||
assert_match /^[a-f0-9]{64}$/, new_code.code_hmac # SHA256 hex digest
|
assert_match(/^[a-f0-9]{64}$/, new_code.code_hmac) # SHA256 hex digest
|
||||||
end
|
end
|
||||||
|
|
||||||
test "should set expiry before validation on create" do
|
test "should set expiry before validation on create" do
|
||||||
@@ -186,7 +186,7 @@ class OidcAuthorizationCodeTest < ActiveSupport::TestCase
|
|||||||
|
|
||||||
# All codes should be SHA256 hex digests
|
# All codes should be SHA256 hex digests
|
||||||
codes.each do |code|
|
codes.each do |code|
|
||||||
assert_match /^[a-f0-9]{64}$/, code
|
assert_match(/^[a-f0-9]{64}$/, code)
|
||||||
assert_equal 64, code.length # SHA256 hex digest
|
assert_equal 64, code.length # SHA256 hex digest
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ class UserPasswordManagementTest < ActiveSupport::TestCase
|
|||||||
assert_not authenticated_user.authenticate("WrongPassword"), "Should not authenticate with wrong password"
|
assert_not authenticated_user.authenticate("WrongPassword"), "Should not authenticate with wrong password"
|
||||||
|
|
||||||
# Test password changes invalidate old sessions
|
# Test password changes invalidate old sessions
|
||||||
old_password_digest = @user.password_digest
|
@user.password_digest
|
||||||
@user.password = "NewPassword123!"
|
@user.password = "NewPassword123!"
|
||||||
@user.save!
|
@user.save!
|
||||||
|
|
||||||
@@ -102,7 +102,7 @@ class UserPasswordManagementTest < ActiveSupport::TestCase
|
|||||||
assert new_user.password_digest.length > 50, "Password digest should be substantial"
|
assert new_user.password_digest.length > 50, "Password digest should be substantial"
|
||||||
|
|
||||||
# Test digest format (bcrypt hashes start with $2a$)
|
# Test digest format (bcrypt hashes start with $2a$)
|
||||||
assert_match /^\$2a\$/, new_user.password_digest, "Password digest should be bcrypt format"
|
assert_match(/^\$2a\$/, new_user.password_digest, "Password digest should be bcrypt format")
|
||||||
|
|
||||||
# Test authentication against digest
|
# Test authentication against digest
|
||||||
authenticated_user = User.find(new_user.id)
|
authenticated_user = User.find(new_user.id)
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ class UserTest < ActiveSupport::TestCase
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "does not find user with invalid invitation token" do
|
test "does not find user with invalid invitation token" do
|
||||||
user = User.create!(
|
User.create!(
|
||||||
email_address: "test@example.com",
|
email_address: "test@example.com",
|
||||||
password: "password123",
|
password: "password123",
|
||||||
status: :pending_invitation
|
status: :pending_invitation
|
||||||
@@ -222,7 +222,7 @@ class UserTest < ActiveSupport::TestCase
|
|||||||
# Should store 10 BCrypt hashes
|
# Should store 10 BCrypt hashes
|
||||||
assert_equal 10, stored_hashes.length
|
assert_equal 10, stored_hashes.length
|
||||||
stored_hashes.each do |hash|
|
stored_hashes.each do |hash|
|
||||||
assert hash.start_with?('$2a$'), "Should be BCrypt hash"
|
assert hash.start_with?("$2a$"), "Should be BCrypt hash"
|
||||||
end
|
end
|
||||||
|
|
||||||
# Verify each plain code matches its corresponding hash
|
# Verify each plain code matches its corresponding hash
|
||||||
@@ -298,7 +298,7 @@ class UserTest < ActiveSupport::TestCase
|
|||||||
# Make 5 failed attempts to trigger rate limit
|
# Make 5 failed attempts to trigger rate limit
|
||||||
5.times do |i|
|
5.times do |i|
|
||||||
result = user.verify_backup_code("INVALID123")
|
result = user.verify_backup_code("INVALID123")
|
||||||
assert_not result, "Failed attempt #{i+1} should return false"
|
assert_not result, "Failed attempt #{i + 1} should return false"
|
||||||
end
|
end
|
||||||
|
|
||||||
# Check that the cache is tracking attempts
|
# Check that the cache is tracking attempts
|
||||||
|
|||||||
@@ -61,18 +61,18 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
|
|||||||
|
|
||||||
assert_not_nil token, "Should generate token"
|
assert_not_nil token, "Should generate token"
|
||||||
assert token.length > 100, "Token should be substantial"
|
assert token.length > 100, "Token should be substantial"
|
||||||
assert token.include?('.')
|
assert token.include?(".")
|
||||||
|
|
||||||
# Decode without verification for testing the payload
|
# Decode without verification for testing the payload
|
||||||
decoded = JWT.decode(token, nil, false).first
|
decoded = JWT.decode(token, nil, false).first
|
||||||
assert_equal @application.client_id, decoded['aud'], "Should have correct audience"
|
assert_equal @application.client_id, decoded["aud"], "Should have correct audience"
|
||||||
assert_equal @user.id.to_s, decoded['sub'], "Should have correct subject"
|
assert_equal @user.id.to_s, decoded["sub"], "Should have correct subject"
|
||||||
assert_equal @user.email_address, decoded['email'], "Should have correct email"
|
assert_equal @user.email_address, decoded["email"], "Should have correct email"
|
||||||
assert_equal true, decoded['email_verified'], "Should have email verified"
|
assert_equal true, decoded["email_verified"], "Should have email verified"
|
||||||
assert_equal @user.email_address, decoded['preferred_username'], "Should have preferred username"
|
assert_equal @user.email_address, decoded["preferred_username"], "Should have preferred username"
|
||||||
assert_equal @user.email_address, decoded['name'], "Should have name"
|
assert_equal @user.email_address, decoded["name"], "Should have name"
|
||||||
assert_equal @service.issuer_url, decoded['iss'], "Should have correct issuer"
|
assert_equal @service.issuer_url, decoded["iss"], "Should have correct issuer"
|
||||||
assert_in_delta Time.current.to_i + 3600, decoded['exp'], 5, "Should have correct expiration"
|
assert_in_delta Time.current.to_i + 3600, decoded["exp"], 5, "Should have correct expiration"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "should handle nonce in id token" do
|
test "should handle nonce in id token" do
|
||||||
@@ -80,8 +80,8 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
|
|||||||
token = @service.generate_id_token(@user, @application, nonce: nonce)
|
token = @service.generate_id_token(@user, @application, nonce: nonce)
|
||||||
|
|
||||||
decoded = JWT.decode(token, nil, false).first
|
decoded = JWT.decode(token, nil, false).first
|
||||||
assert_equal nonce, decoded['nonce'], "Should preserve nonce in token"
|
assert_equal nonce, decoded["nonce"], "Should preserve nonce in token"
|
||||||
assert_in_delta Time.current.to_i + 3600, decoded['exp'], 5, "Should have correct expiration with nonce"
|
assert_in_delta Time.current.to_i + 3600, decoded["exp"], 5, "Should have correct expiration with nonce"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "should include groups in token when user has groups" do
|
test "should include groups in token when user has groups" do
|
||||||
@@ -91,7 +91,7 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
|
|||||||
token = @service.generate_id_token(@user, @application)
|
token = @service.generate_id_token(@user, @application)
|
||||||
|
|
||||||
decoded = JWT.decode(token, nil, false).first
|
decoded = JWT.decode(token, nil, false).first
|
||||||
assert_includes decoded['groups'], "Administrators", "Should include user's groups"
|
assert_includes decoded["groups"], "Administrators", "Should include user's groups"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "admin claim should not be included in token" do
|
test "admin claim should not be included in token" do
|
||||||
@@ -100,14 +100,14 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
|
|||||||
token = @service.generate_id_token(@user, @application)
|
token = @service.generate_id_token(@user, @application)
|
||||||
|
|
||||||
decoded = JWT.decode(token, nil, false).first
|
decoded = JWT.decode(token, nil, false).first
|
||||||
refute decoded.key?('admin'), "Admin claim should not be included in ID tokens (use groups instead)"
|
refute decoded.key?("admin"), "Admin claim should not be included in ID tokens (use groups instead)"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "should handle missing roles gracefully" do
|
test "should handle missing roles gracefully" do
|
||||||
token = @service.generate_id_token(@user, @application)
|
token = @service.generate_id_token(@user, @application)
|
||||||
|
|
||||||
decoded = JWT.decode(token, nil, false).first
|
decoded = JWT.decode(token, nil, false).first
|
||||||
refute_includes decoded, 'roles', "Should not have roles when not configured"
|
refute_includes decoded, "roles", "Should not have roles when not configured"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "should load RSA private key from environment with escaped newlines" do
|
test "should load RSA private key from environment with escaped newlines" do
|
||||||
@@ -168,7 +168,7 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
|
|||||||
OidcJwtService.send(:private_key)
|
OidcJwtService.send(:private_key)
|
||||||
end
|
end
|
||||||
|
|
||||||
assert_match /Invalid OIDC private key format/, error.message
|
assert_match(/Invalid OIDC private key format/, error.message)
|
||||||
ensure
|
ensure
|
||||||
# Restore original value and clear cached key
|
# Restore original value and clear cached key
|
||||||
ENV["OIDC_PRIVATE_KEY"] = original_value
|
ENV["OIDC_PRIVATE_KEY"] = original_value
|
||||||
@@ -193,7 +193,7 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
|
|||||||
OidcJwtService.send(:private_key)
|
OidcJwtService.send(:private_key)
|
||||||
end
|
end
|
||||||
|
|
||||||
assert_match /OIDC private key not configured/, error.message
|
assert_match(/OIDC private key not configured/, error.message)
|
||||||
ensure
|
ensure
|
||||||
# Restore original environment and clear cached key
|
# Restore original environment and clear cached key
|
||||||
ENV["OIDC_PRIVATE_KEY"] = original_value if original_value
|
ENV["OIDC_PRIVATE_KEY"] = original_value if original_value
|
||||||
@@ -214,9 +214,9 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
|
|||||||
|
|
||||||
assert_not_nil decoded_array, "Should decode valid token"
|
assert_not_nil decoded_array, "Should decode valid token"
|
||||||
decoded = decoded_array.first # JWT.decode returns an array
|
decoded = decoded_array.first # JWT.decode returns an array
|
||||||
assert_equal @user.id.to_s, decoded['sub'], "Should decode subject correctly"
|
assert_equal @user.id.to_s, decoded["sub"], "Should decode subject correctly"
|
||||||
assert_equal @application.client_id, decoded['aud'], "Should decode audience correctly"
|
assert_equal @application.client_id, decoded["aud"], "Should decode audience correctly"
|
||||||
assert decoded['exp'] > Time.current.to_i, "Token should not be expired"
|
assert decoded["exp"] > Time.current.to_i, "Token should not be expired"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "should reject invalid id tokens" do
|
test "should reject invalid id tokens" do
|
||||||
@@ -252,9 +252,9 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
|
|||||||
|
|
||||||
decoded = JWT.decode(token, nil, false).first
|
decoded = JWT.decode(token, nil, false).first
|
||||||
# ID tokens always include email_verified
|
# ID tokens always include email_verified
|
||||||
assert_includes decoded.keys, 'email_verified'
|
assert_includes decoded.keys, "email_verified"
|
||||||
assert_equal @user.id.to_s, decoded['sub'], "Should decode subject correctly"
|
assert_equal @user.id.to_s, decoded["sub"], "Should decode subject correctly"
|
||||||
assert_equal @application.client_id, decoded['aud'], "Should decode audience correctly"
|
assert_equal @application.client_id, decoded["aud"], "Should decode audience correctly"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "should validate JWT configuration" do
|
test "should validate JWT configuration" do
|
||||||
@@ -275,7 +275,7 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
|
|||||||
ApplicationUserClaim.create!(
|
ApplicationUserClaim.create!(
|
||||||
user: user,
|
user: user,
|
||||||
application: app,
|
application: app,
|
||||||
custom_claims: { "app_groups": ["admin"], "library_access": "all" }
|
custom_claims: {app_groups: ["admin"], library_access: "all"}
|
||||||
)
|
)
|
||||||
|
|
||||||
token = @service.generate_id_token(user, app)
|
token = @service.generate_id_token(user, app)
|
||||||
@@ -292,17 +292,17 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
|
|||||||
|
|
||||||
# Add user to group with claims
|
# Add user to group with claims
|
||||||
group = groups(:admin_group)
|
group = groups(:admin_group)
|
||||||
group.update!(custom_claims: { "role": "viewer", "max_items": 10 })
|
group.update!(custom_claims: {role: "viewer", max_items: 10})
|
||||||
user.groups << group
|
user.groups << group
|
||||||
|
|
||||||
# Add user custom claims
|
# Add user custom claims
|
||||||
user.update!(custom_claims: { "role": "editor", "theme": "dark" })
|
user.update!(custom_claims: {role: "editor", theme: "dark"})
|
||||||
|
|
||||||
# Add app-specific claims (should override both)
|
# Add app-specific claims (should override both)
|
||||||
ApplicationUserClaim.create!(
|
ApplicationUserClaim.create!(
|
||||||
user: user,
|
user: user,
|
||||||
application: app,
|
application: app,
|
||||||
custom_claims: { "role": "admin", "app_specific": true }
|
custom_claims: {role: "admin", app_specific: true}
|
||||||
)
|
)
|
||||||
|
|
||||||
token = @service.generate_id_token(user, app)
|
token = @service.generate_id_token(user, app)
|
||||||
@@ -324,11 +324,11 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
|
|||||||
|
|
||||||
# Group has roles: ["user"]
|
# Group has roles: ["user"]
|
||||||
group = groups(:admin_group)
|
group = groups(:admin_group)
|
||||||
group.update!(custom_claims: { "roles" => ["user"], "permissions" => ["read"] })
|
group.update!(custom_claims: {"roles" => ["user"], "permissions" => ["read"]})
|
||||||
user.groups << group
|
user.groups << group
|
||||||
|
|
||||||
# User adds roles: ["admin"]
|
# User adds roles: ["admin"]
|
||||||
user.update!(custom_claims: { "roles" => ["admin"], "permissions" => ["write"] })
|
user.update!(custom_claims: {"roles" => ["admin"], "permissions" => ["write"]})
|
||||||
|
|
||||||
token = @service.generate_id_token(user, app)
|
token = @service.generate_id_token(user, app)
|
||||||
decoded = JWT.decode(token, nil, false).first
|
decoded = JWT.decode(token, nil, false).first
|
||||||
@@ -349,16 +349,16 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
|
|||||||
|
|
||||||
# First group has roles: ["user"]
|
# First group has roles: ["user"]
|
||||||
group1 = groups(:admin_group)
|
group1 = groups(:admin_group)
|
||||||
group1.update!(custom_claims: { "roles" => ["user"] })
|
group1.update!(custom_claims: {"roles" => ["user"]})
|
||||||
user.groups << group1
|
user.groups << group1
|
||||||
|
|
||||||
# Second group has roles: ["moderator"]
|
# Second group has roles: ["moderator"]
|
||||||
group2 = Group.create!(name: "moderators", description: "Moderators group")
|
group2 = Group.create!(name: "moderators", description: "Moderators group")
|
||||||
group2.update!(custom_claims: { "roles" => ["moderator"] })
|
group2.update!(custom_claims: {"roles" => ["moderator"]})
|
||||||
user.groups << group2
|
user.groups << group2
|
||||||
|
|
||||||
# User adds roles: ["admin"]
|
# User adds roles: ["admin"]
|
||||||
user.update!(custom_claims: { "roles" => ["admin"] })
|
user.update!(custom_claims: {"roles" => ["admin"]})
|
||||||
|
|
||||||
token = @service.generate_id_token(user, app)
|
token = @service.generate_id_token(user, app)
|
||||||
decoded = JWT.decode(token, nil, false).first
|
decoded = JWT.decode(token, nil, false).first
|
||||||
@@ -376,11 +376,11 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
|
|||||||
|
|
||||||
# Group has roles: ["user", "reader"]
|
# Group has roles: ["user", "reader"]
|
||||||
group = groups(:admin_group)
|
group = groups(:admin_group)
|
||||||
group.update!(custom_claims: { "roles" => ["user", "reader"] })
|
group.update!(custom_claims: {"roles" => ["user", "reader"]})
|
||||||
user.groups << group
|
user.groups << group
|
||||||
|
|
||||||
# User also has "user" role (duplicate)
|
# User also has "user" role (duplicate)
|
||||||
user.update!(custom_claims: { "roles" => ["user", "admin"] })
|
user.update!(custom_claims: {"roles" => ["user", "admin"]})
|
||||||
|
|
||||||
token = @service.generate_id_token(user, app)
|
token = @service.generate_id_token(user, app)
|
||||||
decoded = JWT.decode(token, nil, false).first
|
decoded = JWT.decode(token, nil, false).first
|
||||||
@@ -398,11 +398,11 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
|
|||||||
|
|
||||||
# Group has roles array and max_items scalar
|
# Group has roles array and max_items scalar
|
||||||
group = groups(:admin_group)
|
group = groups(:admin_group)
|
||||||
group.update!(custom_claims: { "roles" => ["user"], "max_items" => 10, "theme" => "light" })
|
group.update!(custom_claims: {"roles" => ["user"], "max_items" => 10, "theme" => "light"})
|
||||||
user.groups << group
|
user.groups << group
|
||||||
|
|
||||||
# User overrides max_items and theme, adds to roles
|
# User overrides max_items and theme, adds to roles
|
||||||
user.update!(custom_claims: { "roles" => ["admin"], "max_items" => 100, "theme" => "dark" })
|
user.update!(custom_claims: {"roles" => ["admin"], "max_items" => 100, "theme" => "dark"})
|
||||||
|
|
||||||
token = @service.generate_id_token(user, app)
|
token = @service.generate_id_token(user, app)
|
||||||
decoded = JWT.decode(token, nil, false).first
|
decoded = JWT.decode(token, nil, false).first
|
||||||
@@ -425,7 +425,7 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
|
|||||||
group.update!(custom_claims: {
|
group.update!(custom_claims: {
|
||||||
"config" => {
|
"config" => {
|
||||||
"theme" => "light",
|
"theme" => "light",
|
||||||
"notifications" => { "email" => true }
|
"notifications" => {"email" => true}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
user.groups << group
|
user.groups << group
|
||||||
@@ -434,7 +434,7 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
|
|||||||
user.update!(custom_claims: {
|
user.update!(custom_claims: {
|
||||||
"config" => {
|
"config" => {
|
||||||
"language" => "en",
|
"language" => "en",
|
||||||
"notifications" => { "sms" => true }
|
"notifications" => {"sms" => true}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -454,17 +454,17 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
|
|||||||
|
|
||||||
# Group has roles: ["user"]
|
# Group has roles: ["user"]
|
||||||
group = groups(:admin_group)
|
group = groups(:admin_group)
|
||||||
group.update!(custom_claims: { "roles" => ["user"] })
|
group.update!(custom_claims: {"roles" => ["user"]})
|
||||||
user.groups << group
|
user.groups << group
|
||||||
|
|
||||||
# User has roles: ["moderator"]
|
# User has roles: ["moderator"]
|
||||||
user.update!(custom_claims: { "roles" => ["moderator"] })
|
user.update!(custom_claims: {"roles" => ["moderator"]})
|
||||||
|
|
||||||
# App-specific has roles: ["app_admin"]
|
# App-specific has roles: ["app_admin"]
|
||||||
ApplicationUserClaim.create!(
|
ApplicationUserClaim.create!(
|
||||||
user: user,
|
user: user,
|
||||||
application: app,
|
application: app,
|
||||||
custom_claims: { "roles" => ["app_admin"] }
|
custom_claims: {"roles" => ["app_admin"]}
|
||||||
)
|
)
|
||||||
|
|
||||||
token = @service.generate_id_token(user, app)
|
token = @service.generate_id_token(user, app)
|
||||||
|
|||||||
@@ -13,13 +13,13 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
|
|||||||
# End-to-End Authentication Flow Tests
|
# End-to-End Authentication Flow Tests
|
||||||
test "complete forward auth flow with default headers" do
|
test "complete forward auth flow with default headers" do
|
||||||
# Create an application with default headers
|
# Create an application with default headers
|
||||||
rule = Application.create!(name: "App", slug: "app-system-test", app_type: "forward_auth", domain_pattern: "app.example.com", active: true)
|
Application.create!(name: "App", slug: "app-system-test", app_type: "forward_auth", domain_pattern: "app.example.com", active: true)
|
||||||
|
|
||||||
# Step 1: Unauthenticated request to protected resource
|
# Step 1: Unauthenticated request to protected resource
|
||||||
get "/api/verify", headers: {
|
get "/api/verify", headers: {
|
||||||
"X-Forwarded-Host" => "app.example.com",
|
"X-Forwarded-Host" => "app.example.com",
|
||||||
"X-Forwarded-Uri" => "/dashboard"
|
"X-Forwarded-Uri" => "/dashboard"
|
||||||
}, params: { rd: "https://app.example.com/dashboard" }
|
}, params: {rd: "https://app.example.com/dashboard"}
|
||||||
|
|
||||||
assert_response 302
|
assert_response 302
|
||||||
location = response.location
|
location = response.location
|
||||||
@@ -30,13 +30,13 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
|
|||||||
assert_equal "https://app.example.com/dashboard", session[:return_to_after_authenticating]
|
assert_equal "https://app.example.com/dashboard", session[:return_to_after_authenticating]
|
||||||
|
|
||||||
# Step 3: Sign in
|
# Step 3: Sign in
|
||||||
post "/signin", params: { email_address: @user.email_address, password: "password" }
|
post "/signin", params: {email_address: @user.email_address, password: "password"}
|
||||||
|
|
||||||
assert_response 302
|
assert_response 302
|
||||||
assert_redirected_to "https://app.example.com/dashboard"
|
assert_redirected_to "https://app.example.com/dashboard"
|
||||||
|
|
||||||
# Step 4: Authenticated request to protected resource
|
# Step 4: Authenticated request to protected resource
|
||||||
get "/api/verify", headers: { "X-Forwarded-Host" => "app.example.com" }
|
get "/api/verify", headers: {"X-Forwarded-Host" => "app.example.com"}
|
||||||
|
|
||||||
assert_response 200
|
assert_response 200
|
||||||
assert_equal @user.email_address, response.headers["x-remote-user"]
|
assert_equal @user.email_address, response.headers["x-remote-user"]
|
||||||
@@ -46,38 +46,38 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
|
|||||||
|
|
||||||
test "multiple domain access with single session" do
|
test "multiple domain access with single session" do
|
||||||
# Create applications for different domains
|
# Create applications for different domains
|
||||||
app_rule = Application.create!(name: "App Domain", slug: "app-domain", app_type: "forward_auth", domain_pattern: "app.example.com", active: true)
|
Application.create!(name: "App Domain", slug: "app-domain", app_type: "forward_auth", domain_pattern: "app.example.com", active: true)
|
||||||
grafana_rule = Application.create!(
|
Application.create!(
|
||||||
name: "Grafana", slug: "grafana-system-test", app_type: "forward_auth",
|
name: "Grafana", slug: "grafana-system-test", app_type: "forward_auth",
|
||||||
domain_pattern: "grafana.example.com",
|
domain_pattern: "grafana.example.com",
|
||||||
active: true,
|
active: true,
|
||||||
headers_config: { user: "X-WEBAUTH-USER", email: "X-WEBAUTH-EMAIL" }
|
headers_config: {user: "X-WEBAUTH-USER", email: "X-WEBAUTH-EMAIL"}
|
||||||
)
|
)
|
||||||
metube_rule = Application.create!(
|
Application.create!(
|
||||||
name: "Metube", slug: "metube-system-test", app_type: "forward_auth",
|
name: "Metube", slug: "metube-system-test", app_type: "forward_auth",
|
||||||
domain_pattern: "metube.example.com",
|
domain_pattern: "metube.example.com",
|
||||||
active: true,
|
active: true,
|
||||||
headers_config: { user: "", email: "", name: "", groups: "", admin: "" }
|
headers_config: {user: "", email: "", name: "", groups: "", admin: ""}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Sign in once
|
# Sign in once
|
||||||
post "/signin", params: { email_address: @user.email_address, password: "password" }
|
post "/signin", params: {email_address: @user.email_address, password: "password"}
|
||||||
assert_response 302
|
assert_response 302
|
||||||
assert_redirected_to "/"
|
assert_redirected_to "/"
|
||||||
|
|
||||||
# Test access to different applications
|
# Test access to different applications
|
||||||
# App with default headers
|
# App with default headers
|
||||||
get "/api/verify", headers: { "X-Forwarded-Host" => "app.example.com" }
|
get "/api/verify", headers: {"X-Forwarded-Host" => "app.example.com"}
|
||||||
assert_response 200
|
assert_response 200
|
||||||
assert response.headers.key?("x-remote-user")
|
assert response.headers.key?("x-remote-user")
|
||||||
|
|
||||||
# Grafana with custom headers
|
# Grafana with custom headers
|
||||||
get "/api/verify", headers: { "X-Forwarded-Host" => "grafana.example.com" }
|
get "/api/verify", headers: {"X-Forwarded-Host" => "grafana.example.com"}
|
||||||
assert_response 200
|
assert_response 200
|
||||||
assert response.headers.key?("x-webauth-user")
|
assert response.headers.key?("x-webauth-user")
|
||||||
|
|
||||||
# Metube with no headers
|
# Metube with no headers
|
||||||
get "/api/verify", headers: { "X-Forwarded-Host" => "metube.example.com" }
|
get "/api/verify", headers: {"X-Forwarded-Host" => "metube.example.com"}
|
||||||
assert_response 200
|
assert_response 200
|
||||||
auth_headers = response.headers.select { |k, v| k.match?(/^x-remote-|^x-webauth-|^x-admin-/i) }
|
auth_headers = response.headers.select { |k, v| k.match?(/^x-remote-|^x-webauth-|^x-admin-/i) }
|
||||||
assert_empty auth_headers
|
assert_empty auth_headers
|
||||||
@@ -98,11 +98,11 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
|
|||||||
@user.groups << @group
|
@user.groups << @group
|
||||||
|
|
||||||
# Sign in
|
# Sign in
|
||||||
post "/signin", params: { email_address: @user.email_address, password: "password" }
|
post "/signin", params: {email_address: @user.email_address, password: "password"}
|
||||||
assert_response 302
|
assert_response 302
|
||||||
|
|
||||||
# Should have access (in allowed group)
|
# Should have access (in allowed group)
|
||||||
get "/api/verify", headers: { "X-Forwarded-Host" => "admin.example.com" }
|
get "/api/verify", headers: {"X-Forwarded-Host" => "admin.example.com"}
|
||||||
assert_response 200
|
assert_response 200
|
||||||
assert_equal @group.name, response.headers["x-remote-groups"]
|
assert_equal @group.name, response.headers["x-remote-groups"]
|
||||||
|
|
||||||
@@ -110,7 +110,7 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
|
|||||||
@user.groups << @group2
|
@user.groups << @group2
|
||||||
|
|
||||||
# Should show multiple groups
|
# Should show multiple groups
|
||||||
get "/api/verify", headers: { "X-Forwarded-Host" => "admin.example.com" }
|
get "/api/verify", headers: {"X-Forwarded-Host" => "admin.example.com"}
|
||||||
assert_response 200
|
assert_response 200
|
||||||
groups_header = response.headers["x-remote-groups"]
|
groups_header = response.headers["x-remote-groups"]
|
||||||
assert_includes groups_header, @group.name
|
assert_includes groups_header, @group.name
|
||||||
@@ -120,13 +120,13 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
|
|||||||
@user.groups.clear
|
@user.groups.clear
|
||||||
|
|
||||||
# Should be denied
|
# Should be denied
|
||||||
get "/api/verify", headers: { "X-Forwarded-Host" => "admin.example.com" }
|
get "/api/verify", headers: {"X-Forwarded-Host" => "admin.example.com"}
|
||||||
assert_response 403
|
assert_response 403
|
||||||
end
|
end
|
||||||
|
|
||||||
test "bypass mode when no groups assigned to rule" do
|
test "bypass mode when no groups assigned to rule" do
|
||||||
# Create bypass application (no groups)
|
# Create bypass application (no groups)
|
||||||
bypass_rule = Application.create!(
|
Application.create!(
|
||||||
name: "Public", slug: "public-system-test", app_type: "forward_auth",
|
name: "Public", slug: "public-system-test", app_type: "forward_auth",
|
||||||
domain_pattern: "public.example.com",
|
domain_pattern: "public.example.com",
|
||||||
active: true
|
active: true
|
||||||
@@ -136,11 +136,11 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
|
|||||||
@user.groups.clear
|
@user.groups.clear
|
||||||
|
|
||||||
# Sign in
|
# Sign in
|
||||||
post "/signin", params: { email_address: @user.email_address, password: "password" }
|
post "/signin", params: {email_address: @user.email_address, password: "password"}
|
||||||
assert_response 302
|
assert_response 302
|
||||||
|
|
||||||
# Should have access (bypass mode)
|
# Should have access (bypass mode)
|
||||||
get "/api/verify", headers: { "X-Forwarded-Host" => "public.example.com" }
|
get "/api/verify", headers: {"X-Forwarded-Host" => "public.example.com"}
|
||||||
assert_response 200
|
assert_response 200
|
||||||
assert_equal @user.email_address, response.headers["x-remote-user"]
|
assert_equal @user.email_address, response.headers["x-remote-user"]
|
||||||
end
|
end
|
||||||
@@ -148,12 +148,12 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
|
|||||||
# Security System Tests
|
# Security System Tests
|
||||||
test "session security and isolation" do
|
test "session security and isolation" do
|
||||||
# User A signs in
|
# User A signs in
|
||||||
post "/signin", params: { email_address: @user.email_address, password: "password" }
|
post "/signin", params: {email_address: @user.email_address, password: "password"}
|
||||||
user_a_session = cookies[:session_id]
|
user_a_session = cookies[:session_id]
|
||||||
|
|
||||||
# User B signs in
|
# User B signs in
|
||||||
delete "/session"
|
delete "/session"
|
||||||
post "/signin", params: { email_address: @admin_user.email_address, password: "password" }
|
post "/signin", params: {email_address: @admin_user.email_address, password: "password"}
|
||||||
user_b_session = cookies[:session_id]
|
user_b_session = cookies[:session_id]
|
||||||
|
|
||||||
# User A should still be able to access resources
|
# User A should still be able to access resources
|
||||||
@@ -178,11 +178,11 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
|
|||||||
|
|
||||||
test "session expiration and cleanup" do
|
test "session expiration and cleanup" do
|
||||||
# Sign in
|
# Sign in
|
||||||
post "/signin", params: { email_address: @user.email_address, password: "password" }
|
post "/signin", params: {email_address: @user.email_address, password: "password"}
|
||||||
session_id = cookies[:session_id]
|
session_id = cookies[:session_id]
|
||||||
|
|
||||||
# Should work initially
|
# Should work initially
|
||||||
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
|
||||||
assert_response 200
|
assert_response 200
|
||||||
|
|
||||||
# Manually expire session
|
# Manually expire session
|
||||||
@@ -190,7 +190,7 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
|
|||||||
session.update!(expires_at: 1.hour.ago)
|
session.update!(expires_at: 1.hour.ago)
|
||||||
|
|
||||||
# Should redirect to login
|
# Should redirect to login
|
||||||
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
|
||||||
assert_response 302
|
assert_response 302
|
||||||
assert_equal "Session expired", response.headers["x-auth-reason"]
|
assert_equal "Session expired", response.headers["x-auth-reason"]
|
||||||
|
|
||||||
@@ -200,7 +200,7 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
|
|||||||
|
|
||||||
test "concurrent access with rate limiting considerations" do
|
test "concurrent access with rate limiting considerations" do
|
||||||
# Sign in
|
# Sign in
|
||||||
post "/signin", params: { email_address: @user.email_address, password: "password" }
|
post "/signin", params: {email_address: @user.email_address, password: "password"}
|
||||||
session_cookie = cookies[:session_id]
|
session_cookie = cookies[:session_id]
|
||||||
|
|
||||||
# Simulate multiple concurrent requests from different IPs
|
# Simulate multiple concurrent requests from different IPs
|
||||||
@@ -244,23 +244,23 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
|
|||||||
apps = [
|
apps = [
|
||||||
{
|
{
|
||||||
domain: "dashboard.example.com",
|
domain: "dashboard.example.com",
|
||||||
headers_config: { user: "X-DASHBOARD-USER", groups: "X-DASHBOARD-GROUPS" },
|
headers_config: {user: "X-DASHBOARD-USER", groups: "X-DASHBOARD-GROUPS"},
|
||||||
groups: [@group]
|
groups: [@group]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
domain: "api.example.com",
|
domain: "api.example.com",
|
||||||
headers_config: { user: "X-API-USER", email: "X-API-EMAIL" },
|
headers_config: {user: "X-API-USER", email: "X-API-EMAIL"},
|
||||||
groups: []
|
groups: []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
domain: "logs.example.com",
|
domain: "logs.example.com",
|
||||||
headers_config: { user: "", email: "", name: "", groups: "", admin: "" },
|
headers_config: {user: "", email: "", name: "", groups: "", admin: ""},
|
||||||
groups: []
|
groups: []
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
# Create applications for each app
|
# Create applications for each app
|
||||||
rules = apps.map.with_index do |app, idx|
|
apps.map.with_index do |app, idx|
|
||||||
rule = Application.create!(
|
rule = Application.create!(
|
||||||
name: "Multi App #{idx}", slug: "multi-app-#{idx}", app_type: "forward_auth",
|
name: "Multi App #{idx}", slug: "multi-app-#{idx}", app_type: "forward_auth",
|
||||||
domain_pattern: app[:domain],
|
domain_pattern: app[:domain],
|
||||||
@@ -275,12 +275,12 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
|
|||||||
@user.groups << @group
|
@user.groups << @group
|
||||||
|
|
||||||
# Sign in once
|
# Sign in once
|
||||||
post "/signin", params: { email_address: @user.email_address, password: "password" }
|
post "/signin", params: {email_address: @user.email_address, password: "password"}
|
||||||
assert_response 302
|
assert_response 302
|
||||||
|
|
||||||
# Test access to each application
|
# Test access to each application
|
||||||
apps.each do |app|
|
apps.each do |app|
|
||||||
get "/api/verify", headers: { "X-Forwarded-Host" => app[:domain] }
|
get "/api/verify", headers: {"X-Forwarded-Host" => app[:domain]}
|
||||||
assert_response 200, "Failed for #{app[:domain]}"
|
assert_response 200, "Failed for #{app[:domain]}"
|
||||||
|
|
||||||
# Verify headers are correct
|
# Verify headers are correct
|
||||||
@@ -300,24 +300,24 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
|
|||||||
test "domain pattern edge cases" do
|
test "domain pattern edge cases" do
|
||||||
# Test various domain patterns
|
# Test various domain patterns
|
||||||
patterns = [
|
patterns = [
|
||||||
{ pattern: "*.example.com", domains: ["app.example.com", "api.example.com", "sub.app.example.com"] },
|
{pattern: "*.example.com", domains: ["app.example.com", "api.example.com", "sub.app.example.com"]},
|
||||||
{ pattern: "api.*.com", domains: ["api.example.com", "api.test.com"] },
|
{pattern: "api.*.com", domains: ["api.example.com", "api.test.com"]},
|
||||||
{ pattern: "*.*.example.com", domains: ["app.dev.example.com", "api.staging.example.com"] }
|
{pattern: "*.*.example.com", domains: ["app.dev.example.com", "api.staging.example.com"]}
|
||||||
]
|
]
|
||||||
|
|
||||||
patterns.each_with_index do |pattern_config, idx|
|
patterns.each_with_index do |pattern_config, idx|
|
||||||
rule = Application.create!(
|
Application.create!(
|
||||||
name: "Pattern Test #{idx}", slug: "pattern-test-#{idx}", app_type: "forward_auth",
|
name: "Pattern Test #{idx}", slug: "pattern-test-#{idx}", app_type: "forward_auth",
|
||||||
domain_pattern: pattern_config[:pattern],
|
domain_pattern: pattern_config[:pattern],
|
||||||
active: true
|
active: true
|
||||||
)
|
)
|
||||||
|
|
||||||
# Sign in
|
# Sign in
|
||||||
post "/signin", params: { email_address: @user.email_address, password: "password" }
|
post "/signin", params: {email_address: @user.email_address, password: "password"}
|
||||||
|
|
||||||
# Test each domain
|
# Test each domain
|
||||||
pattern_config[:domains].each do |domain|
|
pattern_config[:domains].each do |domain|
|
||||||
get "/api/verify", headers: { "X-Forwarded-Host" => domain }
|
get "/api/verify", headers: {"X-Forwarded-Host" => domain}
|
||||||
assert_response 200, "Failed for pattern #{pattern_config[:pattern]} with domain #{domain}"
|
assert_response 200, "Failed for pattern #{pattern_config[:pattern]} with domain #{domain}"
|
||||||
assert_equal @user.email_address, response.headers["x-remote-user"]
|
assert_equal @user.email_address, response.headers["x-remote-user"]
|
||||||
end
|
end
|
||||||
@@ -330,10 +330,10 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
|
|||||||
# Performance System Tests
|
# Performance System Tests
|
||||||
test "system performance under load" do
|
test "system performance under load" do
|
||||||
# Create test application
|
# Create test application
|
||||||
rule = Application.create!(name: "Load Test", slug: "loadtest", app_type: "forward_auth", domain_pattern: "loadtest.example.com", active: true)
|
Application.create!(name: "Load Test", slug: "loadtest", app_type: "forward_auth", domain_pattern: "loadtest.example.com", active: true)
|
||||||
|
|
||||||
# Sign in
|
# Sign in
|
||||||
post "/signin", params: { email_address: @user.email_address, password: "password" }
|
post "/signin", params: {email_address: @user.email_address, password: "password"}
|
||||||
session_cookie = cookies[:session_id]
|
session_cookie = cookies[:session_id]
|
||||||
|
|
||||||
# Performance test
|
# Performance test
|
||||||
@@ -374,7 +374,7 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
|
|||||||
# Error Recovery System Tests
|
# Error Recovery System Tests
|
||||||
test "graceful degradation with database issues" do
|
test "graceful degradation with database issues" do
|
||||||
# Sign in first
|
# Sign in first
|
||||||
post "/signin", params: { email_address: @user.email_address, password: "password" }
|
post "/signin", params: {email_address: @user.email_address, password: "password"}
|
||||||
assert_response 302
|
assert_response 302
|
||||||
|
|
||||||
# Simulate database connection issue by mocking
|
# Simulate database connection issue by mocking
|
||||||
@@ -387,7 +387,7 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
|
|||||||
|
|
||||||
begin
|
begin
|
||||||
# Request should handle the error gracefully
|
# Request should handle the error gracefully
|
||||||
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
|
||||||
|
|
||||||
# Should return 302 (redirect to login) rather than 500 error
|
# Should return 302 (redirect to login) rather than 500 error
|
||||||
assert_response 302, "Should gracefully handle database issues"
|
assert_response 302, "Should gracefully handle database issues"
|
||||||
@@ -398,7 +398,7 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
|
|||||||
end
|
end
|
||||||
|
|
||||||
# Normal operation should still work
|
# Normal operation should still work
|
||||||
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
|
||||||
assert_response 200
|
assert_response 200
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -78,7 +78,7 @@ class WebauthnSecurityTest < ActionDispatch::SystemTestCase
|
|||||||
user = User.create!(email_address: "webauthn_handle_auth_test@example.com", password: "password123")
|
user = User.create!(email_address: "webauthn_handle_auth_test@example.com", password: "password123")
|
||||||
|
|
||||||
user_handle = SecureRandom.uuid
|
user_handle = SecureRandom.uuid
|
||||||
credential = user.webauthn_credentials.create!(
|
user.webauthn_credentials.create!(
|
||||||
external_id: Base64.urlsafe_encode64("fake_credential_id"),
|
external_id: Base64.urlsafe_encode64("fake_credential_id"),
|
||||||
public_key: Base64.urlsafe_encode64("fake_public_key"),
|
public_key: Base64.urlsafe_encode64("fake_public_key"),
|
||||||
sign_count: 0,
|
sign_count: 0,
|
||||||
@@ -99,7 +99,7 @@ class WebauthnSecurityTest < ActionDispatch::SystemTestCase
|
|||||||
|
|
||||||
test "WebAuthn request validates origin" do
|
test "WebAuthn request validates origin" do
|
||||||
user = User.create!(email_address: "webauthn_origin_test@example.com", password: "password123")
|
user = User.create!(email_address: "webauthn_origin_test@example.com", password: "password123")
|
||||||
credential = user.webauthn_credentials.create!(
|
user.webauthn_credentials.create!(
|
||||||
external_id: Base64.urlsafe_encode64("fake_credential_id"),
|
external_id: Base64.urlsafe_encode64("fake_credential_id"),
|
||||||
public_key: Base64.urlsafe_encode64("fake_public_key"),
|
public_key: Base64.urlsafe_encode64("fake_public_key"),
|
||||||
sign_count: 0,
|
sign_count: 0,
|
||||||
@@ -107,14 +107,14 @@ class WebauthnSecurityTest < ActionDispatch::SystemTestCase
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Test WebAuthn challenge from valid origin
|
# Test WebAuthn challenge from valid origin
|
||||||
post webauthn_challenge_path, params: { email: "webauthn_origin_test@example.com" },
|
post webauthn_challenge_path, params: {email: "webauthn_origin_test@example.com"},
|
||||||
headers: { "HTTP_ORIGIN": "http://localhost:3000" }
|
headers: {HTTP_ORIGIN: "http://localhost:3000"}
|
||||||
|
|
||||||
# Should succeed for valid origin
|
# Should succeed for valid origin
|
||||||
|
|
||||||
# Test WebAuthn challenge from invalid origin
|
# Test WebAuthn challenge from invalid origin
|
||||||
post webauthn_challenge_path, params: { email: "webauthn_origin_test@example.com" },
|
post webauthn_challenge_path, params: {email: "webauthn_origin_test@example.com"},
|
||||||
headers: { "HTTP_ORIGIN": "http://evil.com" }
|
headers: {HTTP_ORIGIN: "http://evil.com"}
|
||||||
|
|
||||||
# Should reject invalid origin
|
# Should reject invalid origin
|
||||||
|
|
||||||
@@ -125,7 +125,7 @@ class WebauthnSecurityTest < ActionDispatch::SystemTestCase
|
|||||||
user = User.create!(email_address: "webauthn_verify_origin_test@example.com", password: "password123")
|
user = User.create!(email_address: "webauthn_verify_origin_test@example.com", password: "password123")
|
||||||
user.update!(webauthn_id: SecureRandom.uuid)
|
user.update!(webauthn_id: SecureRandom.uuid)
|
||||||
|
|
||||||
credential = user.webauthn_credentials.create!(
|
user.webauthn_credentials.create!(
|
||||||
external_id: Base64.urlsafe_encode64("fake_credential_id"),
|
external_id: Base64.urlsafe_encode64("fake_credential_id"),
|
||||||
public_key: Base64.urlsafe_encode64("fake_public_key"),
|
public_key: Base64.urlsafe_encode64("fake_public_key"),
|
||||||
sign_count: 0,
|
sign_count: 0,
|
||||||
@@ -133,10 +133,10 @@ class WebauthnSecurityTest < ActionDispatch::SystemTestCase
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Sign in with WebAuthn
|
# Sign in with WebAuthn
|
||||||
post webauthn_challenge_path, params: { email: "webauthn_verify_origin_test@example.com" }
|
post webauthn_challenge_path, params: {email: "webauthn_verify_origin_test@example.com"}
|
||||||
assert_response :success
|
assert_response :success
|
||||||
|
|
||||||
challenge = JSON.parse(@response.body)["challenge"]
|
JSON.parse(@response.body)["challenge"]
|
||||||
|
|
||||||
# Simulate WebAuthn verification with wrong origin
|
# Simulate WebAuthn verification with wrong origin
|
||||||
# This should fail
|
# This should fail
|
||||||
@@ -155,7 +155,7 @@ class WebauthnSecurityTest < ActionDispatch::SystemTestCase
|
|||||||
# Standard attestation formats: none, packed, tpm, android-key, android-safetynet, fido-u2f, etc.
|
# Standard attestation formats: none, packed, tpm, android-key, android-safetynet, fido-u2f, etc.
|
||||||
|
|
||||||
# Test with 'none' attestation (most common for privacy)
|
# Test with 'none' attestation (most common for privacy)
|
||||||
attestation_object = {
|
{
|
||||||
fmt: "none",
|
fmt: "none",
|
||||||
attStmt: {},
|
attStmt: {},
|
||||||
authData: Base64.strict_encode64("fake_auth_data")
|
authData: Base64.strict_encode64("fake_auth_data")
|
||||||
@@ -170,7 +170,7 @@ class WebauthnSecurityTest < ActionDispatch::SystemTestCase
|
|||||||
user = User.create!(email_address: "webauthn_invalid_attestation_test@example.com", password: "password123")
|
user = User.create!(email_address: "webauthn_invalid_attestation_test@example.com", password: "password123")
|
||||||
|
|
||||||
# Try to register with invalid attestation format
|
# Try to register with invalid attestation format
|
||||||
invalid_attestation = {
|
{
|
||||||
fmt: "invalid_format",
|
fmt: "invalid_format",
|
||||||
attStmt: {},
|
attStmt: {},
|
||||||
authData: Base64.strict_encode64("fake_auth_data")
|
authData: Base64.strict_encode64("fake_auth_data")
|
||||||
@@ -263,7 +263,7 @@ class WebauthnSecurityTest < ActionDispatch::SystemTestCase
|
|||||||
|
|
||||||
test "WebAuthn requires user presence for authentication" do
|
test "WebAuthn requires user presence for authentication" do
|
||||||
user = User.create!(email_address: "webauthn_presence_test@example.com", password: "password123")
|
user = User.create!(email_address: "webauthn_presence_test@example.com", password: "password123")
|
||||||
credential = user.webauthn_credentials.create!(
|
user.webauthn_credentials.create!(
|
||||||
external_id: Base64.urlsafe_encode64("fake_credential_id"),
|
external_id: Base64.urlsafe_encode64("fake_credential_id"),
|
||||||
public_key: Base64.urlsafe_encode64("fake_public_key"),
|
public_key: Base64.urlsafe_encode64("fake_public_key"),
|
||||||
sign_count: 0,
|
sign_count: 0,
|
||||||
@@ -291,7 +291,7 @@ class WebauthnSecurityTest < ActionDispatch::SystemTestCase
|
|||||||
nickname: "USB Key"
|
nickname: "USB Key"
|
||||||
)
|
)
|
||||||
|
|
||||||
credential2 = user.webauthn_credentials.create!(
|
user.webauthn_credentials.create!(
|
||||||
external_id: Base64.urlsafe_encode64("credential_2"),
|
external_id: Base64.urlsafe_encode64("credential_2"),
|
||||||
public_key: Base64.urlsafe_encode64("public_key_2"),
|
public_key: Base64.urlsafe_encode64("public_key_2"),
|
||||||
sign_count: 0,
|
sign_count: 0,
|
||||||
@@ -317,7 +317,7 @@ class WebauthnSecurityTest < ActionDispatch::SystemTestCase
|
|||||||
user.update!(webauthn_enabled: true)
|
user.update!(webauthn_enabled: true)
|
||||||
|
|
||||||
# Sign in with password should still work
|
# Sign in with password should still work
|
||||||
post signin_path, params: { email_address: "webauthn_required_test@example.com", password: "password123" }
|
post signin_path, params: {email_address: "webauthn_required_test@example.com", password: "password123"}
|
||||||
|
|
||||||
# If WebAuthn is enabled, should offer WebAuthn as an option
|
# If WebAuthn is enabled, should offer WebAuthn as an option
|
||||||
# Implementation should handle password + WebAuthn or passwordless flow
|
# Implementation should handle password + WebAuthn or passwordless flow
|
||||||
@@ -329,7 +329,7 @@ class WebauthnSecurityTest < ActionDispatch::SystemTestCase
|
|||||||
user = User.create!(email_address: "webauthn_passwordless_test@example.com", password: "password123")
|
user = User.create!(email_address: "webauthn_passwordless_test@example.com", password: "password123")
|
||||||
user.update!(webauthn_enabled: true)
|
user.update!(webauthn_enabled: true)
|
||||||
|
|
||||||
credential = user.webauthn_credentials.create!(
|
user.webauthn_credentials.create!(
|
||||||
external_id: Base64.urlsafe_encode64("passwordless_credential"),
|
external_id: Base64.urlsafe_encode64("passwordless_credential"),
|
||||||
public_key: Base64.urlsafe_encode64("public_key"),
|
public_key: Base64.urlsafe_encode64("public_key"),
|
||||||
sign_count: 0,
|
sign_count: 0,
|
||||||
|
|||||||
Reference in New Issue
Block a user