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

View File

@@ -6,10 +6,10 @@ module ApplicationHelper
smtp_port = ENV["SMTP_PORT"]
smtp_address.present? &&
smtp_port.present? &&
smtp_address != "localhost" &&
!smtp_address.start_with?("127.0.0.1") &&
!smtp_address.start_with?("localhost")
smtp_port.present? &&
smtp_address != "localhost" &&
!smtp_address.start_with?("127.0.0.1") &&
!smtp_address.start_with?("localhost")
end
def email_delivery_method
@@ -22,11 +22,11 @@ module ApplicationHelper
def border_class_for(type)
case type.to_s
when 'notice' then 'border-green-200'
when 'alert', 'error' then 'border-red-200'
when 'warning' then 'border-yellow-200'
when 'info' then 'border-blue-200'
else 'border-gray-200'
when "notice" then "border-green-200"
when "alert", "error" then "border-red-200"
when "warning" then "border-yellow-200"
when "info" then "border-blue-200"
else "border-gray-200"
end
end
end

View File

@@ -25,9 +25,7 @@ module ClaimsHelper
claims = deep_merge_claims(claims, user.parsed_custom_claims)
# Merge app-specific claims (arrays are combined)
claims = deep_merge_claims(claims, application.custom_claims_for_user(user))
claims
deep_merge_claims(claims, application.custom_claims_for_user(user))
end
# Get claim sources breakdown for display

View File

@@ -29,10 +29,10 @@ class BackchannelLogoutJob < ApplicationJob
uri = URI.parse(application.backchannel_logout_uri)
begin
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https', open_timeout: 5, read_timeout: 5) do |http|
request = Net::HTTP::Post.new(uri.path.presence || '/')
request['Content-Type'] = 'application/x-www-form-urlencoded'
request.set_form_data({ logout_token: logout_token })
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 5, read_timeout: 5) do |http|
request = Net::HTTP::Post.new(uri.path.presence || "/")
request["Content-Type"] = "application/x-www-form-urlencoded"
request.set_form_data({logout_token: logout_token})
http.request(request)
end
@@ -44,7 +44,7 @@ class BackchannelLogoutJob < ApplicationJob
rescue Net::OpenTimeout, Net::ReadTimeout => e
Rails.logger.warn "BackchannelLogout: Timeout sending logout to #{application.name} (#{application.backchannel_logout_uri}): #{e.message}"
raise # Retry on timeout
rescue StandardError => e
rescue => e
Rails.logger.error "BackchannelLogout: Failed to send logout to #{application.name} (#{application.backchannel_logout_uri}): #{e.class} - #{e.message}"
raise # Retry on error
end

View File

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

View File

@@ -3,4 +3,4 @@ class InvitationsMailer < ApplicationMailer
@user = user
mail subject: "You're invited to join Clinch", to: user.email_address
end
end
end

View File

