Improve some front end views. More descriptive error condition reporting. Updates to CLINCH_HOST for better WEBAUTHN

This commit is contained in:
Dan Milne
2025-11-12 16:24:05 +11:00
parent 33ad956508
commit 67f28faaca
12 changed files with 114 additions and 24 deletions

View File

@@ -10,6 +10,13 @@ module Api
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
unless csp_report.is_a?(Hash) && csp_report.present?
Rails.logger.warn "Received empty or invalid CSP violation report"
head :bad_request
return
end
# 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']}"

View File

@@ -221,7 +221,9 @@ module Api
# Try CLINCH_HOST environment variable first # Try CLINCH_HOST environment variable first
if ENV['CLINCH_HOST'].present? if ENV['CLINCH_HOST'].present?
"https://#{ENV['CLINCH_HOST']}" host = ENV['CLINCH_HOST']
# Ensure URL has https:// protocol
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']

View File

@@ -134,13 +134,16 @@ module Authentication
original_url = controller_session[:return_to_after_authenticating] original_url = controller_session[:return_to_after_authenticating]
uri = URI.parse(original_url) uri = URI.parse(original_url)
# Add token as query parameter # Skip adding fa_token for OAuth URLs (OAuth flow should not have forward auth tokens)
query_params = URI.decode_www_form(uri.query || "").to_h unless uri.path&.start_with?("/oauth/")
query_params['fa_token'] = token # Add token as query parameter
uri.query = URI.encode_www_form(query_params) query_params = URI.decode_www_form(uri.query || "").to_h
query_params['fa_token'] = token
uri.query = URI.encode_www_form(query_params)
# Update the session with the tokenized URL # Update the session with the tokenized URL
controller_session[:return_to_after_authenticating] = uri.to_s controller_session[:return_to_after_authenticating] = uri.to_s
end
end end
end end
end end

View File

@@ -34,7 +34,7 @@ class OidcController < ApplicationController
# GET /oauth/authorize # GET /oauth/authorize
def authorize def authorize
# Get parameters # Get parameters (ignore forward auth tokens and other unknown params)
client_id = params[:client_id] client_id = params[:client_id]
redirect_uri = params[:redirect_uri] redirect_uri = params[:redirect_uri]
state = params[:state] state = params[:state]
@@ -46,7 +46,12 @@ class OidcController < ApplicationController
# Validate required parameters # Validate required parameters
unless client_id.present? && redirect_uri.present? && response_type == "code" unless client_id.present? && redirect_uri.present? && response_type == "code"
render plain: "Invalid request", status: :bad_request error_details = []
error_details << "client_id is required" unless client_id.present?
error_details << "redirect_uri is required" unless redirect_uri.present?
error_details << "response_type must be 'code'" unless response_type == "code"
render plain: "Invalid request: #{error_details.join(', ')}", status: :bad_request
return return
end end
@@ -67,13 +72,33 @@ class OidcController < ApplicationController
# Find the application # Find the application
@application = Application.find_by(client_id: client_id, app_type: "oidc") @application = Application.find_by(client_id: client_id, app_type: "oidc")
unless @application unless @application
render plain: "Invalid request", status: :bad_request # Log all OIDC applications for debugging
all_oidc_apps = Application.where(app_type: "oidc")
Rails.logger.error "OAuth: Invalid request - application not found for client_id: #{client_id}"
Rails.logger.error "OAuth: Available OIDC applications: #{all_oidc_apps.pluck(:id, :client_id, :name)}"
error_msg = if Rails.env.development?
"Invalid request: Application not found for client_id '#{client_id}'. Available OIDC applications: #{all_oidc_apps.pluck(:name, :client_id).map { |name, id| "#{name} (#{id})" }.join(', ')}"
else
"Invalid request: Application not found"
end
render plain: error_msg, status: :bad_request
return return
end end
# Validate redirect URI # Validate redirect URI
unless @application.parsed_redirect_uris.include?(redirect_uri) unless @application.parsed_redirect_uris.include?(redirect_uri)
render plain: "Invalid request", status: :bad_request Rails.logger.error "OAuth: Invalid request - redirect URI mismatch. Expected: #{@application.parsed_redirect_uris}, Got: #{redirect_uri}"
# For development, show detailed error
error_msg = if Rails.env.development?
"Invalid request: Redirect URI mismatch. Application is configured for: #{@application.parsed_redirect_uris.join(', ')}, but received: #{redirect_uri}"
else
"Invalid request: Redirect URI not registered for this application"
end
render plain: error_msg, status: :bad_request
return return
end end
@@ -139,9 +164,17 @@ class OidcController < ApplicationController
code_challenge_method: code_challenge_method code_challenge_method: code_challenge_method
} }
# Render consent page # Render consent page with dynamic CSP for OAuth redirect
@redirect_uri = redirect_uri @redirect_uri = redirect_uri
@scopes = requested_scopes @scopes = requested_scopes
# Add the redirect URI to CSP form-action for this specific request
# This allows the OAuth redirect to work while maintaining security
if redirect_uri.present?
redirect_host = URI.parse(redirect_uri).host
request.content_security_policy.form_action << "https://#{redirect_host}" if redirect_host
end
render :consent render :consent
end end

