StandardRB fixes
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled

This commit is contained in:
Dan Milne
2026-01-01 13:29:44 +11:00
parent 7d3af2bcec
commit 93a0edb0a2
79 changed files with 779 additions and 786 deletions

View File

@@ -32,13 +32,11 @@ module Admin
client_secret = @application.generate_new_client_secret!
end
flash[:notice] = "Application created successfully."
if @application.oidc?
flash[:notice] = "Application created successfully."
flash[:client_id] = @application.client_id
flash[:client_secret] = client_secret if client_secret
flash[:public_client] = true if @application.public_client?
else
flash[:notice] = "Application created successfully."
end
redirect_to admin_application_path(@application)

View File

@@ -8,7 +8,7 @@ module Api
def violation_report
# Parse CSP violation report
report_data = JSON.parse(request.body.read)
csp_report = report_data['csp-report']
csp_report = report_data["csp-report"]
# Validate that we have a proper CSP report
unless csp_report.is_a?(Hash) && csp_report.present?
@@ -19,28 +19,28 @@ module Api
# Log the violation for security monitoring
Rails.logger.warn "CSP Violation Report:"
Rails.logger.warn " Blocked URI: #{csp_report['blocked-uri']}"
Rails.logger.warn " Document URI: #{csp_report['document-uri']}"
Rails.logger.warn " Referrer: #{csp_report['referrer']}"
Rails.logger.warn " Violated Directive: #{csp_report['violated-directive']}"
Rails.logger.warn " Original Policy: #{csp_report['original-policy']}"
Rails.logger.warn " Blocked URI: #{csp_report["blocked-uri"]}"
Rails.logger.warn " Document URI: #{csp_report["document-uri"]}"
Rails.logger.warn " Referrer: #{csp_report["referrer"]}"
Rails.logger.warn " Violated Directive: #{csp_report["violated-directive"]}"
Rails.logger.warn " Original Policy: #{csp_report["original-policy"]}"
Rails.logger.warn " User Agent: #{request.user_agent}"
Rails.logger.warn " IP Address: #{request.remote_ip}"
# Emit structured event for CSP violation
# This allows multiple subscribers to process the event (Sentry, local logging, etc.)
Rails.event.notify("csp.violation", {
blocked_uri: csp_report['blocked-uri'],
document_uri: csp_report['document-uri'],
referrer: csp_report['referrer'],
violated_directive: csp_report['violated-directive'],
original_policy: csp_report['original-policy'],
disposition: csp_report['disposition'],
effective_directive: csp_report['effective-directive'],
source_file: csp_report['source-file'],
line_number: csp_report['line-number'],
column_number: csp_report['column-number'],
status_code: csp_report['status-code'],
blocked_uri: csp_report["blocked-uri"],
document_uri: csp_report["document-uri"],
referrer: csp_report["referrer"],
violated_directive: csp_report["violated-directive"],
original_policy: csp_report["original-policy"],
disposition: csp_report["disposition"],
effective_directive: csp_report["effective-directive"],
source_file: csp_report["source-file"],
line_number: csp_report["line-number"],
column_number: csp_report["column-number"],
status_code: csp_report["status-code"],
user_agent: request.user_agent,
ip_address: request.remote_ip,
current_user_id: Current.user&.id,
@@ -54,4 +54,4 @@ module Api
head :bad_request
end
end
end
end

View File

@@ -81,22 +81,26 @@ module Api
# User is authenticated and authorized
# Return 200 with user information headers using app-specific configuration
headers = app ? app.headers_for_user(user) : Application::DEFAULT_HEADERS.map { |key, header_name|
case key
when :user, :email, :name
[header_name, user.email_address]
when :groups
user.groups.any? ? [header_name, user.groups.pluck(:name).join(",")] : nil
when :admin
[header_name, user.admin? ? "true" : "false"]
end
}.compact.to_h
headers = if app
app.headers_for_user(user)
else
Application::DEFAULT_HEADERS.map { |key, header_name|
case key
when :user, :email, :name
[header_name, user.email_address]
when :groups
user.groups.any? ? [header_name, user.groups.pluck(:name).join(",")] : nil
when :admin
[header_name, user.admin? ? "true" : "false"]
end
}.compact.to_h
end
headers.each { |key, value| response.headers[key] = value }
# Log what headers we're sending (helpful for debugging)
if headers.any?
Rails.logger.debug "ForwardAuth: Headers sent: #{headers.keys.join(', ')}"
Rails.logger.debug "ForwardAuth: Headers sent: #{headers.keys.join(", ")}"
else
Rails.logger.debug "ForwardAuth: No headers sent (access only)"
end
@@ -123,14 +127,13 @@ module Api
# Delete the token immediately (one-time use)
Rails.cache.delete("forward_auth_token:#{token}")
session_id
session_id
end
def extract_session_id
# Extract session ID from cookie
# Rails uses signed cookies by default
session_id = cookies.signed[:session_id]
session_id
cookies.signed[:session_id]
end
def extract_app_from_headers
@@ -155,7 +158,7 @@ module Api
original_uri = request.headers["X-Forwarded-Uri"] || request.headers["X-Forwarded-Path"] || "/"
# Debug logging to see what headers we're getting
Rails.logger.info "ForwardAuth Headers: Host=#{request.headers['Host']}, X-Forwarded-Host=#{original_host}, X-Forwarded-Uri=#{request.headers['X-Forwarded-Uri']}, X-Forwarded-Path=#{request.headers['X-Forwarded-Path']}"
Rails.logger.info "ForwardAuth Headers: Host=#{request.headers["Host"]}, X-Forwarded-Host=#{original_host}, X-Forwarded-Uri=#{request.headers["X-Forwarded-Uri"]}, X-Forwarded-Path=#{request.headers["X-Forwarded-Path"]}"
original_url = if original_host
# Use the forwarded host and URI (original behavior)
@@ -203,7 +206,7 @@ module Api
return nil unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
# Only allow HTTPS in production
return nil unless Rails.env.development? || uri.scheme == 'https'
return nil unless Rails.env.development? || uri.scheme == "https"
redirect_domain = uri.host.downcase
return nil unless redirect_domain.present?
@@ -214,7 +217,6 @@ module Api
end
matching_app ? url : nil
rescue URI::InvalidURIError
nil
end
@@ -233,13 +235,13 @@ module Api
return redirect_url if redirect_url.present?
# Try CLINCH_HOST environment variable first
if ENV['CLINCH_HOST'].present?
host = ENV['CLINCH_HOST']
if ENV["CLINCH_HOST"].present?
host = ENV["CLINCH_HOST"]
# Ensure URL has https:// protocol
host.match?(/^https?:\/\//) ? host : "https://#{host}"
else
# Fallback to the request host
request_host = request.host || request.headers['X-Forwarded-Host']
request_host = request.host || request.headers["X-Forwarded-Host"]
if request_host.present?
Rails.logger.warn "ForwardAuth: CLINCH_HOST not set, using request host: #{request_host}"
"https://#{request_host}"

View File

@@ -1,5 +1,6 @@
class ApplicationController < ActionController::Base
include Authentication
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
allow_browser versions: :modern

View File

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

View File

@@ -1,7 +1,8 @@
class InvitationsController < ApplicationController
include Authentication
allow_unauthenticated_access
before_action :set_user_by_invitation_token, only: %i[ show update ]
before_action :set_user_by_invitation_token, only: %i[show update]
def show
# Show the password setup form
@@ -35,16 +36,16 @@ class InvitationsController < ApplicationController
# Check if user is still pending invitation
if @user.nil?
redirect_to signin_path, alert: "Invitation link is invalid or has expired."
return false
false
elsif @user.pending_invitation?
# User is valid and pending - proceed
return true
true
else
redirect_to signin_path, alert: "This invitation has already been used or is no longer valid."
return false
false
end
rescue ActiveSupport::MessageVerifier::InvalidSignature
redirect_to signin_path, alert: "Invitation link is invalid or has expired."
return false
false
end
end
end

View File

@@ -5,7 +5,7 @@ class OidcController < ApplicationController
# Rate limiting to prevent brute force and abuse
rate_limit to: 60, within: 1.minute, only: [:token, :revoke], with: -> {
render json: { error: "too_many_requests", error_description: "Rate limit exceeded. Try again later." }, status: :too_many_requests
render json: {error: "too_many_requests", error_description: "Rate limit exceeded. Try again later."}, status: :too_many_requests
}
rate_limit to: 30, within: 1.minute, only: [:authorize, :consent], with: -> {
render plain: "Too many authorization attempts. Try again later.", status: :too_many_requests
@@ -63,7 +63,7 @@ class OidcController < ApplicationController
error_details << "redirect_uri is required" unless redirect_uri.present?
error_details << "response_type must be 'code'" unless response_type == "code"
render plain: "Invalid request: #{error_details.join(', ')}", status: :bad_request
render plain: "Invalid request: #{error_details.join(", ")}", status: :bad_request
return
end
@@ -90,7 +90,7 @@ class OidcController < ApplicationController
Rails.logger.error "OAuth: Available OIDC applications: #{all_oidc_apps.pluck(:id, :client_id, :name)}"
error_msg = if Rails.env.development?
"Invalid request: Application not found for client_id '#{client_id}'. Available OIDC applications: #{all_oidc_apps.pluck(:name, :client_id).map { |name, id| "#{name} (#{id})" }.join(', ')}"
"Invalid request: Application not found for client_id '#{client_id}'. Available OIDC applications: #{all_oidc_apps.pluck(:name, :client_id).map { |name, id| "#{name} (#{id})" }.join(", ")}"
else
"Invalid request: Application not found"
end
@@ -105,7 +105,7 @@ class OidcController < ApplicationController
# For development, show detailed error
error_msg = if Rails.env.development?
"Invalid request: Redirect URI mismatch. Application is configured for: #{@application.parsed_redirect_uris.join(', ')}, but received: #{redirect_uri}"
"Invalid request: Redirect URI mismatch. Application is configured for: #{@application.parsed_redirect_uris.join(", ")}, but received: #{redirect_uri}"
else
"Invalid request: Redirect URI not registered for this application"
end
@@ -223,22 +223,22 @@ class OidcController < ApplicationController
# User denied consent
if params[:deny].present?
session.delete(:oauth_params)
error_uri = "#{oauth_params['redirect_uri']}?error=access_denied"
error_uri += "&state=#{CGI.escape(oauth_params['state'])}" if oauth_params['state']
error_uri = "#{oauth_params["redirect_uri"]}?error=access_denied"
error_uri += "&state=#{CGI.escape(oauth_params["state"])}" if oauth_params["state"]
redirect_to error_uri, allow_other_host: true
return
end
# Find the application
client_id = oauth_params['client_id']
client_id = oauth_params["client_id"]
application = Application.find_by(client_id: client_id, app_type: "oidc")
# Check if application is active (redirect with OAuth error)
unless application&.active?
Rails.logger.error "OAuth: Application is not active: #{application&.name || client_id}"
session.delete(:oauth_params)
error_uri = "#{oauth_params['redirect_uri']}?error=unauthorized_client&error_description=Application+is+not+active"
error_uri += "&state=#{CGI.escape(oauth_params['state'])}" if oauth_params['state'].present?
error_uri = "#{oauth_params["redirect_uri"]}?error=unauthorized_client&error_description=Application+is+not+active"
error_uri += "&state=#{CGI.escape(oauth_params["state"])}" if oauth_params["state"].present?
redirect_to error_uri, allow_other_host: true
return
end
@@ -246,9 +246,9 @@ class OidcController < ApplicationController
user = Current.session.user
# Record user consent
requested_scopes = oauth_params['scope'].split(' ')
requested_scopes = oauth_params["scope"].split(" ")
consent = OidcUserConsent.find_or_initialize_by(user: user, application: application)
consent.scopes_granted = requested_scopes.join(' ')
consent.scopes_granted = requested_scopes.join(" ")
consent.granted_at = Time.current
consent.save!
@@ -256,11 +256,11 @@ class OidcController < ApplicationController
auth_code = OidcAuthorizationCode.create!(
application: application,
user: user,
redirect_uri: oauth_params['redirect_uri'],
scope: oauth_params['scope'],
nonce: oauth_params['nonce'],
code_challenge: oauth_params['code_challenge'],
code_challenge_method: oauth_params['code_challenge_method'],
redirect_uri: oauth_params["redirect_uri"],
scope: oauth_params["scope"],
nonce: oauth_params["nonce"],
code_challenge: oauth_params["code_challenge"],
code_challenge_method: oauth_params["code_challenge_method"],
auth_time: Current.session.created_at.to_i,
acr: Current.session.acr,
expires_at: 10.minutes.from_now
@@ -270,8 +270,8 @@ class OidcController < ApplicationController
session.delete(:oauth_params)
# Redirect back to client with authorization code (plaintext)
redirect_uri = "#{oauth_params['redirect_uri']}?code=#{auth_code.plaintext_code}"
redirect_uri += "&state=#{CGI.escape(oauth_params['state'])}" if oauth_params['state']
redirect_uri = "#{oauth_params["redirect_uri"]}?code=#{auth_code.plaintext_code}"
redirect_uri += "&state=#{CGI.escape(oauth_params["state"])}" if oauth_params["state"]
redirect_to redirect_uri, allow_other_host: true
end
@@ -286,7 +286,7 @@ class OidcController < ApplicationController
when "refresh_token"
handle_refresh_token_grant
else
render json: { error: "unsupported_grant_type" }, status: :bad_request
render json: {error: "unsupported_grant_type"}, status: :bad_request
end
end
@@ -295,14 +295,14 @@ class OidcController < ApplicationController
client_id, client_secret = extract_client_credentials
unless client_id
render json: { error: "invalid_client", error_description: "client_id is required" }, status: :unauthorized
render json: {error: "invalid_client", error_description: "client_id is required"}, status: :unauthorized
return
end
# Find the application
application = Application.find_by(client_id: client_id)
unless application
render json: { error: "invalid_client", error_description: "Unknown client" }, status: :unauthorized
render json: {error: "invalid_client", error_description: "Unknown client"}, status: :unauthorized
return
end
@@ -313,7 +313,7 @@ class OidcController < ApplicationController
else
# Confidential clients MUST provide valid client_secret
unless client_secret.present? && application.authenticate_client_secret(client_secret)
render json: { error: "invalid_client", error_description: "Invalid client credentials" }, status: :unauthorized
render json: {error: "invalid_client", error_description: "Invalid client credentials"}, status: :unauthorized
return
end
end
@@ -321,7 +321,7 @@ class OidcController < ApplicationController
# Check if application is active
unless application.active?
Rails.logger.error "OAuth: Token request for inactive application: #{application.name}"
render json: { error: "invalid_client", error_description: "Application is not active" }, status: :forbidden
render json: {error: "invalid_client", error_description: "Application is not active"}, status: :forbidden
return
end
@@ -334,7 +334,7 @@ class OidcController < ApplicationController
auth_code = OidcAuthorizationCode.find_by_plaintext(code)
unless auth_code && auth_code.application == application
render json: { error: "invalid_grant" }, status: :bad_request
render json: {error: "invalid_grant"}, status: :bad_request
return
end
@@ -365,13 +365,13 @@ class OidcController < ApplicationController
# Check if code is expired
if auth_code.expires_at < Time.current
render json: { error: "invalid_grant", error_description: "Authorization code expired" }, status: :bad_request
render json: {error: "invalid_grant", error_description: "Authorization code expired"}, status: :bad_request
return
end
# Validate redirect URI matches
unless auth_code.redirect_uri == redirect_uri
render json: { error: "invalid_grant", error_description: "Redirect URI mismatch" }, status: :bad_request
render json: {error: "invalid_grant", error_description: "Redirect URI mismatch"}, status: :bad_request
return
end
@@ -413,7 +413,7 @@ class OidcController < ApplicationController
unless consent
Rails.logger.error "OIDC Security: Token requested without consent record (user: #{user.id}, app: #{application.id})"
render json: { error: "invalid_grant", error_description: "Authorization consent not found" }, status: :bad_request
render json: {error: "invalid_grant", error_description: "Authorization consent not found"}, status: :bad_request
return
end
@@ -440,7 +440,7 @@ class OidcController < ApplicationController
}
end
rescue ActiveRecord::RecordNotFound
render json: { error: "invalid_grant" }, status: :bad_request
render json: {error: "invalid_grant"}, status: :bad_request
end
end
@@ -449,14 +449,14 @@ class OidcController < ApplicationController
client_id, client_secret = extract_client_credentials
unless client_id
render json: { error: "invalid_client", error_description: "client_id is required" }, status: :unauthorized
render json: {error: "invalid_client", error_description: "client_id is required"}, status: :unauthorized
return
end
# Find the application
application = Application.find_by(client_id: client_id)
unless application
render json: { error: "invalid_client", error_description: "Unknown client" }, status: :unauthorized
render json: {error: "invalid_client", error_description: "Unknown client"}, status: :unauthorized
return
end
@@ -467,7 +467,7 @@ class OidcController < ApplicationController
else
# Confidential clients MUST provide valid client_secret
unless client_secret.present? && application.authenticate_client_secret(client_secret)
render json: { error: "invalid_client", error_description: "Invalid client credentials" }, status: :unauthorized
render json: {error: "invalid_client", error_description: "Invalid client credentials"}, status: :unauthorized
return
end
end
@@ -475,14 +475,14 @@ class OidcController < ApplicationController
# Check if application is active
unless application.active?
Rails.logger.error "OAuth: Refresh token request for inactive application: #{application.name}"
render json: { error: "invalid_client", error_description: "Application is not active" }, status: :forbidden
render json: {error: "invalid_client", error_description: "Application is not active"}, status: :forbidden
return
end
# Get the refresh token
refresh_token = params[:refresh_token]
unless refresh_token.present?
render json: { error: "invalid_request", error_description: "refresh_token is required" }, status: :bad_request
render json: {error: "invalid_request", error_description: "refresh_token is required"}, status: :bad_request
return
end
@@ -491,13 +491,13 @@ class OidcController < ApplicationController
# Verify the token belongs to the correct application
unless refresh_token_record && refresh_token_record.application == application
render json: { error: "invalid_grant", error_description: "Invalid refresh token" }, status: :bad_request
render json: {error: "invalid_grant", error_description: "Invalid refresh token"}, status: :bad_request
return
end
# Check if refresh token is expired
if refresh_token_record.expired?
render json: { error: "invalid_grant", error_description: "Refresh token expired" }, status: :bad_request
render json: {error: "invalid_grant", error_description: "Refresh token expired"}, status: :bad_request
return
end
@@ -508,7 +508,7 @@ class OidcController < ApplicationController
Rails.logger.warn "OAuth Security: Revoked refresh token reuse detected for token family #{refresh_token_record.token_family_id}"
refresh_token_record.revoke_family!
render json: { error: "invalid_grant", error_description: "Refresh token has been revoked" }, status: :bad_request
render json: {error: "invalid_grant", error_description: "Refresh token has been revoked"}, status: :bad_request
return
end
@@ -541,7 +541,7 @@ class OidcController < ApplicationController
unless consent
Rails.logger.error "OIDC Security: Refresh token used without consent record (user: #{user.id}, app: #{application.id})"
render json: { error: "invalid_grant", error_description: "Authorization consent not found" }, status: :bad_request
render json: {error: "invalid_grant", error_description: "Authorization consent not found"}, status: :bad_request
return
end
@@ -566,7 +566,7 @@ class OidcController < ApplicationController
scope: refresh_token_record.scope
}
rescue ActiveRecord::RecordNotFound
render json: { error: "invalid_grant" }, status: :bad_request
render json: {error: "invalid_grant"}, status: :bad_request
end
# GET /oauth/userinfo
@@ -650,7 +650,7 @@ class OidcController < ApplicationController
# Find and validate the application
application = Application.find_by(client_id: client_id)
unless application && application.authenticate_client_secret(client_secret)
unless application&.authenticate_client_secret(client_secret)
Rails.logger.warn "OAuth: Token revocation attempted for invalid application: #{client_id}"
head :ok
return
@@ -669,7 +669,7 @@ class OidcController < ApplicationController
unless token.present?
# RFC 7009: Missing token parameter is an error
render json: { error: "invalid_request", error_description: "token parameter is required" }, status: :bad_request
render json: {error: "invalid_request", error_description: "token parameter is required"}, status: :bad_request
return
end
@@ -695,7 +695,7 @@ class OidcController < ApplicationController
if access_token_record
access_token_record.revoke!
Rails.logger.info "OAuth: Access token revoked for application #{application.name}"
revoked = true
true
end
end
@@ -709,7 +709,7 @@ class OidcController < ApplicationController
# OpenID Connect RP-Initiated Logout
# Handle id_token_hint and post_logout_redirect_uri parameters
id_token_hint = params[:id_token_hint]
params[:id_token_hint]
post_logout_redirect_uri = params[:post_logout_redirect_uri]
state = params[:state]
@@ -763,7 +763,7 @@ class OidcController < ApplicationController
end
# Skip validation if no code challenge was stored (legacy clients without PKCE requirement)
return { valid: true } unless pkce_provided
return {valid: true} unless pkce_provided
# PKCE was provided during authorization but no verifier sent with token request
unless code_verifier.present?
@@ -787,18 +787,18 @@ class OidcController < ApplicationController
# Recreate code challenge based on method
expected_challenge = case auth_code.code_challenge_method
when "plain"
code_verifier
when "S256"
Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false)
else
return {
valid: false,
error: "server_error",
error_description: "Unsupported code challenge method",
status: :internal_server_error
}
end
when "plain"
code_verifier
when "S256"
Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false)
else
return {
valid: false,
error: "server_error",
error_description: "Unsupported code challenge method",
status: :internal_server_error
}
end
# Validate the code challenge
unless auth_code.code_challenge == expected_challenge
@@ -810,7 +810,7 @@ class OidcController < ApplicationController
}
end
{ valid: true }
{valid: true}
end
def extract_client_credentials
@@ -835,7 +835,7 @@ class OidcController < ApplicationController
return nil unless parsed_uri.is_a?(URI::HTTP) || parsed_uri.is_a?(URI::HTTPS)
# Only allow HTTPS in production
return nil if Rails.env.production? && parsed_uri.scheme != 'https'
return nil if Rails.env.production? && parsed_uri.scheme != "https"
# Check if URI matches any registered OIDC application's redirect URIs
# According to OIDC spec, post_logout_redirect_uri should be pre-registered