@@ -19,16 +19,16 @@ class Application < ApplicationRecord
has_many :oidc_user_consents, dependent: :destroy
validates :name, presence: true
validates :slug, presence: true, uniqueness: { case_sensitive: false },
format: { with: /\A[a-z0-9\-]+\z/, message: "only lowercase letters, numbers, and hyphens" }
validates :slug, presence: true, uniqueness: {case_sensitive: false},
format: {with: /\A[a-z0-9-]+\z/, message: "only lowercase letters, numbers, and hyphens"}
validates :app_type, presence: true,
inclusion: { in: %w[oidc forward_auth] }
validates :client_id, uniqueness: { allow_nil: true }
inclusion: {in: %w[oidc forward_auth]}
validates :client_id, uniqueness: {allow_nil: true}
validates :client_secret, presence: true, on: :create, if: -> { oidc? && confidential_client? }
validates :domain_pattern, presence: true, uniqueness: { case_sensitive: false }, if: :forward_auth?
validates :landing_url, format: { with: URI::regexp(%w[http https]), allow_nil: true, message: "must be a valid URL" }
validates :domain_pattern, presence: true, uniqueness: {case_sensitive: false}, if: :forward_auth?
validates :landing_url, format: {with: URI::RFC2396_PARSER.make_regexp(%w[http https]), allow_nil: true, message: "must be a valid URL"}
validates :backchannel_logout_uri, format: {
with: URI::regexp(%w[http https]),
with: URI::RFC2396_PARSER.make_regexp(%w[http https]),
allow_nil: true,
message: "must be a valid HTTP or HTTPS URL"
}
@@ -38,9 +38,9 @@ class Application < ApplicationRecord
validate :icon_validation, if: -> { icon.attached? }
# Token TTL validations (for OIDC apps)
validates :access_token_ttl, numericality: { greater_than_or_equal_to: 300, less_than_or_equal_to: 86400 }, if: :oidc? # 5 min - 24 hours
validates :refresh_token_ttl, numericality: { greater_than_or_equal_to: 86400, less_than_or_equal_to: 7776000 }, if: :oidc? # 1 day - 90 days
validates :id_token_ttl, numericality: { greater_than_or_equal_to: 300, less_than_or_equal_to: 86400 }, if: :oidc? # 5 min - 24 hours
validates :access_token_ttl, numericality: {greater_than_or_equal_to: 300, less_than_or_equal_to: 86400}, if: :oidc? # 5 min - 24 hours
validates :refresh_token_ttl, numericality: {greater_than_or_equal_to: 86400, less_than_or_equal_to: 7776000}, if: :oidc? # 1 day - 90 days
validates :id_token_ttl, numericality: {greater_than_or_equal_to: 300, less_than_or_equal_to: 86400}, if: :oidc? # 5 min - 24 hours
normalizes :slug, with: ->(slug) { slug.strip.downcase }
normalizes :domain_pattern, with: ->(pattern) {
@@ -56,11 +56,11 @@ class Application < ApplicationRecord
# Default header configuration for ForwardAuth
DEFAULT_HEADERS = {
user: 'X-Remote-User',
email: 'X-Remote-Email',
name: 'X-Remote-Name',
groups: 'X-Remote-Groups',
admin: 'X-Remote-Admin'
user: "X-Remote-User",
email: "X-Remote-Email",
name: "X-Remote-Name",
groups: "X-Remote-Groups",
admin: "X-Remote-Admin"
}.freeze
# Scopes
@@ -135,8 +135,8 @@ class Application < ApplicationRecord
def matches_domain?(domain)
return false if domain.blank? || !forward_auth?
pattern = domain_pattern.gsub('.', '\.')
pattern = pattern.gsub('*', '[^.]*')
pattern = domain_pattern.gsub(".", '\.')
pattern = pattern.gsub("*", "[^.]*")
regex = Regexp.new("^#{pattern}$", Regexp::IGNORECASE)
regex.match?(domain.downcase)
@@ -144,18 +144,18 @@ class Application < ApplicationRecord
# Policy determination based on user status (for ForwardAuth)
def policy_for_user(user)
return 'deny' unless active?
return 'deny' unless user.active?
return "deny" unless active?
return "deny" unless user.active?
# If no groups specified, bypass authentication
return 'bypass' if allowed_groups.empty?
return "bypass" if allowed_groups.empty?
# If user is in allowed groups, determine auth level
if user_allowed?(user)
# Require 2FA if user has TOTP configured, otherwise one factor
user.totp_enabled? ? 'two_factor' : 'one_factor'
user.totp_enabled? ? "two_factor" : "one_factor"
else
'deny'
"deny"
end
end
@@ -197,7 +197,7 @@ class Application < ApplicationRecord
def generate_new_client_secret!
secret = SecureRandom.urlsafe_base64(48)
self.client_secret = secret
self.save!
save!
secret
end
@@ -242,7 +242,7 @@ class Application < ApplicationRecord
# (i.e., has valid, non-revoked tokens)
def user_has_active_session?(user)
oidc_access_tokens.where(user: user).valid.exists? ||
oidc_refresh_tokens.where(user: user).valid.exists?
oidc_refresh_tokens.where(user: user).valid.exists?
end
private
@@ -260,14 +260,14 @@ class Application < ApplicationRecord
return unless icon.attached?
# Check content type
allowed_types = ['image/png', 'image/jpg', 'image/jpeg', 'image/gif', 'image/svg+xml']
allowed_types = ["image/png", "image/jpg", "image/jpeg", "image/gif", "image/svg+xml"]
unless allowed_types.include?(icon.content_type)
errors.add(:icon, 'must be a PNG, JPG, GIF, or SVG image')
errors.add(:icon, "must be a PNG, JPG, GIF, or SVG image")
end
# Check file size (2MB limit)
if icon.blob.byte_size > 2.megabytes
errors.add(:icon, 'must be less than 2MB')
errors.add(:icon, "must be less than 2MB")
end
end
@@ -302,8 +302,8 @@ class Application < ApplicationRecord
begin
uri = URI.parse(backchannel_logout_uri)
unless uri.scheme == 'https'
errors.add(:backchannel_logout_uri, 'must use HTTPS in production')
unless uri.scheme == "https"
errors.add(:backchannel_logout_uri, "must use HTTPS in production")
end
rescue URI::InvalidURIError
# Let the format validator handle invalid URIs

View File

@@ -2,5 +2,5 @@ class ApplicationGroup < ApplicationRecord
belongs_to :application
belongs_to :group
validates :application_id, uniqueness: { scope: :group_id }
validates :application_id, uniqueness: {scope: :group_id}
end

View File

@@ -9,7 +9,7 @@ class ApplicationUserClaim < ApplicationRecord
groups
].freeze
validates :user_id, uniqueness: { scope: :application_id }
validates :user_id, uniqueness: {scope: :application_id}
validate :no_reserved_claim_names
# Parse custom_claims JSON field
@@ -25,7 +25,7 @@ class ApplicationUserClaim < ApplicationRecord
reserved_used = parsed_custom_claims.keys.map(&:to_s) & RESERVED_CLAIMS
if reserved_used.any?
errors.add(:custom_claims, "cannot override reserved OIDC claims: #{reserved_used.join(', ')}")
errors.add(:custom_claims, "cannot override reserved OIDC claims: #{reserved_used.join(", ")}")
end
end
end