View File

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

View File

@@ -37,6 +37,8 @@
<% case application.app_type %> <% case application.app_type %>
<% when "oidc" %> <% when "oidc" %>
<span class="inline-flex items-center rounded-full bg-purple-100 px-2 py-1 text-xs font-medium text-purple-700">OIDC</span> <span class="inline-flex items-center rounded-full bg-purple-100 px-2 py-1 text-xs font-medium text-purple-700">OIDC</span>
<% when "forward_auth" %>
<span class="inline-flex items-center rounded-full bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700">Forward Auth</span>
<% when "saml" %> <% when "saml" %>
<span class="inline-flex items-center rounded-full bg-orange-100 px-2 py-1 text-xs font-medium text-orange-700">SAML</span> <span class="inline-flex items-center rounded-full bg-orange-100 px-2 py-1 text-xs font-medium text-orange-700">SAML</span>
<% end %> <% end %>

View File

@@ -39,9 +39,11 @@
<%= pluralize(group.applications.count, "app") %> <%= pluralize(group.applications.count, "app") %>
</td> </td>
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0"> <td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">
<%= link_to "View", admin_group_path(group), class: "text-blue-600 hover:text-blue-900 mr-4" %> <div class="flex justify-end space-x-3">
<%= link_to "Edit", edit_admin_group_path(group), class: "text-blue-600 hover:text-blue-900 mr-4" %> <%= link_to "View", admin_group_path(group), class: "text-blue-600 hover:text-blue-900 whitespace-nowrap" %>
<%= button_to "Delete", admin_group_path(group), method: :delete, data: { turbo_confirm: "Are you sure you want to delete this group?" }, class: "text-red-600 hover:text-red-900" %> <%= link_to "Edit", edit_admin_group_path(group), class: "text-blue-600 hover:text-blue-900 whitespace-nowrap" %>
<%= button_to "Delete", admin_group_path(group), method: :delete, data: { turbo_confirm: "Are you sure you want to delete this group?" }, class: "text-red-600 hover:text-red-900 whitespace-nowrap" %>
</div>
</td> </td>
</tr> </tr>
<% end %> <% end %>

View File

@@ -57,7 +57,7 @@
</div> </div>
</div> </div>
<%= form_with url: oauth_consent_path, method: :post, class: "space-y-3", data: { turbo: false } do |form| %> <%= form_with url: "/oauth/authorize/consent", method: :post, class: "space-y-3", data: { turbo: false }, local: true do |form| %>
<%= form.submit "Authorize", <%= form.submit "Authorize",
class: "w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %> class: "w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %>

View File

