Improve some front end views. More descriptive error condition reporting. Updates to CLINCH_HOST for better WEBAUTHN
This commit is contained in:
@@ -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']}"
|
||||||
|
|||||||
@@ -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']
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 %>
|
||||||
|
|||||||
@@ -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 %>
|
||||||
|
|||||||
@@ -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" %>
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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?
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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?
|
||||||
|
|||||||
Reference in New Issue
Block a user