View File

@@ -11,7 +11,7 @@ class Group < ApplicationRecord
groups
].freeze
validates :name, presence: true, uniqueness: { case_sensitive: false }
validates :name, presence: true, uniqueness: {case_sensitive: false}
normalizes :name, with: ->(name) { name.strip.downcase }
validate :no_reserved_claim_names
@@ -28,7 +28,7 @@ class Group < ApplicationRecord
reserved_used = parsed_custom_claims.keys.map(&:to_s) & RESERVED_CLAIMS
if reserved_used.any?
errors.add(:custom_claims, "cannot override reserved OIDC claims: #{reserved_used.join(', ')}")
errors.add(:custom_claims, "cannot override reserved OIDC claims: #{reserved_used.join(", ")}")
end
end
end

View File

@@ -25,7 +25,7 @@ class OidcAccessToken < ApplicationRecord
# Compute HMAC for token lookup
def self.compute_token_hmac(plaintext_token)
OpenSSL::HMAC.hexdigest('SHA256', TokenHmac::KEY, plaintext_token)
OpenSSL::HMAC.hexdigest("SHA256", TokenHmac::KEY, plaintext_token)
end
def expired?

View File

@@ -9,7 +9,7 @@ class OidcAuthorizationCode < ApplicationRecord
validates :code_hmac, presence: true, uniqueness: true
validates :redirect_uri, presence: true
validates :code_challenge_method, inclusion: { in: %w[plain S256], allow_nil: true }
validates :code_challenge_method, inclusion: {in: %w[plain S256], allow_nil: true}
validate :validate_code_challenge_format, if: -> { code_challenge.present? }
scope :valid, -> { where(used: false).where("expires_at > ?", Time.current) }
@@ -25,7 +25,7 @@ class OidcAuthorizationCode < ApplicationRecord
# Compute HMAC for code lookup
def self.compute_code_hmac(plaintext_code)
OpenSSL::HMAC.hexdigest('SHA256', TokenHmac::KEY, plaintext_code)
OpenSSL::HMAC.hexdigest("SHA256", TokenHmac::KEY, plaintext_code)
end
def expired?