View File

@@ -1,22 +1,22 @@
class SessionsController < ApplicationController
allow_unauthenticated_access only: %i[ new create verify_totp webauthn_challenge webauthn_verify ]
allow_unauthenticated_access only: %i[new create verify_totp webauthn_challenge webauthn_verify]
rate_limit to: 20, within: 3.minutes, only: :create, with: -> { redirect_to signin_path, alert: "Too many attempts. Try again later." }
rate_limit to: 10, within: 3.minutes, only: :verify_totp, with: -> { redirect_to totp_verification_path, alert: "Too many attempts. Try again later." }
rate_limit to: 10, within: 3.minutes, only: [:webauthn_challenge, :webauthn_verify], with: -> { render json: { error: "Too many attempts. Try again later." }, status: :too_many_requests }
rate_limit to: 10, within: 3.minutes, only: [:webauthn_challenge, :webauthn_verify], with: -> { render json: {error: "Too many attempts. Try again later."}, status: :too_many_requests }
def new
# Redirect to signup if this is first run
if User.count.zero?
respond_to do |format|
format.html { redirect_to signup_path }
format.json { render json: { error: "No users exist. Please complete initial setup." }, status: :service_unavailable }
format.json { render json: {error: "No users exist. Please complete initial setup."}, status: :service_unavailable }
end
return
end
respond_to do |format|
format.html # render HTML login page
format.json { render json: { error: "Authentication required" }, status: :unauthorized }
format.json { render json: {error: "Authentication required"}, status: :unauthorized }
end
end
@@ -127,7 +127,7 @@ class SessionsController < ApplicationController
# Invalid code
redirect_to totp_verification_path, alert: "Invalid verification code. Please try again."
return
nil
end
# Just render the form
@@ -155,14 +155,14 @@ class SessionsController < ApplicationController
email = params[:email]&.strip&.downcase
if email.blank?
render json: { error: "Email is required" }, status: :unprocessable_entity
render json: {error: "Email is required"}, status: :unprocessable_entity
return
end
user = User.find_by(email_address: email)
if user.nil? || !user.can_authenticate_with_webauthn?
render json: { error: "User not found or WebAuthn not available" }, status: :unprocessable_entity
render json: {error: "User not found or WebAuthn not available"}, status: :unprocessable_entity
return
end
@@ -191,10 +191,9 @@ class SessionsController < ApplicationController
session[:webauthn_challenge] = options.challenge
render json: options
rescue => e
Rails.logger.error "WebAuthn challenge generation error: #{e.message}"
render json: { error: "Failed to generate WebAuthn challenge" }, status: :internal_server_error
render json: {error: "Failed to generate WebAuthn challenge"}, status: :internal_server_error
end
end
@@ -202,21 +201,21 @@ class SessionsController < ApplicationController
# Get pending user from session
user_id = session[:pending_webauthn_user_id]
unless user_id
render json: { error: "Session expired. Please try again." }, status: :unprocessable_entity
render json: {error: "Session expired. Please try again."}, status: :unprocessable_entity
return
end
user = User.find_by(id: user_id)
unless user
session.delete(:pending_webauthn_user_id)
render json: { error: "Session expired. Please try again." }, status: :unprocessable_entity
render json: {error: "Session expired. Please try again."}, status: :unprocessable_entity
return
end
# Get the credential and assertion from params
credential_data = params[:credential]
if credential_data.blank?
render json: { error: "Credential data is required" }, status: :unprocessable_entity
render json: {error: "Credential data is required"}, status: :unprocessable_entity
return
end
@@ -224,7 +223,7 @@ class SessionsController < ApplicationController
challenge = session.delete(:webauthn_challenge)
if challenge.blank?
render json: { error: "Invalid or expired session" }, status: :unprocessable_entity
render json: {error: "Invalid or expired session"}, status: :unprocessable_entity
return
end
@@ -237,7 +236,7 @@ class SessionsController < ApplicationController
stored_credential = user.webauthn_credential_for(external_id)
if stored_credential.nil?
render json: { error: "Credential not found" }, status: :unprocessable_entity
render json: {error: "Credential not found"}, status: :unprocessable_entity
return
end
@@ -276,16 +275,15 @@ class SessionsController < ApplicationController
redirect_to: after_authentication_url,
message: "Signed in successfully with passkey"
}
rescue WebAuthn::Error => e
Rails.logger.error "WebAuthn verification error: #{e.message}"
render json: { error: "Authentication failed: #{e.message}" }, status: :unprocessable_entity
render json: {error: "Authentication failed: #{e.message}"}, status: :unprocessable_entity
rescue JSON::ParserError => e
Rails.logger.error "WebAuthn JSON parsing error: #{e.message}"
render json: { error: "Invalid credential format" }, status: :unprocessable_entity
render json: {error: "Invalid credential format"}, status: :unprocessable_entity
rescue => e
Rails.logger.error "Unexpected WebAuthn verification error: #{e.class} - #{e.message}"
render json: { error: "An unexpected error occurred" }, status: :internal_server_error
render json: {error: "An unexpected error occurred"}, status: :internal_server_error
end
end
@@ -301,7 +299,7 @@ class SessionsController < ApplicationController
return nil unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
# Only allow HTTPS in production
return nil unless Rails.env.development? || uri.scheme == 'https'
return nil unless Rails.env.development? || uri.scheme == "https"
redirect_domain = uri.host.downcase
return nil unless redirect_domain.present?
@@ -312,7 +310,6 @@ class SessionsController < ApplicationController
end
matching_app ? url : nil
rescue URI::InvalidURIError
nil
end

View File

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

View File

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