@@ -80,14 +80,28 @@ Rails.application.configure do
# 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)
def self.extract_domain(host)
return host if host.blank?
# Remove protocol (http:// or https://) if present
host.gsub(/^https?:\/\//, '')
end
# Helper method to ensure URL has https:// protocol
def self.ensure_https(url)
return url if url.blank?
# Add https:// if no protocol is present
url.match?(/^https?:\/\//) ? url : "https://#{url}"
end
# 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 = [
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 = 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

View File

@@ -39,6 +39,7 @@ Rails.application.configure do
policy.base_uri :self policy.base_uri :self
# Form actions: Allow self for all form submissions # Form actions: Allow self for all form submissions
# Note: OAuth redirects will be handled dynamically in the consent page
policy.form_action :self policy.form_action :self
# Manifest sources: Allow self for PWA manifest # Manifest sources: Allow self for PWA manifest
@@ -53,9 +54,12 @@ Rails.application.configure do
# Additional security headers for WebAuthn # Additional security headers for WebAuthn
# Required for WebAuthn to work properly # Required for WebAuthn to work properly
policy.require_trusted_types_for :none policy.require_trusted_types_for :none
# CSP reporting using report_uri (supported method)
policy.report_uri "/api/csp-violation-report" policy.report_uri "/api/csp-violation-report"
end end
# Start with CSP in report-only mode for testing # Start with CSP in report-only mode for testing
# Set to false after verifying everything works in production # Set to false after verifying everything works in production
config.content_security_policy_report_only = Rails.env.development? config.content_security_policy_report_only = Rails.env.development?

View File

@@ -23,6 +23,12 @@ Rails.application.config.after_initialize do
def self.emit(event) def self.emit(event)
csp_data = event[:payload] || {} csp_data = event[:payload] || {}
# Skip logging if there's no meaningful violation data
return if csp_data.empty? ||
(csp_data[:violated_directive].nil? &&
csp_data[:blocked_uri].nil? &&
csp_data[:document_uri].nil?)
# Build a structured log message # Build a structured log message
violated_directive = csp_data[:violated_directive] || "unknown" violated_directive = csp_data[:violated_directive] || "unknown"
blocked_uri = csp_data[:blocked_uri] || "unknown" blocked_uri = csp_data[:blocked_uri] || "unknown"

View File

@@ -1,14 +1,31 @@
# WebAuthn configuration for Clinch Identity Provider # WebAuthn configuration for Clinch Identity Provider
WebAuthn.configure do |config| WebAuthn.configure do |config|
# Relying Party name (displayed in authenticator prompts) # Relying Party name (displayed in authenticator prompts)
# For development, use http://localhost to match passkey in Passwords app # CLINCH_HOST should include protocol (https://) for WebAuthn
origin_host = ENV.fetch("CLINCH_HOST", "http://localhost") origin_host = ENV.fetch("CLINCH_HOST", "http://localhost")
config.allowed_origins = [origin_host] config.allowed_origins = [origin_host]
# Relying Party ID (must match origin domain) # Relying Party ID (must match origin domain without protocol)
# Extract domain from origin for RP ID # Extract domain from origin for RP ID if CLINCH_RP_ID not set
origin_uri = URI.parse(origin_host) if ENV["CLINCH_RP_ID"].present?
config.rp_id = ENV.fetch("CLINCH_RP_ID", "localhost") config.rp_id = ENV["CLINCH_RP_ID"]
else
# Extract registrable domain from CLINCH_HOST using PublicSuffix
origin_uri = URI.parse(origin_host)
if origin_uri.host
begin
# Use PublicSuffix to get the registrable domain (e.g., "aapamilne.com" from "auth.aapamilne.com")
domain = PublicSuffix.parse(origin_uri.host)
config.rp_id = domain.domain || origin_uri.host
rescue PublicSuffix::DomainInvalid => e
Rails.logger.warn "WebAuthn: Failed to parse domain '#{origin_uri.host}': #{e.message}, using host as fallback"
config.rp_id = origin_uri.host
end
else
Rails.logger.error "WebAuthn: Could not extract host from CLINCH_HOST '#{origin_host}'"
config.rp_id = "localhost"
end
end
# For development, we also allow localhost with common ports and without port # For development, we also allow localhost with common ports and without port
if Rails.env.development? if Rails.env.development?