View File

@@ -29,7 +29,7 @@ class OidcRefreshToken < ApplicationRecord
# Compute HMAC for token lookup
def self.compute_token_hmac(plaintext_token)
OpenSSL::HMAC.hexdigest('SHA256', TokenHmac::KEY, plaintext_token)
OpenSSL::HMAC.hexdigest("SHA256", TokenHmac::KEY, plaintext_token)
end
def expired?

View File

@@ -3,19 +3,19 @@ class OidcUserConsent < ApplicationRecord
belongs_to :application
validates :user, :application, :scopes_granted, :granted_at, presence: true
validates :user_id, uniqueness: { scope: :application_id }
validates :user_id, uniqueness: {scope: :application_id}
before_validation :set_granted_at, on: :create
before_validation :set_sid, on: :create
# Parse scopes_granted into an array
def scopes
scopes_granted.split(' ')
scopes_granted.split(" ")
end
# Set scopes from an array
def scopes=(scope_array)
self.scopes_granted = Array(scope_array).uniq.join(' ')
self.scopes_granted = Array(scope_array).uniq.join(" ")
end
# Check if this consent covers the requested scopes
@@ -31,18 +31,18 @@ class OidcUserConsent < ApplicationRecord
def formatted_scopes
scopes.map do |scope|
case scope
when 'openid'
'Basic authentication'
when 'profile'
'Profile information'
when 'email'
'Email address'
when 'groups'
'Group membership'
when "openid"
"Basic authentication"
when "profile"
"Profile information"
when "email"
"Email address"
when "groups"
"Group membership"
else
scope.humanize
end
end.join(', ')
end.join(", ")
end
# Find consent by SID

View File

@@ -29,16 +29,16 @@ class User < ApplicationRecord
groups
].freeze
validates :email_address, presence: true, uniqueness: { case_sensitive: false },
format: { with: URI::MailTo::EMAIL_REGEXP }
validates :username, uniqueness: { case_sensitive: false }, allow_nil: true,
format: { with: /\A[a-zA-Z0-9_-]+\z/, message: "can only contain letters, numbers, underscores, and hyphens" },
length: { minimum: 2, maximum: 30 }
validates :password, length: { minimum: 8 }, allow_nil: true
validates :email_address, presence: true, uniqueness: {case_sensitive: false},
format: {with: URI::MailTo::EMAIL_REGEXP}
validates :username, uniqueness: {case_sensitive: false}, allow_nil: true,
format: {with: /\A[a-zA-Z0-9_-]+\z/, message: "can only contain letters, numbers, underscores, and hyphens"},
length: {minimum: 2, maximum: 30}
validates :password, length: {minimum: 8}, allow_nil: true
validate :no_reserved_claim_names
# Enum - automatically creates scopes (User.active, User.disabled, etc.)
enum :status, { active: 0, disabled: 1, pending_invitation: 2 }
enum :status, {active: 0, disabled: 1, pending_invitation: 2}
# Scopes
scope :admins, -> { where(admin: true) }
@@ -122,12 +122,7 @@ class User < ApplicationRecord
cache_key = "backup_code_failed_attempts_#{id}"
attempts = Rails.cache.read(cache_key) || 0
if attempts >= 5 # Allow max 5 failed attempts per hour
true
else
# Don't increment here - increment only on failed attempts
false
end
attempts >= 5
end
# Increment failed attempt counter
@@ -231,7 +226,7 @@ class User < ApplicationRecord
reserved_used = parsed_custom_claims.keys.map(&:to_s) & RESERVED_CLAIMS
if reserved_used.any?
errors.add(:custom_claims, "cannot override reserved OIDC claims: #{reserved_used.join(', ')}")
errors.add(:custom_claims, "cannot override reserved OIDC claims: #{reserved_used.join(", ")}")
end
end

View File

@@ -2,5 +2,5 @@ class UserGroup < ApplicationRecord
belongs_to :user
belongs_to :group
validates :user_id, uniqueness: { scope: :group_id }
validates :user_id, uniqueness: {scope: :group_id}
end

View File

@@ -4,9 +4,9 @@ class WebauthnCredential < ApplicationRecord
# Validations
validates :external_id, presence: true, uniqueness: true
validates :public_key, presence: true
validates :sign_count, presence: true, numericality: { greater_than_or_equal_to: 0, only_integer: true }
validates :sign_count, presence: true, numericality: {greater_than_or_equal_to: 0, only_integer: true}
validates :nickname, presence: true
validates :authenticator_type, inclusion: { in: %w[platform cross-platform] }
validates :authenticator_type, inclusion: {in: %w[platform cross-platform]}
# Scopes for querying
scope :active, -> { where(nil) } # All credentials are active (we can add revoked_at later if needed)
@@ -84,13 +84,13 @@ class WebauthnCredential < ApplicationRecord
days = hours / 24
if days > 0
"#{days.floor} day#{'s' if days > 1} ago"
"#{days.floor} day#{"s" if days > 1} ago"
elsif hours > 0
"#{hours.floor} hour#{'s' if hours > 1} ago"
"#{hours.floor} hour#{"s" if hours > 1} ago"
elsif minutes > 0
"#{minutes.floor} minute#{'s' if minutes > 1} ago"
"#{minutes.floor} minute#{"s" if minutes > 1} ago"
else
"Just now"
end
end
end
end

View File

@@ -13,20 +13,20 @@ module ClaimsMerger
result = base.dup
incoming.each do |key, value|
if result.key?(key)
result[key] = if result.key?(key)
# If both values are arrays, combine them (union to avoid duplicates)
if result[key].is_a?(Array) && value.is_a?(Array)
result[key] = (result[key] + value).uniq
(result[key] + value).uniq
# If both values are hashes, recursively merge them
elsif result[key].is_a?(Hash) && value.is_a?(Hash)
result[key] = deep_merge_claims(result[key], value)
deep_merge_claims(result[key], value)
else
# Otherwise, incoming value wins (override)
result[key] = value
value
end
else
# New key, just add it
result[key] = value
value
end
end

View File

@@ -60,7 +60,7 @@ class OidcJwtService
# Merge app-specific custom claims (highest priority, arrays are combined)
payload = deep_merge_claims(payload, application.custom_claims_for_user(user))
JWT.encode(payload, private_key, "RS256", { kid: key_id, typ: "JWT" })
JWT.encode(payload, private_key, "RS256", {kid: key_id, typ: "JWT"})
end
# Generate a backchannel logout token (JWT)
@@ -84,12 +84,12 @@ class OidcJwtService
}
# Important: Do NOT include nonce in logout tokens (spec requirement)
JWT.encode(payload, private_key, "RS256", { kid: key_id, typ: "JWT" })
JWT.encode(payload, private_key, "RS256", {kid: key_id, typ: "JWT"})
end
# Decode and verify an ID token
def decode_id_token(token)
JWT.decode(token, public_key, true, { algorithm: "RS256" })
JWT.decode(token, public_key, true, {algorithm: "RS256"})
end
# Get the public key in JWK format for the JWKS endpoint