3 Commits

Author SHA1 Message Date
Dan Milne
93a0edb0a2 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
2026-01-01 13:29:44 +11:00
Dan Milne
7d3af2bcec SRB fixes 2026-01-01 13:19:17 +11:00
Dan Milne
c03034c49f Add files to support brakeman and standardrb. Fix some SRB warnings 2026-01-01 13:18:30 +11:00
97 changed files with 5224 additions and 834 deletions

View File

@@ -41,8 +41,6 @@ jobs:
lint:
runs-on: ubuntu-latest
env:
RUBOCOP_CACHE_ROOT: tmp/rubocop
steps:
- name: Checkout code
uses: actions/checkout@v5
@@ -52,18 +50,8 @@ jobs:
with:
bundler-cache: true
- name: Prepare RuboCop cache
uses: actions/cache@v4
env:
DEPENDENCIES_HASH: ${{ hashFiles('.ruby-version', '**/.rubocop.yml', '**/.rubocop_todo.yml', 'Gemfile.lock') }}
with:
path: ${{ env.RUBOCOP_CACHE_ROOT }}
key: rubocop-${{ runner.os }}-${{ env.DEPENDENCIES_HASH }}-${{ github.ref_name == github.event.repository.default_branch && github.run_id || 'default' }}
restore-keys: |
rubocop-${{ runner.os }}-${{ env.DEPENDENCIES_HASH }}-
- name: Lint code for consistent style
run: bin/rubocop -f github
run: bin/standardrb
test:
runs-on: ubuntu-latest

7
.standard.yml Normal file
View File

@@ -0,0 +1,7 @@
ignore:
- 'test_*.rb' # Ignore test files in root directory
- 'tmp/**/*'
- 'vendor/**/*'
- 'node_modules/**/*'
- 'config/initializers/csp_local_logger.rb' # Complex CSP logger with intentional block structure
- 'config/initializers/sentry_subscriber.rb' # Sentry subscriber with module structure

View File

@@ -42,7 +42,7 @@ gem "sentry-ruby", "~> 6.2"
gem "sentry-rails", "~> 6.2"
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
gem "tzinfo-data", platforms: %i[ windows jruby ]
gem "tzinfo-data", platforms: %i[windows jruby]
# Use the database-backed adapters for Rails.cache and Action Cable
gem "solid_cache"
@@ -63,7 +63,7 @@ gem "image_processing", "~> 1.2"
group :development, :test do
# See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
gem "debug", platforms: %i[ mri windows ], require: "debug/prelude"
gem "debug", platforms: %i[mri windows], require: "debug/prelude"
# Audits gems for known security defects (use config/bundler-audit.yml to ignore issues)
gem "bundler-audit", require: false
@@ -71,8 +71,8 @@ group :development, :test do
# Static analysis for security vulnerabilities [https://brakemanscanner.org/]
gem "brakeman", require: false
# Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/]
gem "rubocop-rails-omakase", require: false
# Standard Ruby style guide, linter, and formatter [https://github.com/standardrb/standard]
gem "standard", require: false
end
group :development do
@@ -91,4 +91,3 @@ group :test do
# Code coverage analysis
gem "simplecov", require: false
end

View File

@@ -316,16 +316,6 @@ GEM
lint_roller (~> 1.1)
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.47.1, < 2.0)
rubocop-rails (2.34.2)
activesupport (>= 4.2.0)
lint_roller (~> 1.1)
rack (>= 1.1)
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.44.0, < 2.0)
rubocop-rails-omakase (1.1.0)
rubocop (>= 1.72)
rubocop-performance (>= 1.24)
rubocop-rails (>= 2.30)
ruby-progressbar (1.13.0)
ruby-vips (2.2.5)
ffi (~> 1.12)
@@ -382,6 +372,18 @@ GEM
net-sftp (>= 2.1.2)
net-ssh (>= 2.8.0)
ostruct
standard (1.52.0)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.0)
rubocop (~> 1.81.7)
standard-custom (~> 1.0.0)
standard-performance (~> 1.8)
standard-custom (1.0.2)
lint_roller (~> 1.0)
rubocop (~> 1.50)
standard-performance (1.9.0)
lint_roller (~> 1.1)
rubocop-performance (~> 1.26.0)
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringio (3.1.8)
@@ -467,7 +469,6 @@ DEPENDENCIES
rails (~> 8.1.1)
rotp (~> 6.3)
rqrcode (~> 3.1)
rubocop-rails-omakase
selenium-webdriver
sentry-rails (~> 6.2)
sentry-ruby (~> 6.2)
@@ -476,6 +477,7 @@ DEPENDENCIES
solid_cache
solid_queue (~> 1.2)
sqlite3 (>= 2.1)
standard
stimulus-rails
tailwindcss-rails
thruster

View File

@@ -7,8 +7,9 @@ module ApplicationCable
end
private
def set_current_user
if session = Session.find_by(id: cookies.signed[:session_id])
if (session = Session.find_by(id: cookies.signed[:session_id]))
self.current_user = session.user
end
end

View File

@@ -32,13 +32,11 @@ module Admin
client_secret = @application.generate_new_client_secret!
end
if @application.oidc?
flash[:notice] = "Application created successfully."
if @application.oidc?
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,

View File

@@ -81,7 +81,10 @@ 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|
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]
@@ -91,12 +94,13 @@ module Api
[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
@@ -129,8 +133,7 @@ module Api
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,6 +17,7 @@ module Authentication
end
private
def authenticated?
resume_session
end
@@ -39,9 +40,8 @@ module Authentication
end
def after_authentication_url
return_url = session[:return_to_after_authenticating]
final_url = session.delete(:return_to_after_authenticating) || root_url
final_url
session[:return_to_after_authenticating]
session.delete(:return_to_after_authenticating) || root_url
end
def start_new_session_for(user, acr: "1")
@@ -101,10 +101,14 @@ module Authentication
return nil if host.blank? || host.match?(/^(localhost|127\.0\.0\.1|::1)$/)
# Strip port number for domain parsing
host_without_port = host.split(':').first
host_without_port = host.split(":").first
# Check if it's an IP address (IPv4 or IPv6) - if so, don't set domain cookie
return nil if IPAddr.new(host_without_port) rescue false
begin
return nil if IPAddr.new(host_without_port)
rescue
false
end
# Use Public Suffix List for accurate domain parsing
domain = PublicSuffix.parse(host_without_port)
@@ -138,7 +142,7 @@ module Authentication
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
query_params["fa_token"] = token
uri.query = URI.encode_www_form(query_params)
# Update the session with the tokenized URL

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

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?
@@ -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,13 +1,13 @@
class PasswordsController < ApplicationController
allow_unauthenticated_access
before_action :set_user_by_token, only: %i[ edit update ]
before_action :set_user_by_token, only: %i[edit update]
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_password_path, alert: "Try again later." }
def new
end
def create
if user = User.find_by(email_address: params[:email_address])
if (user = User.find_by(email_address: params[:email_address]))
PasswordsMailer.reset(user).deliver_later
end
@@ -27,6 +27,7 @@ class PasswordsController < ApplicationController
end
private
def set_user_by_token
@user = User.find_by_token_for(:password_reset, params[:token])
redirect_to new_password_path, alert: "Password reset link is invalid or has expired." if @user.nil?

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
@@ -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
@@ -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,7 +157,7 @@ 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)
@@ -169,26 +168,25 @@ class WebauthnController < ApplicationController
webauthn_params = params.require(:webauthn).permit(:nickname, credential: [:id, :rawId, :type, response: {}, clientExtensionResults: {}])
[webauthn_params[:credential], webauthn_params[:nickname]]
end
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

View File

@@ -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

@@ -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
@@ -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,11 +84,11 @@ 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

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

View File

@@ -2,6 +2,4 @@
require "rubygems"
require "bundler/setup"
ARGV.unshift("--ensure-latest")
load Gem.bin_path("brakeman", "brakeman")

View File

@@ -27,13 +27,13 @@ module Clinch
# Configure SMTP settings using environment variables
config.action_mailer.delivery_method = :smtp
config.action_mailer.smtp_settings = {
address: ENV.fetch('SMTP_ADDRESS', 'localhost'),
port: ENV.fetch('SMTP_PORT', 587),
domain: ENV.fetch('SMTP_DOMAIN', 'localhost'),
user_name: ENV.fetch('SMTP_USERNAME', nil),
password: ENV.fetch('SMTP_PASSWORD', nil),
authentication: ENV.fetch('SMTP_AUTHENTICATION', 'plain').to_sym,
enable_starttls_auto: ENV.fetch('SMTP_STARTTLS_AUTO', 'true') == 'true',
address: ENV.fetch("SMTP_ADDRESS", "localhost"),
port: ENV.fetch("SMTP_PORT", 587),
domain: ENV.fetch("SMTP_DOMAIN", "localhost"),
user_name: ENV.fetch("SMTP_USERNAME", nil),
password: ENV.fetch("SMTP_PASSWORD", nil),
authentication: ENV.fetch("SMTP_AUTHENTICATION", "plain").to_sym,
enable_starttls_auto: ENV.fetch("SMTP_STARTTLS_AUTO", "true") == "true",
openssl_verify_mode: OpenSSL::SSL::VERIFY_PEER
}
end

View File

@@ -20,7 +20,7 @@ Rails.application.configure do
if Rails.root.join("tmp/caching-dev.txt").exist?
config.action_controller.perform_caching = true
config.action_controller.enable_fragment_cache_logging = true
config.public_file_server.headers = { "cache-control" => "public, max-age=#{2.days.to_i}" }
config.public_file_server.headers = {"cache-control" => "public, max-age=#{2.days.to_i}"}
else
config.action_controller.perform_caching = false
end
@@ -39,10 +39,10 @@ Rails.application.configure do
config.action_mailer.perform_caching = false
# Set localhost to be used by links generated in mailer templates.
config.action_mailer.default_url_options = { host: "localhost", port: 3000 }
config.action_mailer.default_url_options = {host: "localhost", port: 3000}
# Log with request_id as a tag (same as production).
config.log_tags = [ :request_id ]
config.log_tags = [:request_id]
# Print deprecation notices to the Rails logger.
config.active_support.deprecation = :log
@@ -62,7 +62,6 @@ Rails.application.configure do
# Use async processor for background jobs in development
config.active_job.queue_adapter = :async
# Highlight code that triggered redirect in logs.
config.action_dispatch.verbose_redirect_logs = true

View File

@@ -16,7 +16,7 @@ Rails.application.configure do
config.action_controller.perform_caching = true
# Cache assets for far-future expiry since they are all digest stamped.
config.public_file_server.headers = { "cache-control" => "public, max-age=#{1.year.to_i}" }
config.public_file_server.headers = {"cache-control" => "public, max-age=#{1.year.to_i}"}
# Enable serving of images, stylesheets, and JavaScripts from an asset server.
# config.asset_host = "http://assets.example.com"
@@ -34,16 +34,16 @@ Rails.application.configure do
# Note: Rails already sets X-Content-Type-Options: nosniff by default
# Note: Permissions-Policy is configured in config/initializers/permissions_policy.rb
config.action_dispatch.default_headers.merge!(
'X-Frame-Options' => 'DENY', # Override default SAMEORIGIN to prevent clickjacking
'Referrer-Policy' => 'strict-origin-when-cross-origin' # Control referrer information
"X-Frame-Options" => "DENY", # Override default SAMEORIGIN to prevent clickjacking
"Referrer-Policy" => "strict-origin-when-cross-origin" # Control referrer information
)
# Skip http-to-https redirect for the default health check endpoint.
# config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } }
# Log to STDOUT with the current request id as a default log tag.
config.log_tags = [ :request_id ]
config.logger = ActiveSupport::TaggedLogging.logger(STDOUT)
config.log_tags = [:request_id]
config.logger = ActiveSupport::TaggedLogging.logger($stdout)
# Change to "debug" to log everything (including potentially personally-identifiable information!).
config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info")
@@ -66,7 +66,7 @@ Rails.application.configure do
# Set host to be used by links generated in mailer templates.
config.action_mailer.default_url_options = {
host: ENV.fetch('CLINCH_HOST', 'example.com')
host: ENV.fetch("CLINCH_HOST", "example.com")
}
# Specify outgoing SMTP server. Remember to add smtp/* credentials via bin/rails credentials:edit.
@@ -86,13 +86,13 @@ Rails.application.configure do
config.active_record.dump_schema_after_migration = false
# Only use :id for inspections in production.
config.active_record.attributes_for_inspect = [ :id ]
config.active_record.attributes_for_inspect = [:id]
# Helper method to extract domain from CLINCH_HOST (removes protocol if present)
def self.extract_domain(host)
return host if host.blank?
# Remove protocol (http:// or https://) if present
host.gsub(/^https?:\/\//, '')
host.gsub(/^https?:\/\//, "")
end
# Helper method to ensure URL has https:// protocol
@@ -105,11 +105,11 @@ Rails.application.configure do
# Enable DNS rebinding protection and other `Host` header attacks.
# Configure allowed hosts based on deployment scenario
allowed_hosts = [
extract_domain(ENV.fetch('CLINCH_HOST', 'auth.example.com')), # External domain (auth service itself)
extract_domain(ENV.fetch("CLINCH_HOST", "auth.example.com")) # External domain (auth service itself)
]
# Use PublicSuffix to extract registrable domain and allow all subdomains
host_domain = extract_domain(ENV.fetch('CLINCH_HOST', 'auth.example.com'))
host_domain = extract_domain(ENV.fetch("CLINCH_HOST", "auth.example.com"))
if host_domain.present?
begin
# Use PublicSuffix to properly extract the domain
@@ -123,20 +123,20 @@ Rails.application.configure do
rescue PublicSuffix::DomainInvalid
# Fallback to simple domain extraction if PublicSuffix fails
Rails.logger.warn "Could not parse domain '#{host_domain}' with PublicSuffix, using fallback"
base_domain = host_domain.split('.').last(2).join('.')
base_domain = host_domain.split(".").last(2).join(".")
allowed_hosts << /.*#{Regexp.escape(base_domain)}/
end
end
# Allow Docker service names if running in same compose
if ENV['CLINCH_DOCKER_SERVICE_NAME']
allowed_hosts << ENV['CLINCH_DOCKER_SERVICE_NAME']
if ENV["CLINCH_DOCKER_SERVICE_NAME"]
allowed_hosts << ENV["CLINCH_DOCKER_SERVICE_NAME"]
end
# Allow internal IP access for cross-compose or host networking
if ENV['CLINCH_ALLOW_INTERNAL_IPS'] == 'true'
if ENV["CLINCH_ALLOW_INTERNAL_IPS"] == "true"
# Specific host IP
allowed_hosts << '192.168.2.246'
allowed_hosts << "192.168.2.246"
# Private IP ranges for internal network access
allowed_hosts += [
@@ -147,14 +147,14 @@ Rails.application.configure do
end
# Local development fallbacks
if ENV['CLINCH_ALLOW_LOCALHOST'] == 'true'
allowed_hosts += ['localhost', '127.0.0.1', '0.0.0.0']
if ENV["CLINCH_ALLOW_LOCALHOST"] == "true"
allowed_hosts += ["localhost", "127.0.0.1", "0.0.0.0"]
end
config.hosts = allowed_hosts
# Skip DNS rebinding protection for the default health check endpoint.
config.host_authorization = { exclude: ->(request) { request.path == "/up" } }
config.host_authorization = {exclude: ->(request) { request.path == "/up" }}
# Sentry configuration for production
# Only enabled if SENTRY_DSN environment variable is set

View File

@@ -16,7 +16,7 @@ Rails.application.configure do
config.eager_load = ENV["CI"].present?
# Configure public file server for tests with cache-control for performance.
config.public_file_server.headers = { "cache-control" => "public, max-age=3600" }
config.public_file_server.headers = {"cache-control" => "public, max-age=3600"}
# Show full error reports.
config.consider_all_requests_local = true
@@ -37,7 +37,7 @@ Rails.application.configure do
config.action_mailer.delivery_method = :test
# Set host to be used by links generated in mailer templates.
config.action_mailer.default_url_options = { host: "example.com" }
config.action_mailer.default_url_options = {host: "example.com"}
# Print deprecation notices to the stderr.
config.active_support.deprecation = :stderr

View File

@@ -8,14 +8,14 @@
# - ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT
# Use env vars if set, otherwise derive from SECRET_KEY_BASE (deterministic)
primary_key = ENV.fetch('ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY') do
Rails.application.key_generator.generate_key('active_record_encryption_primary', 32)
primary_key = ENV.fetch("ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY") do
Rails.application.key_generator.generate_key("active_record_encryption_primary", 32)
end
deterministic_key = ENV.fetch('ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY') do
Rails.application.key_generator.generate_key('active_record_encryption_deterministic', 32)
deterministic_key = ENV.fetch("ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY") do
Rails.application.key_generator.generate_key("active_record_encryption_deterministic", 32)
end
key_derivation_salt = ENV.fetch('ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT') do
Rails.application.key_generator.generate_key('active_record_encryption_salt', 32)
key_derivation_salt = ENV.fetch("ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT") do
Rails.application.key_generator.generate_key("active_record_encryption_salt", 32)
end
# Configure Rails 7.1+ ActiveRecord encryption

View File

@@ -59,7 +59,6 @@ Rails.application.configure do
policy.report_uri "/api/csp-violation-report"
end
# Start with CSP in report-only mode for testing
# Set to false after verifying everything works in production
config.content_security_policy_report_only = Rails.env.development?

View File

@@ -8,7 +8,7 @@ Rails.application.config.after_initialize do
# Configure log rotation
csp_logger = Logger.new(
csp_log_path,
'daily', # Rotate daily
"daily", # Rotate daily
30 # Keep 30 old log files
)
@@ -16,7 +16,7 @@ Rails.application.config.after_initialize do
# Format: [TIMESTAMP] LEVEL MESSAGE
csp_logger.formatter = proc do |severity, datetime, progname, msg|
"[#{datetime.strftime('%Y-%m-%d %H:%M:%S')}] #{severity} #{msg}\n"
"[#{datetime.strftime("%Y-%m-%d %H:%M:%S")}] #{severity} #{msg}\n"
end
module CspViolationLocalLogger
@@ -69,7 +69,6 @@ Rails.application.config.after_initialize do
# Also log to main Rails logger for visibility
Rails.logger.info "CSP violation logged to csp_violations.log: #{violated_directive} - #{blocked_uri}"
rescue => e
# Ensure logger errors don't break the CSP reporting flow
Rails.logger.error "Failed to log CSP violation to file: #{e.message}"
@@ -81,12 +80,12 @@ Rails.application.config.after_initialize do
csp_log_path = Rails.root.join("log", "csp_violations.log")
logger = Logger.new(
csp_log_path,
'daily', # Rotate daily
"daily", # Rotate daily
30 # Keep 30 old log files
)
logger.level = Logger::INFO
logger.formatter = proc do |severity, datetime, progname, msg|
"[#{datetime.strftime('%Y-%m-%d %H:%M:%S')}] #{severity} #{msg}\n"
"[#{datetime.strftime("%Y-%m-%d %H:%M:%S")}] #{severity} #{msg}\n"
end
logger
end
@@ -120,7 +119,6 @@ Rails.application.config.after_initialize do
# Test write to ensure permissions are correct
csp_logger.info "CSP Logger initialized at #{Time.current}"
rescue => e
Rails.logger.error "Failed to initialize CSP local logger: #{e.message}"
Rails.logger.error "CSP violations will only be sent to Sentry (if configured)"

View File

@@ -47,7 +47,7 @@ Rails.application.config.after_initialize do
timestamp: csp_data[:timestamp]
}
},
user: csp_data[:current_user_id] ? { id: csp_data[:current_user_id] } : nil
user: csp_data[:current_user_id] ? {id: csp_data[:current_user_id]} : nil
)
# Log to Rails logger for redundancy
@@ -69,10 +69,10 @@ Rails.application.config.after_initialize do
parsed.host
rescue URI::InvalidURIError
# Handle cases where URI might be malformed or just a path
if uri.start_with?('/')
if uri.start_with?("/")
nil # It's a relative path, no domain
else
uri.split('/').first # Best effort extraction
uri.split("/").first # Best effort extraction
end
end
end

View File

@@ -3,5 +3,5 @@
# Derived from SECRET_KEY_BASE - no storage needed, deterministic output
# Optional: Set OIDC_TOKEN_PREFIX_HMAC env var to override with explicit key
module TokenHmac
KEY = ENV['OIDC_TOKEN_PREFIX_HMAC'] || Rails.application.key_generator.generate_key('oidc_token_prefix', 32)
KEY = ENV["OIDC_TOKEN_PREFIX_HMAC"] || Rails.application.key_generator.generate_key("oidc_token_prefix", 32)
end

View File

@@ -31,7 +31,6 @@ threads threads_count, threads_count
# Specifies the `port` that Puma will listen on to receive requests; default is 3000.
port ENV.fetch("PORT", 3000)
# Allow puma to be restarted by `bin/rails restart` command.
plugin :tmp_restart

View File

@@ -8,7 +8,7 @@ Rails.application.routes.draw do
# Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.
# Can be used by load balancers and uptime monitors to verify that the app is live.
get "up" => "rails/health#show", as: :rails_health_check
get "up" => "rails/health#show", :as => :rails_health_check
# Authentication routes
get "/signup", to: "users#new", as: :signup
@@ -61,21 +61,21 @@ Rails.application.routes.draw do
end
# TOTP (2FA) routes
get '/totp/new', to: 'totp#new', as: :new_totp
post '/totp', to: 'totp#create', as: :totp
delete '/totp', to: 'totp#destroy'
get '/totp/backup_codes', to: 'totp#backup_codes', as: :backup_codes_totp
post '/totp/verify_password', to: 'totp#verify_password', as: :verify_password_totp
get '/totp/regenerate_backup_codes', to: 'totp#regenerate_backup_codes', as: :regenerate_backup_codes_totp
post '/totp/regenerate_backup_codes', to: 'totp#create_new_backup_codes', as: :create_new_backup_codes_totp
post '/totp/complete_setup', to: 'totp#complete_setup', as: :complete_totp_setup
get "/totp/new", to: "totp#new", as: :new_totp
post "/totp", to: "totp#create", as: :totp
delete "/totp", to: "totp#destroy"
get "/totp/backup_codes", to: "totp#backup_codes", as: :backup_codes_totp
post "/totp/verify_password", to: "totp#verify_password", as: :verify_password_totp
get "/totp/regenerate_backup_codes", to: "totp#regenerate_backup_codes", as: :regenerate_backup_codes_totp
post "/totp/regenerate_backup_codes", to: "totp#create_new_backup_codes", as: :create_new_backup_codes_totp
post "/totp/complete_setup", to: "totp#complete_setup", as: :complete_totp_setup
# WebAuthn (Passkeys) routes
get '/webauthn/new', to: 'webauthn#new', as: :new_webauthn
post '/webauthn/challenge', to: 'webauthn#challenge'
post '/webauthn/create', to: 'webauthn#create'
delete '/webauthn/:id', to: 'webauthn#destroy', as: :webauthn_credential
get '/webauthn/check', to: 'webauthn#check'
get "/webauthn/new", to: "webauthn#new", as: :new_webauthn
post "/webauthn/challenge", to: "webauthn#challenge"
post "/webauthn/create", to: "webauthn#create"
delete "/webauthn/:id", to: "webauthn#destroy", as: :webauthn_credential
get "/webauthn/check", to: "webauthn#check"
# Admin routes
namespace :admin do

View File

@@ -7,6 +7,6 @@ class CreateUserGroups < ActiveRecord::Migration[8.1]
t.timestamps
end
add_index :user_groups, [ :user_id, :group_id ], unique: true
add_index :user_groups, [:user_id, :group_id], unique: true
end
end

View File

@@ -7,6 +7,6 @@ class CreateApplicationGroups < ActiveRecord::Migration[8.1]
t.timestamps
end
add_index :application_groups, [ :application_id, :group_id ], unique: true
add_index :application_groups, [:application_id, :group_id], unique: true
end
end

View File

@@ -13,6 +13,6 @@ class CreateOidcAuthorizationCodes < ActiveRecord::Migration[8.1]
end
add_index :oidc_authorization_codes, :code, unique: true
add_index :oidc_authorization_codes, :expires_at
add_index :oidc_authorization_codes, [ :application_id, :user_id ]
add_index :oidc_authorization_codes, [:application_id, :user_id]
end
end

View File

@@ -11,6 +11,6 @@ class CreateOidcAccessTokens < ActiveRecord::Migration[8.1]
end
add_index :oidc_access_tokens, :token, unique: true
add_index :oidc_access_tokens, :expires_at
add_index :oidc_access_tokens, [ :application_id, :user_id ]
add_index :oidc_access_tokens, [:application_id, :user_id]
end
end

View File

@@ -1,9 +1,9 @@
class AddRoleMappingToApplications < ActiveRecord::Migration[8.1]
def change
add_column :applications, :role_mapping_mode, :string, default: 'disabled', null: false
add_column :applications, :role_mapping_mode, :string, default: "disabled", null: false
add_column :applications, :role_prefix, :string
add_column :applications, :managed_permissions, :json, default: {}
add_column :applications, :role_claim_name, :string, default: 'roles'
add_column :applications, :role_claim_name, :string, default: "roles"
create_table :application_roles do |t|
t.references :application, null: false, foreign_key: true
@@ -21,7 +21,7 @@ class AddRoleMappingToApplications < ActiveRecord::Migration[8.1]
create_table :user_role_assignments do |t|
t.references :user, null: false, foreign_key: true
t.references :application_role, null: false, foreign_key: true
t.string :source, default: 'oidc' # 'oidc', 'manual', 'group_sync'
t.string :source, default: "oidc" # 'oidc', 'manual', 'group_sync'
t.json :metadata, default: {}
t.timestamps

View File

@@ -41,7 +41,7 @@ class MigrateForwardAuthRulesToApplications < ActiveRecord::Migration[8.1]
app = application_class.create!(
name: rule.domain_pattern.titleize,
slug: rule.domain_pattern.parameterize.presence || "forward-auth-#{rule.id}",
app_type: 'forward_auth',
app_type: "forward_auth",
domain_pattern: rule.domain_pattern,
headers_config: rule.headers_config || {},
active: rule.active
@@ -59,7 +59,7 @@ class MigrateForwardAuthRulesToApplications < ActiveRecord::Migration[8.1]
def down
# Remove all forward_auth applications created by this migration
Application.where(app_type: 'forward_auth').destroy_all
Application.where(app_type: "forward_auth").destroy_all
end
private

View File

@@ -5,7 +5,7 @@ class CreateWebauthnCredentials < ActiveRecord::Migration[8.1]
t.references :user, null: false, foreign_key: true, index: true
# WebAuthn specification fields
t.string :external_id, null: false, index: { unique: true } # credential ID (base64)
t.string :external_id, null: false, index: {unique: true} # credential ID (base64)
t.string :public_key, null: false # public key (base64)
t.integer :sign_count, null: false, default: 0 # signature counter (clone detection)

View File

@@ -17,6 +17,6 @@ class CreateOidcRefreshTokens < ActiveRecord::Migration[8.1]
add_index :oidc_refresh_tokens, :expires_at
add_index :oidc_refresh_tokens, :revoked_at
add_index :oidc_refresh_tokens, :token_family_id
add_index :oidc_refresh_tokens, [ :application_id, :user_id ]
add_index :oidc_refresh_tokens, [:application_id, :user_id]
end
end

View File

@@ -1,13 +1,13 @@
class CreateApplicationUserClaims < ActiveRecord::Migration[8.1]
def change
create_table :application_user_claims do |t|
t.references :application, null: false, foreign_key: { on_delete: :cascade }
t.references :user, null: false, foreign_key: { on_delete: :cascade }
t.references :application, null: false, foreign_key: {on_delete: :cascade}
t.references :user, null: false, foreign_key: {on_delete: :cascade}
t.json :custom_claims, default: {}, null: false
t.timestamps
end
add_index :application_user_claims, [:application_id, :user_id], unique: true, name: 'index_app_user_claims_unique'
add_index :application_user_claims, [:application_id, :user_id], unique: true, name: "index_app_user_claims_unique"
end
end

View File

@@ -19,7 +19,7 @@ class CreateActiveStorageTables < ActiveRecord::Migration[7.0]
t.datetime :created_at, null: false
end
t.index [ :key ], unique: true
t.index [:key], unique: true
end
create_table :active_storage_attachments, id: primary_key_type do |t|
@@ -33,7 +33,7 @@ class CreateActiveStorageTables < ActiveRecord::Migration[7.0]
t.datetime :created_at, null: false
end
t.index [ :record_type, :record_id, :name, :blob_id ], name: :index_active_storage_attachments_uniqueness, unique: true
t.index [:record_type, :record_id, :name, :blob_id], name: :index_active_storage_attachments_uniqueness, unique: true
t.foreign_key :active_storage_blobs, column: :blob_id
end
@@ -41,17 +41,18 @@ class CreateActiveStorageTables < ActiveRecord::Migration[7.0]
t.belongs_to :blob, null: false, index: false, type: foreign_key_type
t.string :variation_digest, null: false
t.index [ :blob_id, :variation_digest ], name: :index_active_storage_variant_records_uniqueness, unique: true
t.index [:blob_id, :variation_digest], name: :index_active_storage_variant_records_uniqueness, unique: true
t.foreign_key :active_storage_blobs, column: :blob_id
end
end
private
def primary_and_foreign_key_types
config = Rails.configuration.generators
setting = config.options[config.orm][:primary_key_type]
primary_key_type = setting || :primary_key
foreign_key_type = setting || :bigint
[ primary_key_type, foreign_key_type ]
[primary_key_type, foreign_key_type]
end
end

View File

@@ -0,0 +1,275 @@
# Rodauth-OAuth Analysis Documents
This directory contains a comprehensive analysis of rodauth-oauth and how it compares to your custom OIDC implementation in Clinch.
## Start Here
### 1. **RODAUTH_DECISION_GUIDE.md** (15-minute read)
**Purpose:** Help you make a decision about your OAuth/OIDC implementation
**Contains:**
- TL;DR of three options
- Decision flowchart
- Feature roadmap scenarios
- Effort estimates for each path
- Security comparison
- Real-world questions to ask your team
- Next actions for each option
**Best for:** Deciding whether to keep your implementation, migrate, or use a hybrid approach
---
### 2. **rodauth-oauth-quick-reference.md** (20-minute read)
**Purpose:** Quick lookup guide and architecture overview
**Contains:**
- What Rodauth-OAuth is (concise)
- Key statistics and certifications
- Feature advantages & disadvantages
- Architecture diagrams (text-based)
- Database schema comparison
- Feature matrix with implementation effort
- Performance considerations
- Getting started guide
- Code examples (minimal setup)
**Best for:** Understanding what you're looking at, quick decision support
---
### 3. **rodauth-oauth-analysis.md** (45-minute deep-dive)
**Purpose:** Comprehensive technical analysis for decision-making
**Contains:**
- Complete architecture breakdown (12 sections)
- All 34 features detailed and explained
- Full database schema documentation
- Request flow diagrams
- Feature dependency graphs
- Integration paths with Rails
- Security analysis
- Migration procedures
- Code comparisons
- Performance metrics
**Best for:** Deep understanding before making technical decisions, planning migrations
---
## How to Use These Documents
### Scenario 1: "I have 15 minutes"
1. Read: RODAUTH_DECISION_GUIDE.md (sections: TL;DR + Decision Matrix)
2. Go to: Next Actions for your chosen option
3. Done: You have a direction
### Scenario 2: "I have 45 minutes"
1. Read: RODAUTH_DECISION_GUIDE.md (complete)
2. Skim: rodauth-oauth-quick-reference.md (focus on code examples)
3. Decide: Which path interests you most
4. Plan: Team discussion using decision matrix
### Scenario 3: "I'm doing technical deep-dive"
1. Read: RODAUTH_DECISION_GUIDE.md (complete)
2. Read: rodauth-oauth-quick-reference.md (complete)
3. Read: rodauth-oauth-analysis.md (sections 1-6)
4. Reference: rodauth-oauth-analysis.md (sections 7-12 as needed)
### Scenario 4: "I'm planning a migration"
1. Read: RODAUTH_DECISION_GUIDE.md (effort estimates section)
2. Read: rodauth-oauth-analysis.md (migration path section)
3. Reference: rodauth-oauth-analysis.md (database schema section)
4. Plan: Detailed migration steps
---
## Three Options Explained (Very Brief)
### Option A: Keep Your Implementation
- **Time:** Ongoing (add features incrementally)
- **Effort:** 4-6 months to reach feature parity
- **Maintenance:** 8-10 hours/month
- **Best if:** Auth Code + PKCE is sufficient forever
### Option B: Switch to Rodauth-OAuth
- **Time:** 5-9 weeks (one-time migration)
- **Learning:** 1-2 weeks (Roda framework)
- **Maintenance:** 1-2 hours/month
- **Best if:** Need enterprise features, want low maintenance
### Option C: Hybrid Approach (Microservices)
- **Time:** 3-5 weeks (independent setup)
- **Learning:** Low (Roda is isolated)
- **Maintenance:** 2-3 hours/month
- **Best if:** Want Option B benefits without full Rails→Roda migration
---
## Key Findings
**What Rodauth-OAuth Provides That You Don't Have:**
- Refresh tokens
- Token revocation (RFC 7009)
- Token introspection (RFC 7662)
- Client Credentials grant (machine-to-machine)
- Device Code flow (IoT/smart TV)
- JWT Access Tokens (stateless)
- Session Management
- Front & Back-Channel Logout
- Token hashing (bcrypt security)
- DPoP support (token binding)
- TLS mutual authentication
- Dynamic Client Registration
- 20+ more optional features
**Security Differences:**
- Your impl: Tokens stored in plaintext (DB breach = token theft)
- Rodauth: Tokens hashed with bcrypt (secure even if DB breached)
**Maintenance Burden:**
- Your impl: YOU maintain everything
- Rodauth: Community maintains, you maintain config only
---
## Document Structure
### RODAUTH_DECISION_GUIDE.md Sections:
```
1. TL;DR - Three options
2. Decision Matrix - Flowchart
3. Feature Roadmap Comparison
4. Architecture Diagrams (visual)
5. Effort Estimates
6. Real-World Questions
7. Security Comparison
8. Cost-Benefit Summary
9. Decision Scorecard
10. Next Actions
```
### rodauth-oauth-quick-reference.md Sections:
```
1. What Is It? (overview)
2. Key Stats
3. Why Consider It? (advantages)
4. Architecture Overview (your impl vs rodauth)
5. Database Schema Comparison
6. Feature Comparison Matrix
7. Code Examples
8. Integration Paths
9. Getting Started
10. Next Steps
```
### rodauth-oauth-analysis.md Sections:
```
1. Executive Summary
2. What Rodauth-OAuth Is
3. File Structure & Organization
4. OIDC/OAuth Features
5. Architecture: How It Works
6. Database Schema Requirements
7. Integration with Rails
8. Architectural Comparison
9. Feature Matrix
10. Integration Complexity
11. Key Findings & Recommendations
12. Migration Path & Code Examples
```
---
## For Your Team
### Sharing with Stakeholders
- **Non-technical:** Use RODAUTH_DECISION_GUIDE.md (TL;DR section)
- **Technical leads:** Use rodauth-oauth-quick-reference.md
- **Engineers:** Use rodauth-oauth-analysis.md (sections 1-6)
- **Security team:** Use rodauth-oauth-analysis.md (security sections)
### Team Discussion
Print out the decision matrix from RODAUTH_DECISION_GUIDE.md and:
1. Walk through each option
2. Discuss team comfort with framework learning
3. Check against feature roadmap
4. Decide on maintenance philosophy
5. Vote on preferred option
---
## Next Steps After Reading
### If Choosing Option A (Keep Custom):
- [ ] Plan feature roadmap (refresh tokens first)
- [ ] Allocate team capacity
- [ ] Add token hashing security
- [ ] Set up security monitoring
### If Choosing Option B (Full Migration):
- [ ] Assign team member to learn Roda/Rodauth
- [ ] Run examples from `/tmp/rodauth-oauth/examples`
- [ ] Plan database migration
- [ ] Prepare rollback plan
- [ ] Schedule migration window
### If Choosing Option C (Hybrid):
- [ ] Evaluate microservices capability
- [ ] Review service communication plan
- [ ] Set up service infrastructure
- [ ] Plan gradual deployment
---
## Bonus: Running the Example
Rodauth-OAuth includes a working OIDC server example you can run:
```bash
cd /Users/dkam/Development/clinch/tmp/rodauth-oauth/examples/oidc
ruby authentication_server.rb
# Then visit: http://localhost:9292
# Login with: foo@bar.com / password
# See: Full OIDC provider in action
```
---
## Questions?
These documents should answer:
- What is rodauth-oauth?
- How does it compare to my implementation?
- What features would we gain?
- What would we lose?
- How much effort is a migration?
- Should we switch?
If questions remain, reference the specific section in the analysis documents.
---
## Document Generation Info
**Generated:** November 12, 2025
**Analysis Duration:** Complete codebase exploration of rodauth-oauth gem
**Sources Analyzed:**
- 34 feature files (10,000+ lines of code)
- 7 database migrations
- 6 complete example applications
- Comprehensive test suite
- README and migration guides
**Analysis Includes:**
- Line-by-line code structure review
- Database schema comparison
- Feature cross-reference analysis
- Integration complexity assessment
- Security analysis
- Effort estimation models
---
**Start with RODAUTH_DECISION_GUIDE.md and go from there!**

View File

@@ -0,0 +1,426 @@
# Rodauth-OAuth Decision Guide
## TL;DR - Make Your Choice Here
### Option A: Keep Your Rails Implementation
**Best if:** Authorization Code + PKCE is all you need, forever
- Keep your current 450 lines of OIDC controller code
- Maintain incrementally as needs change
- Stay 100% in Rails ecosystem
- Time investment: Ongoing (2-3 months to feature parity)
- Learning curve: None (already know Rails)
### Option B: Switch to Rodauth-OAuth
**Best if:** You need enterprise features, standards compliance, low maintenance
- Replace 450 lines with plugin config
- Get 34 optional features on demand
- OpenID Certified, production-hardened
- Time investment: 4-8 weeks (one-time)
- Learning curve: Medium (learn Roda/Rodauth)
### Option C: Hybrid (Recommended if Option B appeals you)
**Best if:** You want rodauth-oauth benefits without framework change
- Run Rodauth-OAuth as separate microservice
- Keep your Rails app unchanged
- Services talk via HTTP APIs
- Time investment: 2-3 weeks (independent services)
- Learning curve: Low (Roda is isolated)
---
## Decision Matrix
```
┌─────────────────────────────────────────────────────────────────┐
│ Do you need features beyond Authorization Code + PKCE? │
├─────────────────────────────────────────────────────────────────┤
│ YES ─→ Go to Question 2 │
│ NO ─→ KEEP YOUR IMPLEMENTATION │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Can your team learn Roda (different from Rails)? │
├─────────────────────────────────────────────────────────────────┤
│ YES ─→ SWITCH TO RODAUTH-OAUTH │
│ NO ─→ Go to Question 3 │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Can you run separate services (microservices)? │
├─────────────────────────────────────────────────────────────────┤
│ YES ─→ USE HYBRID APPROACH │
│ NO ─→ KEEP YOUR IMPLEMENTATION │
└─────────────────────────────────────────────────────────────────┘
```
---
## Feature Roadmap Comparison
### Scenario 1: You Need Refresh Tokens (Common)
**Option A (Keep Custom):**
- Implement refresh token endpoints
- Add refresh_token columns to DB
- Token rotation logic
- Estimate: 1-2 weeks of work
- Ongoing: Maintain refresh token security
**Option B (Rodauth-OAuth):**
- Already built and tested
- Just enable: `:oauth_authorization_code_grant` (includes refresh)
- Token rotation: Configurable options
- Estimate: Already included
- Ongoing: Community maintains
**Option C (Hybrid):**
- Rodauth-OAuth handles it
- Your app unchanged
- Same as Option B for this feature
### Scenario 2: You Need Token Revocation
**Option A (Keep Custom):**
- Build `/oauth/revoke` endpoint
- Implement token blacklist or DB update
- Handle race conditions
- Estimate: 1-2 weeks
- Ongoing: Monitor revocation leaks
**Option B (Rodauth-OAuth):**
- Enable `:oauth_token_revocation` feature
- RFC 7009 compliant out of the box
- Estimate: Already included
- Ongoing: Community handles RFC updates
**Option C (Hybrid):**
- Same as Option B
### Scenario 3: You Need Client Credentials Grant
**Option A (Keep Custom):**
- New endpoint logic
- Client authentication (different from user auth)
- Token generation for apps without users
- Estimate: 2-3 weeks
- Ongoing: Test with external clients
**Option B (Rodauth-OAuth):**
- Enable `:oauth_client_credentials_grant` feature
- All edge cases handled
- Estimate: Already included
- Ongoing: Community maintains
**Option C (Hybrid):**
- Same as Option B
---
## Architecture Diagrams
### Current Setup (Your Implementation)
```
┌─────────────────────────────┐
│ Your Rails Application │
├─────────────────────────────┤
│ app/controllers/ │
│ oidc_controller.rb │ ← 450 lines of OAuth logic
│ │
│ app/models/ │
│ OidcAuthorizationCode │
│ OidcAccessToken │
│ OidcUserConsent │
│ │
│ app/services/ │
│ OidcJwtService │
├─────────────────────────────┤
│ Rails ActiveRecord │
├─────────────────────────────┤
│ PostgreSQL Database │
│ - oidc_authorization_codes
│ - oidc_access_tokens
│ - oidc_user_consents
│ - applications
└─────────────────────────────┘
```
### Option B: Full Migration
```
┌──────────────────────────────┐
│ Roda + Rodauth-OAuth App │
├──────────────────────────────┤
│ lib/rodauth_app.rb │ ← Config (not code!)
│ enable :oidc, │
│ enable :oauth_pkce, │
│ enable :oauth_token_... │
│ │
│ [Routes auto-mounted] │
│ /.well-known/config │
│ /oauth/authorize │
│ /oauth/token │
│ /oauth/userinfo │
│ /oauth/revoke │
│ /oauth/introspect │
├──────────────────────────────┤
│ Sequel ORM │
├──────────────────────────────┤
│ PostgreSQL Database │
│ - accounts (rodauth)
│ - oauth_applications
│ - oauth_grants (unified!)
│ - optional feature tables
└──────────────────────────────┘
```
### Option C: Microservices Architecture (Hybrid)
```
┌──────────────────────────┐ ┌──────────────────────────┐
│ Your Rails App │ │ Rodauth-OAuth Service │
├──────────────────────────┤ ├──────────────────────────┤
│ Normal Rails Controllers │ │ lib/rodauth_app.rb │
│ & Business Logic │ │ [OAuth Features] │
│ │ │ │
│ HTTP Calls to →──────────┼─────→ /.well-known/config │
│ OAuth Service OAuth │ │ /oauth/authorize │
│ HTTP API │ │ /oauth/token │
│ │ │ /oauth/userinfo │
│ Verify Tokens via →──────┼─────→ /oauth/introspect │
│ /oauth/introspect │ │ │
├──────────────────────────┤ ├──────────────────────────┤
│ Rails ActiveRecord │ │ Sequel ORM │
├──────────────────────────┤ ├──────────────────────────┤
│ PostgreSQL │ │ PostgreSQL │
│ [business tables] │ │ [oauth tables] │
└──────────────────────────┘ └──────────────────────────┘
```
---
## Effort Estimates
### Option A: Keep & Enhance Custom Implementation
```
Refresh Tokens: 1-2 weeks
Token Revocation: 1-2 weeks
Token Introspection: 1-2 weeks
Client Credentials: 2-3 weeks
Device Code: 3-4 weeks
JWT Access Tokens: 1-2 weeks
Session Management: 2-3 weeks
Front-Channel Logout: 1-2 weeks
Back-Channel Logout: 2-3 weeks
─────────────────────────────────
TOTAL FOR PARITY: 15-25 weeks
(4-6 months of work)
ONGOING MAINTENANCE: ~8-10 hours/month
(security updates, RFC changes, bug fixes)
```
### Option B: Migrate to Rodauth-OAuth
```
Learn Roda/Rodauth: 1-2 weeks
Migrate Database Schema: 1-2 weeks
Replace OIDC Code: 1-2 weeks
Test & Validation: 2-3 weeks
─────────────────────────────────
ONE-TIME EFFORT: 5-9 weeks
(1-2 months)
ONGOING MAINTENANCE: ~1-2 hours/month
(dependency updates, config tweaks)
```
### Option C: Hybrid Approach
```
Set up Rodauth service: 1-2 weeks
Configure integration: 1-2 weeks
Test both services: 1 week
─────────────────────────────────
ONE-TIME EFFORT: 3-5 weeks
(less than Option B)
ONGOING MAINTENANCE: ~2-3 hours/month
(maintain two services, but Roda handles OAuth)
```
---
## Real-World Questions to Ask Your Team
### Question 1: Feature Needs
- "Do we need refresh tokens?"
- "Will clients ask for token revocation?"
- "Do we support service-to-service auth (client credentials)?"
- "Will we ever need device code flow (IoT)?"
If YES to any: **Option B or C makes sense**
### Question 2: Maintenance Philosophy
- "Do we want to own the OAuth code?"
- "Can we afford to maintain OAuth compliance?"
- "Do we have experts in OAuth/OIDC?"
If NO to all: **Option B or C is better**
### Question 3: Framework Flexibility
- "Is Rails non-negotiable for this company?"
- "Can our team learn a new framework?"
- "Can we run microservices?"
If Rails is required: **Option C (hybrid)**
### Question 4: Time Constraints
- "Do we have 4-8 weeks for a migration?"
- "Can we maintain OAuth for years?"
- "What if specs change?"
If time-constrained: **Option B is fastest path to full features**
---
## Security Comparison
### Your Implementation
- ✓ PKCE support
- ✓ JWT signing
- ✓ HTTPS recommended
- ✗ Token hashing (stores tokens in plaintext)
- ✗ Token rotation
- ✗ DPoP (token binding)
- ✗ Automatic spec compliance
- Risk: Token theft if DB compromised
### Rodauth-OAuth
- ✓ PKCE support
- ✓ JWT signing
- ✓ Token hashing (bcrypt by default)
- ✓ Token rotation policies
- ✓ DPoP support (RFC 9449)
- ✓ TLS mutual authentication
- ✓ Automatic spec updates
- ✓ Certified compliance
- Risk: Minimal (industry-standard)
---
## Cost-Benefit Summary
### Keep Your Implementation
```
Costs:
- 15-25 weeks to feature parity
- Ongoing security monitoring
- Spec compliance tracking
- Bug fixes & edge cases
Benefits:
- No framework learning
- Full code understanding
- Rails-native patterns
- Minimal dependencies
```
### Switch to Rodauth-OAuth
```
Costs:
- 5-9 weeks migration effort
- Learn Roda/Rodauth
- Database schema changes
- Test all flows
Benefits:
- Get 34 features immediately
- Certified compliance
- Community-maintained
- Security best practices
- Ongoing support
```
### Hybrid Approach
```
Costs:
- 3-5 weeks setup
- Learn Roda basics
- Operate two services
- Service communication
Benefits:
- All Rodauth-OAuth features
- Rails app unchanged
- Independent scaling
- Clear separation of concerns
```
---
## Decision Scorecard
| Factor | Option A | Option B | Option C |
|--------|----------|----------|----------|
| Initial Time | Low | Medium | Medium-Low |
| Ongoing Effort | High | Low | Medium |
| Feature Completeness | Low | High | High |
| Framework Learning | None | Medium | Low |
| Standards Compliance | Manual | Auto | Auto |
| Deployment Complexity | Simple | Simple | Complex |
| Team Preference | ??? | ??? | ??? |
---
## Next Actions
### For Option A (Keep Custom):
1. Plan feature roadmap (refresh tokens first)
2. Allocate team capacity for implementation
3. Document OAuth decisions
4. Set up security monitoring
### For Option B (Full Migration):
1. Assign someone to learn Roda/Rodauth
2. Run rodauth-oauth examples
3. Plan database migration
4. Schedule migration window
5. Prepare rollback plan
### For Option C (Hybrid):
1. Evaluate microservices capability
2. Run Rodauth-OAuth example
3. Plan service boundaries
4. Set up service communication
5. Plan infrastructure for two services
---
## Still Can't Decide?
Ask these questions:
1. **Will you add features beyond Auth Code + PKCE in next 12 months?**
- YES → Option B or C
- NO → Option A
2. **Do you have maintenance bandwidth?**
- YES → Option A
- NO → Option B or C
3. **Can you run multiple services?**
- YES → Option C (best of both)
- NO → Option B (if framework is OK) or Option A (stay Rails)
---
## Document Files
You now have three documents:
1. **rodauth-oauth-analysis.md** - Deep technical analysis (12 sections)
2. **rodauth-oauth-quick-reference.md** - Quick lookup guide
3. **RODAUTH_DECISION_GUIDE.md** - This decision framework
Read in this order:
1. This guide (make a decision)
2. Quick reference (understand architecture)
3. Analysis (deep dive on your choice)
---
**Made Your Decision?** Create an issue/commit to document your choice and next steps!

176
docs/caddy-example.md Normal file
View File

@@ -0,0 +1,176 @@
# Caddy ForwardAuth Configuration Examples
## Basic Configuration (Protecting MEtube)
Assuming Caddy and Clinch are running in a docker compose, and we can use the sevice name `clinch`. Exterally, assume you're connecting to https://clinch.example.com/
```caddyfile
# Clinch SSO (main authentication server)
clinch.yourdomain.com {
reverse_proxy clinch:3000
}
# MEtube (protected by Clinch)
metube.yourdomain.com {
# Forward authentication to Clinch
forward_auth clinch:3000 {
uri /api/verify
# uri /api/verify?rd=https://clinch.yourdomain.com # Shouldn't need this, the rd value should be sent via headers
copy_headers Remote-User Remote-Email Remote-Groups Remote-Admin
}
# If authentication succeeds, proxy to MEtube
handle {
reverse_proxy * {
to http://<ip-address-of-metube>:8081
header_up X-Real-IP {remote_host}
}
}
}
```
## How It Works
1. User visits `https://metube.yourdomain.com`
2. Caddy makes request to `http://clinch:3000/api/verify passing in the url destination for metueb
3. Clinch checks if user is authenticated and authorized:
- If **200**: Caddy forwards request to MEtube with user headers
- If **302**: User is redirected to clinch.yourdomain.com to login
- If **403**: Access denied
4. User signs into Clinch (with TOTP if enabled or Passkey)
5. Clinch redirects back to MEtube
6. User can now access MEtube!
## Protecting Multiple Applications
```caddyfile
# Clinch SSO
clinch.yourdomain.com {
reverse_proxy clinch:3000
}
# MEtube - Anyone can access (no groups required)
metube.yourdomain.com {
forward_auth clinch:3000 {
uri /api/verify
copy_headers Remote-User Remote-Email Remote-Groups Remote-Admin
}
handle {
reverse_proxy * {
to http://metube:8081
header_up X-Real-IP {remote_host}
}
}
}
# Sonarr - Only "media-managers" group
sonarr.yourdomain.com {
forward_auth clinch:3000 {
uri /api/verify
copy_headers Remote-User Remote-Email Remote-Groups Remote-Admin
}
handle {
reverse_proxy * {
to http://sonarr:8989
header_up X-Real-IP {remote_host}
}
}
}
# Grafana - Only "admins" group
grafana.yourdomain.com {
forward_auth clinch:3000 {
uri /api/verify
copy_headers Remote-User Remote-Email Remote-Groups Remote-Admin
}
handle {
reverse_proxy * {
to http://grafana:3001
header_up X-Real-IP {remote_host}
}
}
}
```
## Setup Steps
### 1. Create Applications in Clinch
Create the Application within Clinch, making sure to set Forward Auth application type
### 2. Update Caddyfile
Add the forward_auth directives shown above.
### 3. Reload Caddy
```bash
caddy reload
```
### 4. Test
Visit https://metube.yourdomain.com - you should be redirected to Clinch login!
## Advanced: Passing Headers to Application
Some applications can use the forwarded headers for user identification:
```caddyfile
metube.yourdomain.com {
forward_auth clinch:3000 {
uri /api/verify
copy_headers Remote-User Remote-Email Remote-Groups Remote-Admin
}
# The headers are automatically passed to the backend
handle {
reverse_proxy * {
to http://metube:8081
header_up X-Real-IP {remote_host}
}
}
}
```
Now MEtube receives these headers with every request:
- `Remote-User`: user@example.com
- `Remote-Email`: user@example.com
- `Remote-Groups`: media-managers,users
- `Remote-Admin`: false
## Troubleshooting
### Users not staying logged in
Ensure your Caddy configuration preserves cookies:
```caddyfile
clinch.yourdomain.com {
reverse_proxy localhost:3000 {
header_up X-Forwarded-Host {host}
header_up X-Forwarded-Proto {scheme}
}
}
```
### Authentication loop
Check that the `/api/verify` endpoint is not itself protected:
- `/api/verify` must be accessible without authentication
- It returns 401/403 for unauthenticated users (this is expected)
### Check Clinch logs
```bash
tail -f log/production.log
```
You'll see ForwardAuth log messages like:
```
ForwardAuth: User user@example.com granted access to metube
ForwardAuth: Unauthorized - No session cookie
```

View File

@@ -0,0 +1,227 @@
# Forward Auth Testing Guide
## Overview
Testing forward authentication requires testing multiple layers: HTTP requests, session management, and header forwarding. This guide provides practical testing approaches.
## Quick Start
### 1. Start Rails Server
```bash
rails server
```
### 2. Basic curl Tests
#### Test 1: Unauthenticated Request
```bash
curl -v http://localhost:3000/api/verify \
-H "X-Forwarded-Host: test.example.com"
```
**Expected Result:** 302 redirect to login
```
< HTTP/1.1 302 Found
< Location: http://localhost:3000/signin?rd=https://test.example.com/
< X-Auth-Reason: No session cookie
```
#### Test 2: Authenticated Request
1. Sign in at http://localhost:3000/signin
2. Copy session cookie from browser
3. Run:
```bash
curl -v http://localhost:3000/api/verify \
-H "X-Forwarded-Host: test.example.com" \
-H "Cookie: _clinch_session_id=YOUR_SESSION_COOKIE"
```
**Expected Result:** 200 OK with headers
```
< HTTP/1.1 200 OK
< X-Remote-User: your-email@example.com
< X-Remote-Email: your-email@example.com
< X-Remote-Name: your-email@example.com
< X-Remote-Groups: group-name
< X-Remote-Admin: true/false
```
## Testing Header Configurations
### Create Test Rules in Admin Interface
1. **Default Headers Rule** (`test.example.com`)
- Leave header fields empty (uses defaults)
- Expected: X-Remote-* headers
2. **No Headers Rule** (`metube.example.com`)
- Set all header fields to empty strings
- Expected: No authentication headers (access only)
3. **Custom Headers Rule** (`grafana.example.com`)
- Set custom header names:
- User Header: `X-WEBAUTH-USER`
- Groups Header: `X-WEBAUTH-ROLES`
- Email Header: `X-WEBAUTH-EMAIL`
- Expected: Custom header names
### Test Different Configurations
```bash
# Test default headers
curl -v http://localhost:3000/api/verify \
-H "X-Forwarded-Host: test.example.com" \
-H "Cookie: _clinch_session_id=YOUR_SESSION_COOKIE"
# Test no headers (access only)
curl -v http://localhost:3000/api/verify \
-H "X-Forwarded-Host: metube.example.com" \
-H "Cookie: _clinch_session_id=YOUR_SESSION_COOKIE"
# Test custom headers
curl -v http://localhost:3000/api/verify \
-H "X-Forwarded-Host: grafana.example.com" \
-H "Cookie: _clinch_session_id=YOUR_SESSION_COOKIE"
```
## Domain Pattern Testing
Test various domain patterns:
```bash
# Wildcard subdomains
curl -v http://localhost:3000/api/verify \
-H "X-Forwarded-Host: app.test.example.com"
# Exact domains
curl -v http://localhost:3000/api/verify \
-H "X-Forwarded-Host: api.example.com"
# No matching rule (should use defaults)
curl -v http://localhost:3000/api/verify \
-H "X-Forwarded-Host: unknown.example.com"
```
## Integration Testing
### Test with Real Reverse Proxy (Caddy Example)
1. Set up Caddy with forward auth:
```caddyfile
example.com {
forward_auth localhost:3000 {
uri /api/verify
copy_headers X-Remote-User X-Remote-Email X-Remote-Groups X-Remote-Admin
}
reverse_proxy localhost:8080
}
```
2. Test by visiting `https://example.com` in browser
3. Should redirect to Clinch login, then back to application
## Unit Testing (Rails Console)
Test the header logic directly:
```ruby
# Rails console: rails console
# Get a user
user = User.first
# Test default headers
rule = ForwardAuthRule.create!(domain_pattern: 'test.example.com', active: true)
headers = rule.headers_for_user(user)
puts headers
# => {"X-Remote-User" => "user@example.com", "X-Remote-Email" => "user@example.com", ...}
# Test custom headers
rule.update!(headers_config: { user: 'X-Custom-User', groups: 'X-Custom-Groups' })
headers = rule.headers_for_user(user)
puts headers
# => {"X-Custom-User" => "user@example.com", "X-Remote-Email" => "user@example.com", ...}
# Test no headers
rule.update!(headers_config: { user: '', email: '', name: '', groups: '', admin: '' })
headers = rule.headers_for_user(user)
puts headers
# => {}
```
## Testing Checklist
### Basic Functionality
- [ ] Unauthenticated requests redirect to login
- [ ] Authenticated requests return 200 OK
- [ ] Headers are correctly forwarded to applications
- [ ] Session cookies work correctly
### Header Configurations
- [ ] Default headers (X-Remote-*) work
- [ ] Custom headers work with specific applications
- [ ] No headers option works for access-only apps
- [ ] Empty header fields are handled correctly
### Domain Matching
- [ ] Wildcard domains (*.example.com) work
- [ ] Exact domains work
- [ ] Case insensitivity works
- [ ] No matching rule falls back to defaults
### Access Control
- [ ] Group restrictions work correctly
- [ ] Inactive users are denied access
- [ ] Inactive rules are ignored
- [ ] Bypass mode (no groups) works
## Troubleshooting
### Common Issues
1. **Headers not being sent**
- Check rule is active
- Verify headers configuration
- Check user is in allowed groups
2. **Authentication loops**
- Check session cookie domain
- Verify redirect URLs
- Check browser cookie settings
3. **Headers not reaching application**
- Check reverse proxy configuration
- Verify proxy is forwarding headers
- Check application expects correct header names
### Debug Logging
Enable debug logging in `forward_auth_controller.rb`:
```ruby
Rails.logger.level = Logger::DEBUG
```
This will show detailed information about:
- Session extraction
- Rule matching
- Header generation
- Redirect URLs
## Production Testing
Before deploying to production:
1. **SSL/TLS Testing**: Test with HTTPS
2. **Cookie Domains**: Test cross-subdomain cookies
3. **Performance**: Test response times under load
4. **Security**: Test with invalid sessions and malformed headers
5. **Monitoring**: Set up logging and alerting
## Automation
For automated testing, consider:
1. **Integration Tests**: Use Rails integration tests for controller testing
2. **API Tests**: Use tools like Postman or Insomnia for API testing
3. **Browser Tests**: Use Selenium or Cypress for end-to-end testing
4. **Load Testing**: Use tools like k6 or JMeter for performance testing

View File

@@ -0,0 +1,611 @@
# OIDC Refresh Tokens - Client Implementation Guide
## Overview
Clinch now supports **OAuth 2.0 Refresh Tokens**, allowing your applications to maintain long-lived sessions without requiring users to re-authenticate every hour.
**Key Benefits:**
- ✅ No user re-authentication for 30 days (configurable)
- ✅ Silent token refresh - no redirects, no user interaction
- ✅ Secure token rotation - prevents reuse attacks
- ✅ Token revocation support - users can invalidate sessions
---
## Quick Start
### Before (Without Refresh Tokens)
```
User logs in → Access token (1 hour)
After 1 hour → Redirect to /oauth/authorize
User auto-approves → New access token
Repeat every hour... 😞
```
### Now (With Refresh Tokens)
```
User logs in → Access token (1 hour) + Refresh token (30 days)
After 1 hour → POST to /oauth/token with refresh_token
Get new tokens → No redirect! No user interaction! 🎉
```
---
## Initial Authorization
### 1. Authorization Code Flow (Unchanged)
**Step 1: Redirect user to authorization endpoint**
```
GET https://auth.example.com/oauth/authorize?
client_id=YOUR_CLIENT_ID&
redirect_uri=https://yourapp.com/callback&
response_type=code&
scope=openid%20profile%20email&
state=RANDOM_STATE&
code_challenge=BASE64URL(SHA256(code_verifier))&
code_challenge_method=S256
```
**Step 2: Exchange authorization code for tokens**
```http
POST https://auth.example.com/oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=AUTHORIZATION_CODE
&redirect_uri=https://yourapp.com/callback
&client_id=YOUR_CLIENT_ID
&client_secret=YOUR_CLIENT_SECRET
&code_verifier=CODE_VERIFIER
```
**Response (NEW - now includes refresh_token):**
```json
{
"access_token": "eyJhbGc...",
"token_type": "Bearer",
"expires_in": 3600,
"id_token": "eyJhbGc...",
"refresh_token": "abc123xyz...",
"scope": "openid profile email"
}
```
**IMPORTANT:** Store the `refresh_token` securely! You'll need it to get new access tokens.
---
## Token Refresh Flow
When your `access_token` expires (after 1 hour), use the `refresh_token` to get new tokens **without user interaction**.
### How to Refresh Tokens
**Request:**
```http
POST https://auth.example.com/oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token
&refresh_token=YOUR_REFRESH_TOKEN
&client_id=YOUR_CLIENT_ID
&client_secret=YOUR_CLIENT_SECRET
```
**Response:**
```json
{
"access_token": "eyJhbGc...NEW",
"token_type": "Bearer",
"expires_in": 3600,
"id_token": "eyJhbGc...NEW",
"refresh_token": "def456uvw...NEW",
"scope": "openid profile email"
}
```
**CRITICAL:**
- The old `refresh_token` is **immediately revoked** (single-use)
- You receive a **new `refresh_token`** to use next time
- **Replace** the old refresh token with the new one in your storage
---
## Token Lifecycle
```
┌─────────────────────────────────────────────────────────┐
│ Initial Authorization │
├─────────────────────────────────────────────────────────┤
│ GET /oauth/authorize → User logs in │
│ POST /oauth/token (authorization_code grant) │
│ ↓ │
│ Receive: access_token (1h) + refresh_token (30d) │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ Token Refresh (Silent, No User Interaction) │
├─────────────────────────────────────────────────────────┤
│ After 1 hour (access_token expires): │
│ POST /oauth/token (refresh_token grant) │
│ ↓ │
│ Receive: NEW access_token + NEW refresh_token │
│ Old refresh_token is revoked │
└─────────────────────────────────────────────────────────┘
↓ (Repeat for 30 days)
┌─────────────────────────────────────────────────────────┐
│ Session Expiry │
├─────────────────────────────────────────────────────────┤
│ After 30 days (refresh_token expires): │
│ Redirect user to /oauth/authorize for re-authentication │
└─────────────────────────────────────────────────────────┘
```
---
## Token Storage Best Practices
### ✅ Secure Storage Recommendations
**Web Applications (Server-Side):**
- Store refresh tokens in **server-side session** (encrypted)
- Use **HttpOnly, Secure cookies** for access tokens
- **Never** send refresh tokens to browser JavaScript
**Single Page Applications (SPAs):**
- Store access tokens in **memory only** (JavaScript variable)
- Store refresh tokens in **HttpOnly, Secure cookie** (via backend)
- Use Backend-for-Frontend (BFF) pattern for refresh
**Mobile Apps:**
- Use platform-specific **secure storage**:
- iOS: Keychain
- Android: EncryptedSharedPreferences or Keystore
- **Never** store in UserDefaults/SharedPreferences
**Desktop Apps:**
- Use OS-specific credential storage
- Encrypt tokens at rest
### ❌ DO NOT Store Refresh Tokens In:
- LocalStorage (XSS vulnerable)
- SessionStorage (XSS vulnerable)
- Unencrypted cookies
- Plain text files
- Source code or config files
---
## Token Revocation
Allow users to invalidate their sessions (e.g., "Sign out of all devices").
### Revoke a Token
**Request:**
```http
POST https://auth.example.com/oauth/revoke
Content-Type: application/x-www-form-urlencoded
token=YOUR_TOKEN
&token_type_hint=refresh_token
&client_id=YOUR_CLIENT_ID
&client_secret=YOUR_CLIENT_SECRET
```
**Parameters:**
- `token` (required) - The token to revoke (access or refresh token)
- `token_type_hint` (optional) - "access_token" or "refresh_token"
- `client_id` + `client_secret` (required) - Client authentication
**Response:**
```
HTTP/1.1 200 OK
```
**Note:** Per RFC 7009, the response is always `200 OK`, even if the token was invalid or already revoked (prevents token scanning attacks).
---
## Error Handling
### Refresh Token Errors
#### 1. Invalid or Expired Refresh Token
```json
{
"error": "invalid_grant",
"error_description": "Invalid refresh token"
}
```
**Action:** Redirect user to /oauth/authorize for re-authentication
#### 2. Refresh Token Revoked (Reuse Detected!)
```json
{
"error": "invalid_grant",
"error_description": "Refresh token has been revoked"
}
```
**Action:**
- This indicates a **security issue** (possible token theft)
- All tokens in the same family are revoked
- Redirect user to /oauth/authorize
- Consider alerting the user about suspicious activity
#### 3. Invalid Client Credentials
```json
{
"error": "invalid_client"
}
```
**Action:** Check your `client_id` and `client_secret`
---
## Implementation Examples
### Example 1: Node.js Express
```javascript
const axios = require('axios');
class OAuthClient {
constructor(config) {
this.clientId = config.clientId;
this.clientSecret = config.clientSecret;
this.tokenEndpoint = config.tokenEndpoint;
this.accessToken = null;
this.refreshToken = null;
this.expiresAt = null;
}
// Exchange authorization code for tokens
async exchangeCode(code, redirectUri, codeVerifier) {
const response = await axios.post(this.tokenEndpoint, new URLSearchParams({
grant_type: 'authorization_code',
code: code,
redirect_uri: redirectUri,
client_id: this.clientId,
client_secret: this.clientSecret,
code_verifier: codeVerifier
}));
this.storeTokens(response.data);
return response.data;
}
// Refresh access token
async refreshAccessToken() {
if (!this.refreshToken) {
throw new Error('No refresh token available');
}
const response = await axios.post(this.tokenEndpoint, new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: this.refreshToken,
client_id: this.clientId,
client_secret: this.clientSecret
}));
this.storeTokens(response.data);
return response.data;
}
// Get valid access token (auto-refresh if needed)
async getAccessToken() {
// Check if token is expired or about to expire (5 min buffer)
if (this.expiresAt && Date.now() >= this.expiresAt - 300000) {
await this.refreshAccessToken();
}
return this.accessToken;
}
storeTokens(tokenResponse) {
this.accessToken = tokenResponse.access_token;
this.refreshToken = tokenResponse.refresh_token;
this.expiresAt = Date.now() + (tokenResponse.expires_in * 1000);
}
// Revoke tokens
async revokeToken(token, tokenTypeHint) {
await axios.post('https://auth.example.com/oauth/revoke', new URLSearchParams({
token: token,
token_type_hint: tokenTypeHint,
client_id: this.clientId,
client_secret: this.clientSecret
}));
}
}
// Usage
const client = new OAuthClient({
clientId: 'your-client-id',
clientSecret: 'your-client-secret',
tokenEndpoint: 'https://auth.example.com/oauth/token'
});
// After initial login
await client.exchangeCode(authCode, redirectUri, codeVerifier);
// Make API calls (auto-refreshes if needed)
const token = await client.getAccessToken();
const apiResponse = await axios.get('https://api.example.com/data', {
headers: { Authorization: `Bearer ${token}` }
});
// Logout - revoke refresh token
await client.revokeToken(client.refreshToken, 'refresh_token');
```
### Example 2: Python
```python
import requests
import time
from urllib.parse import urlencode
class OAuthClient:
def __init__(self, client_id, client_secret, token_endpoint):
self.client_id = client_id
self.client_secret = client_secret
self.token_endpoint = token_endpoint
self.access_token = None
self.refresh_token = None
self.expires_at = None
def exchange_code(self, code, redirect_uri, code_verifier):
"""Exchange authorization code for tokens"""
response = requests.post(self.token_endpoint, data={
'grant_type': 'authorization_code',
'code': code,
'redirect_uri': redirect_uri,
'client_id': self.client_id,
'client_secret': self.client_secret,
'code_verifier': code_verifier
})
response.raise_for_status()
self._store_tokens(response.json())
return response.json()
def refresh_access_token(self):
"""Refresh the access token using refresh token"""
if not self.refresh_token:
raise ValueError('No refresh token available')
response = requests.post(self.token_endpoint, data={
'grant_type': 'refresh_token',
'refresh_token': self.refresh_token,
'client_id': self.client_id,
'client_secret': self.client_secret
})
response.raise_for_status()
self._store_tokens(response.json())
return response.json()
def get_access_token(self):
"""Get valid access token, refresh if needed"""
# Check if token is expired (with 5 min buffer)
if self.expires_at and time.time() >= self.expires_at - 300:
self.refresh_access_token()
return self.access_token
def _store_tokens(self, token_response):
"""Store tokens and expiration time"""
self.access_token = token_response['access_token']
self.refresh_token = token_response['refresh_token']
self.expires_at = time.time() + token_response['expires_in']
def revoke_token(self, token, token_type_hint='refresh_token'):
"""Revoke a token"""
requests.post('https://auth.example.com/oauth/revoke', data={
'token': token,
'token_type_hint': token_type_hint,
'client_id': self.client_id,
'client_secret': self.client_secret
})
# Usage
client = OAuthClient(
client_id='your-client-id',
client_secret='your-client-secret',
token_endpoint='https://auth.example.com/oauth/token'
)
# After initial login
client.exchange_code(auth_code, redirect_uri, code_verifier)
# Make API calls (auto-refreshes if needed)
token = client.get_access_token()
response = requests.get('https://api.example.com/data',
headers={'Authorization': f'Bearer {token}'})
# Logout
client.revoke_token(client.refresh_token, 'refresh_token')
```
---
## Security Considerations
### 1. Token Rotation (Implemented ✅)
- Each refresh token is **single-use only**
- After use, old refresh token is immediately revoked
- New refresh token is issued
- Prevents replay attacks
### 2. Token Family Tracking (Implemented ✅)
- All refresh tokens in a rotation chain share a `token_family_id`
- If a **revoked** refresh token is reused → **entire family is revoked**
- Detects stolen token attacks
### 3. Refresh Token Binding
- Refresh tokens are bound to:
- Specific client (client_id)
- Specific user
- Specific scopes
- Cannot be used by different clients
### 4. Expiration Times (Configurable per application)
- **Access tokens:** 5 minutes - 24 hours (default: 1 hour)
- **Refresh tokens:** 1 day - 90 days (default: 30 days)
- **ID tokens:** 5 minutes - 24 hours (default: 1 hour)
---
## Discovery Endpoint Updates
The OIDC discovery endpoint now advertises refresh token support:
**GET `https://auth.example.com/.well-known/openid-configuration`**
```json
{
"issuer": "https://auth.example.com",
"authorization_endpoint": "https://auth.example.com/oauth/authorize",
"token_endpoint": "https://auth.example.com/oauth/token",
"revocation_endpoint": "https://auth.example.com/oauth/revoke",
"userinfo_endpoint": "https://auth.example.com/oauth/userinfo",
"jwks_uri": "https://auth.example.com/.well-known/jwks.json",
"grant_types_supported": ["authorization_code", "refresh_token"],
"response_types_supported": ["code"],
"scopes_supported": ["openid", "profile", "email", "groups"],
"token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"],
...
}
```
---
## Testing Your Implementation
### Test 1: Initial Token Exchange
```bash
# Get authorization code (manual - visit in browser)
# Then exchange for tokens:
curl -X POST https://auth.example.com/oauth/token \
-d "grant_type=authorization_code" \
-d "code=YOUR_AUTH_CODE" \
-d "redirect_uri=https://yourapp.com/callback" \
-d "client_id=YOUR_CLIENT_ID" \
-d "client_secret=YOUR_CLIENT_SECRET" \
-d "code_verifier=YOUR_CODE_VERIFIER"
# Response should include refresh_token
```
### Test 2: Token Refresh
```bash
curl -X POST https://auth.example.com/oauth/token \
-d "grant_type=refresh_token" \
-d "refresh_token=YOUR_REFRESH_TOKEN" \
-d "client_id=YOUR_CLIENT_ID" \
-d "client_secret=YOUR_CLIENT_SECRET"
# Response should include NEW access_token and NEW refresh_token
```
### Test 3: Token Revocation
```bash
curl -X POST https://auth.example.com/oauth/revoke \
-d "token=YOUR_REFRESH_TOKEN" \
-d "token_type_hint=refresh_token" \
-d "client_id=YOUR_CLIENT_ID" \
-d "client_secret=YOUR_CLIENT_SECRET"
# Should return 200 OK
```
### Test 4: Reuse Detection (Security Test)
```bash
# 1. Use refresh token to get new tokens
curl -X POST ... (as in Test 2)
# 2. Try to use the OLD refresh token again
curl -X POST ... (with OLD refresh_token)
# Should return error: "invalid_grant" - token has been revoked
```
---
## FAQ
### Q: How long do refresh tokens last?
**A:** By default, 30 days. This is configurable per application (1-90 days).
### Q: Can I use the same refresh token multiple times?
**A:** No. Refresh tokens are **single-use**. After using a refresh token, you get a new one.
### Q: What happens if my refresh token is stolen?
**A:** If someone tries to use a revoked refresh token, all tokens in that family are immediately revoked (token rotation security).
### Q: Do I need to store the ID token?
**A:** Usually no. The ID token is for authentication (verify user identity). You typically decode it, verify it, extract claims, then discard it.
### Q: Can I refresh an access token before it expires?
**A:** Yes! It's recommended to refresh tokens 5-10 minutes before expiration to avoid race conditions.
### Q: What if my refresh token expires?
**A:** User must re-authenticate via the normal OAuth flow (redirect to /oauth/authorize).
### Q: Can I revoke all of a user's sessions at once?
**A:** Yes, but you need to track all refresh tokens per user on your backend, then revoke them all.
### Q: Are access tokens revocable?
**A:** Yes! You can revoke access tokens using the same `/oauth/revoke` endpoint.
---
## Migration Guide (From Access Token Only)
### Before (Access Token Only):
```javascript
// User logs in
const tokens = await exchangeAuthCode(code);
localStorage.setItem('access_token', tokens.access_token);
// After 1 hour -> Token expires -> Redirect to login
if (isTokenExpired()) {
window.location = '/oauth/authorize';
}
```
### After (With Refresh Tokens):
```javascript
// User logs in
const tokens = await exchangeAuthCode(code);
sessionStorage.setItem('access_token', tokens.access_token);
secureStorage.set('refresh_token', tokens.refresh_token); // Encrypted
// After 1 hour -> Refresh silently
if (isTokenExpired()) {
const newTokens = await refreshAccessToken();
sessionStorage.setItem('access_token', newTokens.access_token);
secureStorage.set('refresh_token', newTokens.refresh_token);
}
```
---
## Additional Resources
- **RFC 6749 (OAuth 2.0):** https://datatracker.ietf.org/doc/html/rfc6749
- **RFC 7009 (Token Revocation):** https://datatracker.ietf.org/doc/html/rfc7009
- **OIDC Core Spec:** https://openid.net/specs/openid-connect-core-1_0.html
- **OAuth 2.0 Security Best Practices:** https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics
---
## Support
For issues or questions about refresh token implementation, contact your Clinch administrator or check the application documentation.
**Version:** 1.0
**Last Updated:** November 2025

View File

@@ -0,0 +1,913 @@
# Rodauth-OAuth Analysis: Comprehensive Comparison with Clinch's Custom Implementation
## Executive Summary
**Rodauth-OAuth** is a production-ready Ruby gem that implements the OAuth 2.0 framework and OpenID Connect on top of the `rodauth` authentication library. It's architected as a modular feature-based system that integrates with Roda (a routing library) and provides extensive OAuth/OIDC capabilities.
Your current Clinch implementation is a **custom, minimalist Rails-based OIDC provider** focusing on the authorization code grant with PKCE support. Switching to rodauth-oauth would provide significantly more features and standards compliance but requires architectural changes.
---
## 1. What Rodauth-OAuth Is
### Core Identity
- **Type**: Ruby gem providing OAuth 2.0 & OpenID Connect implementation
- **Framework**: Built on top of `rodauth` (a dedicated authentication library)
- **Web Framework**: Designed for Roda framework (lightweight, routing-focused)
- **Rails Support**: Available via `rodauth-rails` wrapper
- **Maturity**: Production-ready, OpenID-Certified for multiple profiles
- **Author**: Tiago Cardoso (tiago.cardoso@gmail.com)
- **License**: Apache 2.0
### Architecture Philosophy
- **Feature-based**: Modular "features" that can be enabled/disabled
- **Database-agnostic**: Uses Sequel ORM, works with any SQL database
- **Highly configurable**: Override methods to customize behavior
- **Standards-focused**: Implements RFCs and OpenID specs strictly
---
## 2. File Structure and Organization
### Directory Layout in `/tmp/rodauth-oauth`
```
rodauth-oauth/
├── lib/
│ └── rodauth/
│ ├── oauth.rb # Main module entry point
│ ├── oauth/
│ │ ├── version.rb
│ │ ├── database_extensions.rb
│ │ ├── http_extensions.rb
│ │ ├── jwe_extensions.rb
│ │ └── ttl_store.rb
│ └── features/ # 34 feature files!
│ ├── oauth_base.rb # Foundation
│ ├── oauth_authorization_code_grant.rb
│ ├── oauth_pkce.rb
│ ├── oauth_jwt*.rb # JWT support (5 files)
│ ├── oidc.rb # OpenID Core
│ ├── oidc_*logout.rb # Logout flows (3 files)
│ ├── oauth_client_credentials_grant.rb
│ ├── oauth_device_code_grant.rb
│ ├── oauth_token_revocation.rb
│ ├── oauth_token_introspection.rb
│ ├── oauth_dynamic_client_registration.rb
│ ├── oauth_dpop.rb # DPoP support
│ ├── oauth_tls_client_auth.rb
│ ├── oauth_pushed_authorization_request.rb
│ ├── oauth_assertion_base.rb
│ └── ... (more features)
├── test/
│ ├── migrate/ # Database migrations
│ │ ├── 001_accounts.rb
│ │ ├── 003_oauth_applications.rb
│ │ ├── 004_oauth_grants.rb
│ │ ├── 005_pushed_requests.rb
│ │ ├── 006_saml_settings.rb
│ │ └── 007_dpop_proofs.rb
│ └── [multiple test directories with hundreds of tests]
├── examples/ # Full working examples
│ ├── authorization_server/
│ ├── oidc/
│ ├── jwt/
│ ├── device_grant/
│ ├── saml_assertion/
│ └── mtls/
├── templates/ # HTML/ERB templates
├── locales/ # i18n translations
├── doc/
└── [Gemfile, README, MIGRATION-GUIDE, etc.]
```
### Feature Count: 34 Features!
The gem is completely modular. Each feature can be independently enabled:
**Core OAuth Features:**
- `oauth_base` - Foundation
- `oauth_authorization_code_grant` - Authorization Code Flow
- `oauth_implicit_grant` - Implicit Flow
- `oauth_client_credentials_grant` - Client Credentials Flow
- `oauth_device_code_grant` - Device Code Flow
**Token Management:**
- `oauth_token_revocation` - RFC 7009
- `oauth_token_introspection` - RFC 7662
- `oauth_refresh_token` - Refresh tokens
**Security & Advanced:**
- `oauth_pkce` - RFC 7636 (what Clinch is using!)
- `oauth_jwt` - JWT Access Tokens
- `oauth_jwt_bearer_grant` - RFC 7523
- `oauth_saml_bearer_grant` - RFC 7522
- `oauth_tls_client_auth` - Mutual TLS
- `oauth_dpop` - Demonstrating Proof-of-Possession
- `oauth_jwt_secured_authorization_request` - Request Objects
- `oauth_resource_indicators` - RFC 8707
- `oauth_pushed_authorization_request` - RFC 9126
**OpenID Connect:**
- `oidc` - Core OpenID Connect
- `oidc_session_management` - Session Management
- `oidc_rp_initiated_logout` - RP-Initiated Logout
- `oidc_frontchannel_logout` - Front-Channel Logout
- `oidc_backchannel_logout` - Back-Channel Logout
- `oidc_dynamic_client_registration` - Dynamic Registration
- `oidc_self_issued` - Self-Issued Provider
**Management & Discovery:**
- `oauth_application_management` - Client app dashboard
- `oauth_grant_management` - Grant management dashboard
- `oauth_dynamic_client_registration` - RFC 7591/7592
- `oauth_jwt_jwks` - JWKS endpoint
---
## 3. OIDC/OAuth Features Provided
### Grant Types Supported (15 types!)
| Grant Type | Status | RFC/Spec |
|-----------|--------|----------|
| Authorization Code | Yes | RFC 6749 |
| Implicit | Optional | RFC 6749 |
| Client Credentials | Optional | RFC 6749 |
| Device Code | Optional | RFC 8628 |
| Refresh Token | Yes | RFC 6749 |
| JWT Bearer | Optional | RFC 7523 |
| SAML Bearer | Optional | RFC 7522 |
### Response Types & Modes
**Response Types:**
- `code` (Authorization Code) - Default
- `id_token` (OIDC Implicit) - Optional
- `token` (Implicit) - Optional
- `id_token token` (Hybrid) - Optional
- `code id_token` (Hybrid) - Optional
- `code token` (Hybrid) - Optional
- `code id_token token` (Hybrid) - Optional
**Response Modes:**
- `query` (URL parameters)
- `fragment` (URL fragment)
- `form_post` (HTML form)
- `jwt` (JWT-based response)
### OpenID Connect Features
**Certified for:**
- Basic OP (OpenID Provider)
- Implicit OP
- Hybrid OP
- Config OP (Discovery)
- Dynamic OP (Dynamic Client Registration)
- Form Post OP
- 3rd Party-Init OP
- Session Management OP
- RP-Initiated Logout OP
- Front-Channel Logout OP
- Back-Channel Logout OP
**Standard Claims Support:**
- `openid`, `email`, `profile`, `address`, `phone` scopes
- Automatic claim mapping per OpenID spec
- Custom claims via extension
**Token Features:**
- JWT ID Tokens
- JWT Access Tokens
- Encrypted JWTs (JWE support)
- HMAC-SHA256 signing
- RSA/EC signing
- Custom token formats
### Security Features
| Feature | Details |
|---------|---------|
| PKCE | RFC 7636 - Proof Key for Public Clients |
| Token Hashing | Bcrypt-based token storage (plain text optional) |
| DPoP | RFC 9449 - Demonstrating Proof-of-Possession |
| TLS Client Auth | RFC 8705 - Mutual TLS authentication |
| Request Objects | JWT-signed/encrypted authorization requests |
| Pushed Auth Requests | RFC 9126 - Pushed Authorization Requests |
| Token Introspection | RFC 7662 - Token validation without DB lookup |
| Token Revocation | RFC 7009 - Revoke tokens on demand |
### Scopes & Authorization
- Configurable scope list per application
- Offline access support (refresh tokens)
- Scope-based access control
- Custom scope handlers
- Consent UI for user authorization
---
## 4. Architecture: How It Works
### As a Plugin System
Rodauth-OAuth integrates with Roda as a **plugin**:
```ruby
# This is how you configure it
class AuthServer < Roda
plugin :rodauth do
db database_connection
# Enable features
enable :login, :logout, :create_account, :oidc, :oidc_session_management,
:oauth_pkce, :oauth_authorization_code_grant
# Configure
oauth_application_scopes %w[openid email profile]
oauth_require_pkce true
hmac_secret "SECRET"
# Customize with blocks
oauth_jwt_keys("RS256" => [private_key])
oauth_jwt_public_keys("RS256" => [public_key])
end
end
```
### Request Flow Architecture
```
1. Authorization Request
rodauth validates params
(if not auth'd) user logs in via rodauth
(if first use) consent page rendered
create oauth_grant (code, nonce, PKCE challenge, etc.)
redirect with auth code
2. Token Exchange
rodauth validates client (Basic/POST auth)
validates code, redirect_uri, PKCE verifier
creates access token (plain or JWT)
creates refresh token
returns JSON with tokens
3. UserInfo
validate access token
lookup grant/account
return claims as JSON
```
### Feature Composition
Features depend on each other. For example:
- `oidc` depends on: `active_sessions`, `oauth_jwt`, `oauth_jwt_jwks`, `oauth_authorization_code_grant`, `oauth_implicit_grant`
- `oauth_pkce` depends on: `oauth_authorization_code_grant`
- `oidc_rp_initiated_logout` depends on: `oidc`
This is a **strong dependency injection pattern**.
---
## 5. Database Schema Requirements
### Rodauth-OAuth Tables
#### `accounts` table (from rodauth)
```sql
CREATE TABLE accounts (
id INTEGER PRIMARY KEY,
status_id INTEGER DEFAULT 1, -- unverified/verified/closed
email VARCHAR UNIQUE NOT NULL,
-- password-related columns (added by rodauth features)
password_hash VARCHAR,
-- other rodauth-managed columns
);
```
#### `oauth_applications` table (75+ columns!)
```sql
CREATE TABLE oauth_applications (
id INTEGER PRIMARY KEY,
account_id INTEGER FOREIGN KEY,
-- Basic info
name VARCHAR NOT NULL,
description VARCHAR,
homepage_url VARCHAR,
logo_uri VARCHAR,
tos_uri VARCHAR,
policy_uri VARCHAR,
-- OAuth credentials
client_id VARCHAR UNIQUE NOT NULL,
client_secret VARCHAR UNIQUE NOT NULL,
registration_access_token VARCHAR,
-- OAuth config
redirect_uri VARCHAR NOT NULL,
scopes VARCHAR NOT NULL,
token_endpoint_auth_method VARCHAR,
grant_types VARCHAR,
response_types VARCHAR,
response_modes VARCHAR,
-- JWT/JWKS
jwks_uri VARCHAR,
jwks TEXT,
jwt_public_key TEXT,
-- OIDC-specific
sector_identifier_uri VARCHAR,
application_type VARCHAR,
initiate_login_uri VARCHAR,
subject_type VARCHAR,
-- Token encryption algorithms
id_token_signed_response_alg VARCHAR,
id_token_encrypted_response_alg VARCHAR,
id_token_encrypted_response_enc VARCHAR,
userinfo_signed_response_alg VARCHAR,
userinfo_encrypted_response_alg VARCHAR,
userinfo_encrypted_response_enc VARCHAR,
-- Request object handling
request_object_signing_alg VARCHAR,
request_object_encryption_alg VARCHAR,
request_object_encryption_enc VARCHAR,
request_uris VARCHAR,
require_signed_request_object BOOLEAN,
-- PAR (Pushed Auth Requests)
require_pushed_authorization_requests BOOLEAN DEFAULT FALSE,
-- DPoP
dpop_bound_access_tokens BOOLEAN DEFAULT FALSE,
-- TLS Client Auth
tls_client_auth_subject_dn VARCHAR,
tls_client_auth_san_dns VARCHAR,
tls_client_auth_san_uri VARCHAR,
tls_client_auth_san_ip VARCHAR,
tls_client_auth_san_email VARCHAR,
tls_client_certificate_bound_access_tokens BOOLEAN DEFAULT FALSE,
-- Logout URIs
post_logout_redirect_uris VARCHAR,
frontchannel_logout_uri VARCHAR,
frontchannel_logout_session_required BOOLEAN DEFAULT FALSE,
backchannel_logout_uri VARCHAR,
backchannel_logout_session_required BOOLEAN DEFAULT FALSE,
-- Response encryption
authorization_signed_response_alg VARCHAR,
authorization_encrypted_response_alg VARCHAR,
authorization_encrypted_response_enc VARCHAR,
contact_info VARCHAR,
software_id VARCHAR,
software_version VARCHAR
);
```
#### `oauth_grants` table (everything in one table!)
```sql
CREATE TABLE oauth_grants (
id INTEGER PRIMARY KEY,
account_id INTEGER FOREIGN KEY, -- nullable for client credentials
oauth_application_id INTEGER FOREIGN KEY,
sub_account_id INTEGER, -- for context-based ownership
type VARCHAR, -- 'authorization_code', 'refresh_token', etc.
-- Authorization code flow
code VARCHAR UNIQUE (per app),
redirect_uri VARCHAR,
-- Tokens (stored hashed or plain)
token VARCHAR UNIQUE,
token_hash VARCHAR UNIQUE,
refresh_token VARCHAR UNIQUE,
refresh_token_hash VARCHAR UNIQUE,
-- Expiry
expires_in TIMESTAMP NOT NULL,
revoked_at TIMESTAMP,
-- Scopes
scopes VARCHAR NOT NULL,
access_type VARCHAR DEFAULT 'offline', -- 'offline' or 'online'
-- PKCE
code_challenge VARCHAR,
code_challenge_method VARCHAR, -- 'plain' or 'S256'
-- Device Code Grant
user_code VARCHAR UNIQUE,
last_polled_at TIMESTAMP,
-- TLS Client Auth
certificate_thumbprint VARCHAR,
-- Resource Indicators
resource VARCHAR,
-- OpenID Connect
nonce VARCHAR,
acr VARCHAR, -- Authentication Context Class
claims_locales VARCHAR,
claims VARCHAR, -- custom OIDC claims
-- DPoP
dpop_jkt VARCHAR -- DPoP key thumbprint
);
```
#### Optional Tables for Advanced Features
```sql
-- For Pushed Authorization Requests
CREATE TABLE oauth_pushed_requests (
request_uri VARCHAR UNIQUE PRIMARY KEY,
oauth_application_id INTEGER FOREIGN KEY,
params TEXT, -- JSON params
created_at TIMESTAMP
);
-- For SAML Assertion Grant
CREATE TABLE oauth_saml_settings (
id INTEGER PRIMARY KEY,
oauth_application_id INTEGER FOREIGN KEY,
idp_url VARCHAR,
certificate TEXT,
-- ...
);
-- For DPoP
CREATE TABLE oauth_dpop_proofs (
id INTEGER PRIMARY KEY,
oauth_grant_id INTEGER FOREIGN KEY,
jti VARCHAR UNIQUE,
created_at TIMESTAMP
);
```
### Key Differences from Your Implementation
| Aspect | Your Implementation | Rodauth-OAuth |
|--------|-------------------|----------------|
| Authorization Codes | Separate table | In oauth_grants |
| Access Tokens | Separate table | In oauth_grants |
| Refresh Tokens | Not implemented | In oauth_grants |
| Token Hashing | Not done | Bcrypt (default) |
| Applications | Basic (name, client_id, secret) | 75+ columns for full spec |
| PKCE | Simple columns | Built-in feature |
| Account Data | In users table | In accounts table |
| Session Management | Session model | Rodauth's account_active_session_keys |
| User Consent | OidcUserConsent table | In memory or via hooks |
---
## 6. Integration Points with Rails
### Via Rodauth-Rails Wrapper
Rodauth-OAuth can be used in Rails through the `rodauth-rails` gem:
```bash
# Install generator
gem 'rodauth-rails'
bundle install
rails generate rodauth:install
rails generate rodauth:oauth:install # Generates OIDC tables/migrations
rails generate rodauth:oauth:views # Generates templates
```
### Generated Components
1. **Migration**: `db/migrate/*_create_rodauth_oauth.rb`
- Creates all OAuth tables
- Customizable column names via config
2. **Models**: `app/models/`
- `RodauthApp` (configuration)
- `OauthApplication` (client app)
- `OauthGrant` (grants/tokens)
- Customizable!
3. **Views**: `app/views/rodauth/`
- Authorization consent form
- Application management dashboard
- Grant management dashboard
4. **Lib**: `lib/rodauth_app.rb`
- Main rodauth configuration
### Rails Controller Integration
```ruby
class BooksController < ApplicationController
before_action :require_oauth_authorization, only: %i[create update]
before_action :require_oauth_authorization_scopes, only: %i[create update]
private
def require_oauth_authorization(scope = "books.read")
rodauth.require_oauth_authorization(scope)
end
end
```
Or for route protection:
```ruby
# config/routes.rb
namespace :api do
resources :books, only: [:index] # protected by rodauth
end
```
---
## 7. Architectural Comparison
### Your Custom Implementation
**Pros:**
- Simple, easy to understand
- Minimal dependencies (just JWT, OpenSSL)
- Lightweight database (small tables)
- Direct Rails integration
- Minimal features = less surface area
**Cons:**
- Only supports Authorization Code + PKCE
- No refresh tokens
- No token revocation/introspection
- No client credentials grant
- No JWT access tokens
- Manual consent management
- Not standards-compliant (missing many OIDC features)
- Will need continuous custom development
**Architecture:**
```
Rails Controller
OidcController (450 lines)
OidcAuthorizationCode Model
OidcAccessToken Model
OidcUserConsent Model
Database
```
### Rodauth-OAuth Implementation
**Pros:**
- 34 built-in features
- OpenID-Certified
- Production-tested
- Highly configurable
- Comprehensive token management
- Standards-compliant (RFCs & OpenID specs)
- Strong test coverage (hundreds of tests)
- Active maintenance
**Cons:**
- More complex (needs Roda/Rodauth knowledge)
- Larger codebase to learn
- Rails integration via wrapper (extra layer)
- Different paradigm (Roda vs Rails)
- More database columns to manage
**Architecture:**
```
Roda App
Rodauth Plugin (configurable)
├── oauth_base (foundation)
├── oauth_authorization_code_grant
├── oauth_pkce
├── oauth_jwt
├── oidc (all OpenID features)
├── [other optional features]
Sequel ORM
Database (flexible schema)
```
---
## 8. Feature Comparison Matrix
| Feature | Your Impl | Rodauth-OAuth | Notes |
|---------|-----------|---------------|-------|
| **Authorization Code** | ✓ | ✓ | Both support |
| **PKCE** | ✓ | ✓ | Both support |
| **Refresh Tokens** | ✗ | ✓ | You'd need to add |
| **Implicit Flow** | ✗ | ✓ Optional | Legacy, not recommended |
| **Client Credentials** | ✗ | ✓ Optional | Machine-to-machine |
| **Device Code** | ✗ | ✓ Optional | IoT devices |
| **JWT Bearer Grant** | ✗ | ✓ Optional | Service accounts |
| **SAML Bearer Grant** | ✗ | ✓ Optional | Enterprise SAML |
| **JWT Access Tokens** | ✗ | ✓ Optional | Stateless tokens |
| **Token Revocation** | ✗ | ✓ | RFC 7009 |
| **Token Introspection** | ✗ | ✓ | RFC 7662 |
| **Pushed Auth Requests** | ✗ | ✓ Optional | RFC 9126 |
| **DPoP** | ✗ | ✓ Optional | RFC 9449 |
| **TLS Client Auth** | ✗ | ✓ Optional | RFC 8705 |
| **OpenID Connect** | ✓ Basic | ✓ Full | Yours is minimal |
| **ID Tokens** | ✓ | ✓ | Both support |
| **UserInfo Endpoint** | ✓ | ✓ | Both support |
| **Discovery** | ✓ | ✓ | Both support |
| **Session Management** | ✗ | ✓ Optional | Check session iframe |
| **RP-Init Logout** | ✓ | ✓ | Both support |
| **Front-Channel Logout** | ✗ | ✓ | Iframe-based |
| **Back-Channel Logout** | ✗ | ✓ | Server-to-server |
| **Dynamic Client Reg** | ✗ | ✓ Optional | RFC 7591/7592 |
| **Token Hashing** | ✗ | ✓ | Security best practice |
| **Scopes** | ✓ | ✓ | Both support |
| **Custom Claims** | ✓ Manual | ✓ Built-in | Yours via JWT service |
| **Consent UI** | ✓ | ✓ | Both support |
| **Client App Dashboard** | ✗ | ✓ Optional | Built-in |
| **Grant Management Dashboard** | ✗ | ✓ Optional | Built-in |
---
## 9. Integration Complexity Analysis
### Switching to Rodauth-OAuth
#### Medium Complexity (Not Trivial, but Doable)
**What you'd need to do:**
1. **Learn Roda + Rodauth**
- Move from pure Rails to Roda-based architecture
- Understand rodauth feature system
- Time: 1-2 weeks for Rails developers
2. **Migrate Database Schema**
- Consolidate tables: authorization codes + access tokens → oauth_grants
- Rename columns to match rodauth conventions
- Add many new columns for feature support
- Migration script needed: ~100-300 lines
- Time: 1 week development + testing
3. **Replace Your OIDC Code**
- Replace your 450-line OidcController
- Remove your 3 model files
- Keep your OidcJwtService (mostly compatible)
- Add rodauth configuration
- Time: 1-2 weeks
4. **Update Application/Client Model**
- Expand `Application` model properties
- Support all OAuth scopes, grant types, response types
- Time: 3-5 days
5. **Create Migrations from Template**
- Use rodauth-oauth migration templates
- Customize for your database
- Time: 2-3 days
6. **Testing**
- Write integration tests
- Verify all OAuth flows still work
- Check token validation logic
- Time: 2-3 weeks
**Total Effort:** 4-8 weeks for experienced team
### Keeping Your Implementation (Custom Path)
#### What You'd Need to Add
To reach feature parity with rodauth-oauth (for common use cases):
1. **Refresh Token Support** (1-2 weeks)
- Database schema
- Token refresh endpoint
- Token validation logic
2. **Token Revocation** (1 week)
- Revocation endpoint
- Token blacklist/invalidation
3. **Token Introspection** (1 week)
- Introspection endpoint
- Token validation without DB lookup
4. **Client Credentials Grant** (2 weeks)
- Endpoint logic
- Client authentication
- Token generation for apps
5. **Improved Security** (ongoing)
- Token hashing (bcrypt)
- Rate limiting
- Additional validation
6. **Advanced OIDC Features**
- Session Management
- Logout endpoints (front/back-channel)
- Dynamic client registration
- Device code flow
**Total Effort:** 2-3 months ongoing
---
## 10. Key Findings & Recommendations
### What Rodauth-OAuth Does Better
1. **Standards Compliance**
- Certified for 11 OpenID Connect profiles
- Implements 20+ RFCs and specs
- Regular spec updates
2. **Security**
- Token hashing by default
- DPoP support (token binding)
- TLS client auth
- Proper scope enforcement
3. **Features**
- 34 optional features (you get what you need)
- No bloat - only enable what you use
- Mature refresh token handling
4. **Production Readiness**
- Thousands of test cases
- Open source (auditable)
- Active maintenance
- Real-world deployments
5. **Flexibility**
- Works with any SQL database
- Highly configurable column names
- Custom behavior via overrides
- Multiple app types support
### What Your Implementation Does Better
1. **Simplicity**
- Fewer dependencies
- Smaller codebase
- Easier to reason about
2. **Rails Integration**
- Direct Rails ActiveRecord
- No Roda learning curve
- Familiar patterns
3. **Control**
- Full control of every line
- No surprises
- Easy to debug
### Recommendation
**Use Rodauth-OAuth IF:**
- You need a production OIDC/OAuth provider
- You want standards compliance
- You plan to support multiple grant types
- You need token revocation/introspection
- You want a maintained codebase
**Keep Your Custom Implementation IF:**
- Authorization Code + PKCE only is sufficient
- You're avoiding Roda/Rodauth learning curve
- Your org standardizes on Rails patterns
- You have time to add features incrementally
- You need maximum control and simplicity
**Hybrid Approach:**
- Use rodauth-oauth for OIDC/OAuth server components
- Keep your Rails app for other features
- They can coexist (separate services)
---
## 11. Migration Path (If You Decide to Switch)
### Phase 1: Preparation (Week 1-2)
- Set up separate Roda app with rodauth-oauth
- Run alongside your existing service
- Parallel user testing
### Phase 2: Data Migration (Week 2-3)
- Create migration script for oauth_grants table
- Backfill existing auth codes and tokens
- Verify data integrity
### Phase 3: Gradual Cutover (Week 4-6)
- Direct some OAuth clients to new server
- Monitor for issues
- Swap over when confident
### Phase 4: Cleanup (Week 6+)
- Remove custom OIDC code
- Decommission old tables
- Document new architecture
---
## 12. Code Examples
### Rodauth-OAuth: Minimal Setup
```ruby
# Gemfile
gem 'roda'
gem 'rodauth-oauth'
gem 'sequel'
# lib/auth_server.rb
class AuthServer < Roda
plugin :render, views: 'views'
plugin :sessions, secret: 'SECRET'
plugin :rodauth do
db DB
enable :login, :logout, :create_account, :oidc, :oauth_pkce,
:oauth_authorization_code_grant, :oauth_token_introspection
oauth_application_scopes %w[openid email profile]
oauth_require_pkce true
hmac_secret 'HMAC_SECRET'
oauth_jwt_keys('RS256' => [private_key])
end
route do |r|
r.rodauth # All OAuth routes automatically mounted
# Your custom routes
r.get 'api' do
rodauth.require_oauth_authorization('api.read')
# return data
end
end
end
```
### Your Current Approach: Manual
```ruby
# app/controllers/oidc_controller.rb
def authorize
validate_params
find_application
check_authentication
handle_consent
generate_code
redirect_with_code
end
def token
extract_client_credentials
find_application
validate_code
check_pkce
generate_tokens
return_json
end
```
---
## Summary Table
| Aspect | Your Implementation | Rodauth-OAuth |
|--------|-------------------|----------------|
| **Framework** | Rails | Roda |
| **Database ORM** | ActiveRecord | Sequel |
| **Grant Types** | 1 (Auth Code) | 7+ options |
| **Token Types** | Opaque | Opaque or JWT |
| **Security Features** | Basic | Advanced (DPoP, MTLS, etc.) |
| **OIDC Compliance** | Partial | Full (Certified) |
| **Lines of Code** | ~1000 | ~10,000+ |
| **Features** | 2-3 | 34 optional |
| **Maintenance Burden** | High | Low (OSS) |
| **Learning Curve** | Low | Medium (Roda) |
| **Production Ready** | Yes | Yes |
| **Community** | Just you | Active |

View File

@@ -0,0 +1,418 @@
# Rodauth-OAuth: Quick Reference Guide
## What Is It?
A production-ready Ruby gem implementing OAuth 2.0 and OpenID Connect. Think of it as a complete, standards-certified OAuth/OIDC server library for Ruby apps.
## Key Stats
- **Framework**: Roda (not Rails, but works with Rails via wrapper)
- **Features**: 34 modular features you can enable/disable
- **Certification**: Officially certified for 11 OpenID Connect profiles
- **Test Coverage**: Hundreds of tests
- **Status**: Production-ready, actively maintained
## Why Consider It?
### Advantages Over Your Implementation
1. **Complete OAuth/OIDC Implementation**
- All major grant types supported
- Certified compliance with standards
- 20+ RFC implementations
2. **Security Features**
- Token hashing (bcrypt) by default
- DPoP support (token binding)
- TLS mutual authentication
- Proper scope enforcement
3. **Advanced Token Management**
- Refresh tokens (you don't have)
- Token revocation
- Token introspection
- Token rotation policies
4. **Low Maintenance**
- Well-tested codebase
- Active community
- Regular spec updates
- Battle-tested in production
5. **Extensible**
- Highly configurable
- Override any behavior you need
- Database-agnostic
- Works with any SQL DB
### What Your Implementation Does Better
1. **Simplicity** - Fewer lines of code, easier to understand
2. **Rails Native** - No need to learn Roda
3. **Control** - Full ownership of the codebase
4. **Minimal Dependencies** - Just JWT and OpenSSL
## Architecture Overview
### Your Current Setup
```
Rails App
└─ OidcController (450 lines)
├─ /oauth/authorize
├─ /oauth/token
├─ /oauth/userinfo
└─ /logout
Models:
├─ OidcAuthorizationCode
├─ OidcAccessToken
└─ OidcUserConsent
Features Supported:
├─ Authorization Code Flow ✓
├─ PKCE ✓
└─ Basic OIDC ✓
NOT Supported:
├─ Refresh Tokens
├─ Token Revocation
├─ Token Introspection
├─ Client Credentials Grant
├─ Device Code Flow
├─ Session Management
├─ Front/Back-Channel Logout
└─ Dynamic Client Registration
```
### Rodauth-OAuth Setup
```
Roda App (web framework)
└─ Rodauth Plugin (authentication/authorization)
├─ oauth_base (foundation)
├─ oauth_authorization_code_grant
├─ oauth_pkce
├─ oauth_jwt (optional)
├─ oidc (OpenID core)
├─ oidc_session_management (optional)
├─ oidc_rp_initiated_logout (optional)
├─ oidc_frontchannel_logout (optional)
├─ oidc_backchannel_logout (optional)
├─ oauth_token_revocation (optional)
├─ oauth_token_introspection (optional)
├─ oauth_client_credentials_grant (optional)
└─ ... (28+ more optional features)
Routes Generated Automatically:
├─ /.well-known/openid-configuration ✓
├─ /.well-known/jwks.json ✓
├─ /oauth/authorize ✓
├─ /oauth/token ✓
├─ /oauth/userinfo ✓
├─ /oauth/introspect (optional)
├─ /oauth/revoke (optional)
└─ /logout ✓
```
## Database Schema Comparison
### Your Current Tables
```
oidc_authorization_codes
├─ id
├─ user_id
├─ application_id
├─ code (unique)
├─ redirect_uri
├─ scope
├─ nonce
├─ code_challenge
├─ code_challenge_method
├─ used (boolean)
├─ expires_at
└─ created_at
oidc_access_tokens
├─ id
├─ user_id
├─ application_id
├─ token (unique)
├─ scope
├─ expires_at
└─ created_at
oidc_user_consents
├─ user_id
├─ application_id
├─ scopes_granted
└─ granted_at
applications
├─ id
├─ name
├─ client_id (unique)
├─ client_secret
├─ redirect_uris (JSON)
├─ app_type
└─ ... (few more fields)
```
### Rodauth-OAuth Tables
```
accounts (from rodauth)
├─ id
├─ status_id
├─ email
└─ password_hash
oauth_applications (75+ columns!)
├─ Basic: id, account_id, name, description
├─ OAuth: client_id, client_secret, redirect_uri, scopes
├─ Config: token_endpoint_auth_method, grant_types, response_types
├─ JWT/JWKS: jwks_uri, jwks, jwt_public_key
├─ OIDC: subject_type, id_token_signed_response_alg, etc.
├─ PAR: require_pushed_authorization_requests
├─ DPoP: dpop_bound_access_tokens
├─ TLS: tls_client_auth_* fields
└─ Logout: post_logout_redirect_uris, frontchannel_logout_uri, etc.
oauth_grants (consolidated - replaces your two tables!)
├─ id, account_id, oauth_application_id
├─ type (authorization_code, refresh_token, etc.)
├─ code, token, refresh_token (with hashed versions)
├─ expires_in, revoked_at
├─ scopes, access_type
├─ code_challenge, code_challenge_method (PKCE)
├─ user_code, last_polled_at (Device code grant)
├─ nonce, acr, claims (OIDC)
├─ dpop_jkt (DPoP)
└─ certificate_thumbprint, resource (advanced)
[Optional tables for features you enable]
```
## Feature Comparison Matrix
| Feature | Your Code | Rodauth-OAuth | Effort to Add* |
|---------|-----------|---------------|--------|
| Authorization Code Flow | ✓ | ✓ | N/A |
| PKCE | ✓ | ✓ | N/A |
| Refresh Tokens | ✗ | ✓ | 1-2 weeks |
| Token Revocation | ✗ | ✓ | 1 week |
| Token Introspection | ✗ | ✓ | 1 week |
| Client Credentials Grant | ✗ | ✓ | 2 weeks |
| Device Code Flow | ✗ | ✓ | 3 weeks |
| JWT Access Tokens | ✗ | ✓ | 1 week |
| Session Management | ✗ | ✓ | 2-3 weeks |
| Front-Channel Logout | ✗ | ✓ | 1-2 weeks |
| Back-Channel Logout | ✗ | ✓ | 2 weeks |
| Dynamic Client Reg | ✗ | ✓ | 3-4 weeks |
| Token Hashing | ✗ | ✓ | 1 week |
*Time estimates for adding to your implementation
## Code Examples
### Rodauth-OAuth: Minimal OAuth Server
```ruby
# Gemfile
gem 'roda'
gem 'rodauth-oauth'
gem 'sequel'
# lib/auth_server.rb
class AuthServer < Roda
plugin :sessions, secret: ENV['SESSION_SECRET']
plugin :rodauth do
db DB
enable :login, :logout, :create_account,
:oidc, :oauth_pkce, :oauth_authorization_code_grant,
:oauth_token_revocation
oauth_application_scopes %w[openid email profile]
oauth_require_pkce true
end
route do |r|
r.rodauth # All OAuth endpoints auto-mounted!
# Your app logic here
end
end
```
That's it! All these endpoints are automatically available:
- GET /.well-known/openid-configuration
- GET /.well-known/jwks.json
- GET /oauth/authorize
- POST /oauth/token
- POST /oauth/revoke
- GET /oauth/userinfo
- GET /logout
### Your Current Approach
```ruby
# app/controllers/oidc_controller.rb
class OidcController < ApplicationController
def authorize
# 150 lines of validation logic
end
def token
# 100 lines of token generation logic
end
def userinfo
# 50 lines of claims logic
end
def logout
# 50 lines of logout logic
end
private
def validate_pkce(auth_code, code_verifier)
# 50 lines of PKCE validation
end
end
```
## Integration Paths
### Option 1: Stick with Your Implementation
- Keep building features incrementally
- Effort: 2-3 months to reach feature parity
- Pro: Rails native, full control
- Con: Continuous maintenance burden
### Option 2: Switch to Rodauth-OAuth
- Learn Roda/Rodauth (1-2 weeks)
- Migrate database (1 week)
- Replace 450 lines of code with config (1 week)
- Testing & validation (2-3 weeks)
- Effort: 4-8 weeks total
- Pro: Production-ready, certified, maintained
- Con: Different framework (Roda)
### Option 3: Hybrid Approach
- Keep your Rails app for business logic
- Use rodauth-oauth as separate OAuth/OIDC service
- Services communicate via HTTP/APIs
- Effort: 2-3 weeks (independent services)
- Pro: Best of both worlds
- Con: Operational complexity
## Decision Matrix
### Use Rodauth-OAuth If You Need...
- [x] Standards compliance (OpenID certified)
- [x] Multiple grant types (Client Credentials, Device Code, etc.)
- [x] Token revocation/introspection
- [x] Refresh tokens
- [x] Advanced logout (front/back-channel)
- [x] Session management
- [x] Token hashing/security best practices
- [x] Hands-off maintenance
- [x] Production-battle-tested code
### Keep Your Implementation If You...
- [x] Only need Authorization Code + PKCE
- [x] Want zero Roda/external framework learning
- [x] Value Rails patterns over standards
- [x] Like to understand every line of code
- [x] Can allocate time for ongoing maintenance
- [x] Prefer minimal dependencies
## Key Differences You'll Notice
### 1. Framework Paradigm
- **Your impl**: Rails (MVC, familiar)
- **Rodauth**: Roda (routing-focused, lightweight)
### 2. Database ORM
- **Your impl**: ActiveRecord (Rails native)
- **Rodauth**: Sequel (lighter, more control)
### 3. Configuration Style
- **Your impl**: Rails initializers, environment variables
- **Rodauth**: Plugin block with DSL
### 4. Model Management
- **Your impl**: Rails models with validations, associations
- **Rodauth**: Minimal models, logic in database
### 5. Testing Approach
- **Your impl**: RSpec, model/controller tests
- **Rodauth**: Request-based integration tests
## File Locations (If You Switch)
```
Current Structure
├── app/controllers/oidc_controller.rb
├── app/models/
│ ├── oidc_authorization_code.rb
│ ├── oidc_access_token.rb
│ └── oidc_user_consent.rb
├── app/services/oidc_jwt_service.rb
├── db/migrate/*oidc*.rb
Rodauth-OAuth Equivalent
├── lib/rodauth_app.rb # Configuration (replaces most controllers)
├── app/views/rodauth/ # Templates (consent form, etc.)
├── config/routes.rb # Simple: routes mount rodauth
└── db/migrate/*rodauth_oauth*.rb
```
## Performance Considerations
### Your Implementation
- Small tables → fast queries
- Fewer columns → less overhead
- Simple token validation
- Estimated: 5-10ms per token validation
### Rodauth-OAuth
- More columns, but same queries
- Optional token hashing (slight overhead)
- More features = more options checked
- Estimated: 10-20ms per token validation
- Can be optimized: disable unused features
## Getting Started (If You Want to Explore)
1. **Review the code**
```bash
cd /Users/dkam/Development/clinch/tmp/rodauth-oauth
ls -la lib/rodauth/features/ # See all features
cat examples/oidc/authentication_server.rb # Full working example
```
2. **Run the example**
```bash
cd /Users/dkam/Development/clinch/tmp/rodauth-oauth/examples
ruby oidc/authentication_server.rb # Starts server on http://localhost:9292
```
3. **Read the key files**
- README.md: Overview
- MIGRATION-GUIDE-v1.md: Version migration (shows architecture)
- test/migrate/*.rb: Database schema
- examples/oidc/*.rb: Complete working implementation
## Next Steps
1. **If keeping your implementation:**
- Prioritize refresh token support
- Add token revocation endpoint
- Consider token hashing
2. **If exploring rodauth-oauth:**
- Run the example server
- Review the feature files
- Check if hybrid approach works for your org
3. **For either path:**
- Document your decision
- Plan feature roadmap
- Set up appropriate monitoring
---
**Bottom Line**: Rodauth-OAuth is the "production-grade" option if you need comprehensive OAuth/OIDC. Your implementation is fine if you keep features minimal and have maintenance bandwidth.

330
docs/traefik-example.md Normal file
View File

@@ -0,0 +1,330 @@
# Traefik ForwardAuth Configuration Examples
## Basic Configuration (Protecting MEtube)
### docker-compose.yml with Traefik Labels
```yaml
version: '3'
services:
# Clinch SSO
clinch:
image: your-clinch-image
labels:
- "traefik.enable=true"
- "traefik.http.routers.clinch.rule=Host(`clinch.yourdomain.com`)"
- "traefik.http.routers.clinch.entrypoints=websecure"
- "traefik.http.routers.clinch.tls.certresolver=letsencrypt"
- "traefik.http.services.clinch.loadbalancer.server.port=3000"
# MEtube - Protected by Clinch
metube:
image: ghcr.io/alexta69/metube
labels:
- "traefik.enable=true"
- "traefik.http.routers.metube.rule=Host(`metube.yourdomain.com`)"
- "traefik.http.routers.metube.entrypoints=websecure"
- "traefik.http.routers.metube.tls.certresolver=letsencrypt"
# ForwardAuth middleware
- "traefik.http.routers.metube.middlewares=metube-auth"
- "traefik.http.middlewares.metube-auth.forwardauth.address=http://clinch:3000/api/verify?app=metube"
- "traefik.http.middlewares.metube-auth.forwardauth.authResponseHeaders=Remote-User,Remote-Email,Remote-Groups,Remote-Admin"
- "traefik.http.services.metube.loadbalancer.server.port=8081"
```
## Traefik Static Configuration (File)
### traefik.yml
```yaml
entryPoints:
web:
address: ":80"
http:
redirections:
entryPoint:
to: websecure
scheme: https
websecure:
address: ":443"
certificatesResolvers:
letsencrypt:
acme:
email: your-email@example.com
storage: /letsencrypt/acme.json
tlsChallenge: {}
providers:
docker:
exposedByDefault: false
file:
filename: /config/dynamic.yml
watch: true
```
## Traefik Dynamic Configuration (File)
### dynamic.yml
```yaml
http:
middlewares:
# Clinch ForwardAuth middleware for MEtube
metube-auth:
forwardAuth:
address: "http://clinch:3000/api/verify?app=metube"
authResponseHeaders:
- "Remote-User"
- "Remote-Email"
- "Remote-Groups"
- "Remote-Admin"
# Clinch ForwardAuth for Sonarr (with group restriction)
sonarr-auth:
forwardAuth:
address: "http://clinch:3000/api/verify?app=sonarr"
authResponseHeaders:
- "Remote-User"
- "Remote-Email"
- "Remote-Groups"
- "Remote-Admin"
routers:
clinch:
rule: "Host(`clinch.yourdomain.com`)"
service: clinch
entryPoints:
- websecure
tls:
certResolver: letsencrypt
metube:
rule: "Host(`metube.yourdomain.com`)"
service: metube
middlewares:
- metube-auth
entryPoints:
- websecure
tls:
certResolver: letsencrypt
sonarr:
rule: "Host(`sonarr.yourdomain.com`)"
service: sonarr
middlewares:
- sonarr-auth
entryPoints:
- websecure
tls:
certResolver: letsencrypt
services:
clinch:
loadBalancer:
servers:
- url: "http://clinch:3000"
metube:
loadBalancer:
servers:
- url: "http://metube:8081"
sonarr:
loadBalancer:
servers:
- url: "http://sonarr:8989"
```
## How It Works
1. User visits `https://metube.yourdomain.com`
2. Traefik intercepts and applies the `metube-auth` middleware
3. Traefik makes request to `http://clinch:3000/api/verify?app=metube`
4. Clinch checks if user is authenticated and authorized:
- If **200**: Traefik forwards request to MEtube with user headers
- If **401/403**: Traefik redirects to Clinch login page
5. User signs into Clinch (with TOTP if enabled)
6. Clinch redirects back to MEtube
7. User can now access MEtube!
## Setup Steps
### 1. Create Applications in Clinch
Via Rails console:
```ruby
# MEtube - No groups = everyone can access
Application.create!(
name: "MEtube",
slug: "metube",
app_type: "trusted_header",
active: true
)
# Sonarr - Restricted to media-managers group
media_group = Group.find_by(name: "media-managers")
sonarr = Application.create!(
name: "Sonarr",
slug: "sonarr",
app_type: "trusted_header",
active: true
)
ApplicationGroup.create!(application: sonarr, group: media_group)
```
### 2. Update Traefik Configuration
Add the ForwardAuth middlewares and labels shown above.
### 3. Restart Traefik
```bash
docker-compose restart traefik
```
### 4. Test
Visit https://metube.yourdomain.com - you should be redirected to Clinch login!
## Advanced: Custom Error Pages
```yaml
http:
middlewares:
clinch-errors:
errors:
status:
- "401-403"
service: clinch
query: "/signin?redirect={url}"
metube-auth:
forwardAuth:
address: "http://clinch:3000/api/verify?app=metube"
authResponseHeaders:
- "Remote-User"
- "Remote-Email"
- "Remote-Groups"
- "Remote-Admin"
routers:
metube:
rule: "Host(`metube.yourdomain.com`)"
service: metube
middlewares:
- metube-auth
- clinch-errors # Add custom error handling
entryPoints:
- websecure
tls:
certResolver: letsencrypt
```
## Kubernetes Ingress Example
```yaml
apiVersion: traefik.containo.us/v1alpha1
kind: Middleware
metadata:
name: clinch-metube-auth
spec:
forwardAuth:
address: http://clinch.clinch-system.svc.cluster.local:3000/api/verify?app=metube
authResponseHeaders:
- Remote-User
- Remote-Email
- Remote-Groups
- Remote-Admin
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: metube
annotations:
traefik.ingress.kubernetes.io/router.middlewares: default-clinch-metube-auth@kubernetescrd
spec:
rules:
- host: metube.yourdomain.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: metube
port:
number: 8081
```
## Troubleshooting
### Users not staying logged in
Ensure Traefik preserves cookies and sets correct headers:
```yaml
http:
routers:
clinch:
middlewares:
- clinch-headers
middlewares:
clinch-headers:
headers:
customRequestHeaders:
X-Forwarded-Host: "clinch.yourdomain.com"
X-Forwarded-Proto: "https"
```
### Authentication loop
1. Check that `/api/verify` is accessible from Traefik
2. Verify the ForwardAuth middleware address is correct
3. Check Clinch logs for errors
### Check Clinch logs
```bash
docker-compose logs -f clinch
```
You'll see ForwardAuth log messages like:
```
ForwardAuth: User user@example.com granted access to metube
ForwardAuth: Unauthorized - No session cookie
```
### Debug Traefik
Enable access logs in `traefik.yml`:
```yaml
accessLog:
filePath: "/var/log/traefik/access.log"
format: json
```
## Comparison: Traefik vs. Caddy
### Traefik
- ✅ Better for Docker/Kubernetes environments
- ✅ Automatic service discovery
- ✅ Rich middleware system
- ❌ More complex configuration
### Caddy
- ✅ Simpler configuration
- ✅ Automatic HTTPS by default
- ✅ Better for static configurations
- ❌ Less dynamic than Traefik
Both work great with Clinch ForwardAuth!

View File

@@ -0,0 +1,238 @@
# WebAuthn/Passkeys Implementation - Quick Start
This is a companion summary to the [full implementation plan](webauthn-passkeys-plan.md).
## What We're Building
Add modern passwordless authentication (passkeys) to Clinch, allowing users to sign in with Face ID, Touch ID, Windows Hello, or hardware security keys (YubiKey).
## Quick Overview
### Features
- **Passwordless login** - Sign in with biometrics, no password needed
- **Multi-device support** - Register passkeys on multiple devices
- **Synced passkeys** - Works with iCloud Keychain, Google Password Manager
- **2FA option** - Use passkeys as second factor instead of TOTP
- **Hardware keys** - Support for YubiKey and other FIDO2 devices
- **User management** - Register, name, and delete multiple passkeys
### Tech Stack
- `webauthn` gem (~3.0) - Server-side WebAuthn implementation
- Browser WebAuthn API - Native browser support (no JS libraries needed)
- Stimulus controller - Frontend UX management
## 5-Phase Implementation
### Phase 1: Foundation (Week 1-2)
Core WebAuthn registration and authentication
- Database schema for credentials
- Registration ceremony (add passkey)
- Authentication ceremony (sign in with passkey)
- Basic JavaScript integration
### Phase 2: User Experience (Week 2-3)
Polished UI and management
- Profile page: list/manage passkeys
- Login page: "Sign in with Passkey" button
- Nickname management
- First-run wizard update
### Phase 3: Security (Week 3-4)
Advanced security features
- Sign count verification (clone detection)
- Attestation validation (optional)
- User verification requirements
- Admin controls and policies
### Phase 4: Integration (Week 4)
Connect with existing features
- OIDC integration (AMR claims)
- WebAuthn as 2FA option
- ForwardAuth compatibility
- Account recovery flows
### Phase 5: Testing & Docs (Week 4-5)
Quality assurance
- Unit, integration, and system tests
- Virtual authenticator testing
- User and admin documentation
- Security audit
## Database Schema
### New Table: `webauthn_credentials`
```ruby
create_table :webauthn_credentials do |t|
t.references :user, null: false, foreign_key: true
t.string :external_id, null: false # Credential ID
t.string :public_key, null: false # Public key
t.integer :sign_count, default: 0 # For clone detection
t.string :nickname # "MacBook Touch ID"
t.string :authenticator_type # platform/cross-platform
t.datetime :last_used_at
t.timestamps
end
```
### Update `users` table
```ruby
add_column :users, :webauthn_id, :string # User handle
add_column :users, :webauthn_required, :boolean # Policy enforcement
```
## Key User Flows
### 1. Register Passkey
```
User profile → "Add Passkey" → Browser prompt →
Touch ID/Face ID → Passkey saved → Can sign in
```
### 2. Sign In with Passkey
```
Login page → Enter email → "Continue with Passkey" →
Browser prompt → Touch ID/Face ID → Signed in
```
### 3. WebAuthn as 2FA
```
Enter password → Prompted for passkey →
Touch ID/Face ID → Signed in
```
## Security Highlights
1. **Phishing-resistant** - Passkeys are bound to the domain
2. **No shared secrets** - Public key cryptography
3. **Clone detection** - Sign count verification
4. **User verification** - Biometric or PIN required
5. **Privacy-preserving** - Opaque user handles
## Integration Points
### OIDC
- Add `amr` claim: `["webauthn"]`
- Support `acr_values=webauthn` in authorization request
- Include authentication method in ID token
### ForwardAuth
- WebAuthn creates standard sessions
- Works automatically with existing `/api/verify` endpoint
- Optional header: `Remote-Auth-Method: webauthn`
### Admin Controls
- Require WebAuthn for specific users/groups
- View all registered passkeys
- Revoke compromised credentials
- Audit log of authentications
## Files to Create/Modify
### New Files (~12)
- `app/models/webauthn_credential.rb`
- `app/controllers/webauthn_controller.rb`
- `app/javascript/controllers/webauthn_controller.js`
- `config/initializers/webauthn.rb`
- Views for registration/management
- Tests (model, controller, integration, system)
- Documentation (user guide, admin guide)
### Modified Files (~8)
- `Gemfile` - Add webauthn gem
- `app/models/user.rb` - Add associations/methods
- `app/controllers/sessions_controller.rb` - WebAuthn authentication
- `app/views/sessions/new.html.erb` - Add passkey button
- `app/views/profiles/show.html.erb` - Passkey management
- `config/routes.rb` - WebAuthn routes
- `README.md` - Document feature
- `app/controllers/oidc_controller.rb` - AMR claims
## Browser Support
### Supported (WebAuthn Level 2)
- Chrome/Edge 90+
- Firefox 90+
- Safari 14+ (macOS Big Sur / iOS 14+)
### Platform Authenticators
- macOS: Touch ID
- iOS/iPadOS: Face ID, Touch ID
- Windows: Windows Hello (face, fingerprint, PIN)
- Android: Fingerprint, face unlock
### Roaming Authenticators
- YubiKey 5 series
- SoloKeys
- Google Titan Security Key
- Any FIDO2-certified hardware key
## Open Questions
1. **Attestation**: Validate authenticator hardware? (Recommend: No for v1)
2. **Resident Keys**: Require discoverable credentials? (Recommend: Preferred, not required)
3. **Synced Passkeys**: Allow iCloud/Google sync? (Recommend: Yes)
4. **Recovery**: How to recover if all passkeys lost? (Recommend: Email verification)
5. **2FA**: Replace TOTP or offer both? (Recommend: Offer both)
6. **Enforcement**: When to require passkeys? (Recommend: 3 months after launch for admins)
## Success Metrics
### Adoption
- % of users with ≥1 passkey
- % of logins using passkey vs password
- Average registration time
### Security
- Reduced password reset requests
- Reduced account takeover attempts
- Zero phishing success (passkeys can't be phished)
### Performance
- Faster authentication time
- Low error rate (<5%)
- High browser compatibility (>95%)
## Timeline
- **Week 1-2**: Foundation (Phase 1)
- **Week 2-3**: UX & Testing (Phase 2 + Phase 5 start)
- **Week 3-4**: Security & Integration (Phase 3 + Phase 4)
- **Week 4-5**: Beta testing and documentation
- **Week 5+**: Production rollout
**Total**: 4-6 weeks for full implementation and testing
## Next Steps
1. ✅ Review this plan
2. ⬜ Create Gitea issues for each phase
3. ⬜ Add `webauthn` gem to Gemfile
4. ⬜ Create database migrations
5. ⬜ Implement Phase 1 (registration ceremony)
6. ⬜ Implement Phase 1 (authentication ceremony)
7. ⬜ Add JavaScript frontend
8. ⬜ Test with virtual authenticators
9. ⬜ Continue through remaining phases
## Resources
- [Full Implementation Plan](webauthn-passkeys-plan.md) - Detailed 50+ page document
- [W3C WebAuthn Spec](https://www.w3.org/TR/webauthn-2/)
- [webauthn-ruby gem](https://github.com/cedarcode/webauthn-ruby)
- [WebAuthn Guide](https://webauthn.guide/)
- [MDN Web Authentication API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API)
## Questions?
Refer to the [full implementation plan](webauthn-passkeys-plan.md) for:
- Detailed technical specifications
- Security considerations
- Code examples
- Testing strategies
- Migration strategies
- Complete API reference
---
*Status: Ready for Review*
*See: [webauthn-passkeys-plan.md](webauthn-passkeys-plan.md) for full details*

View File

@@ -0,0 +1,787 @@
# WebAuthn / Passkeys Implementation Plan for Clinch
## Executive Summary
This document outlines a comprehensive plan to add WebAuthn/Passkeys support to Clinch, enabling modern passwordless authentication alongside the existing password + TOTP authentication methods.
## Goals
1. **Primary Authentication**: Allow users to register and use passkeys as their primary login method (passwordless)
2. **MFA Enhancement**: Support passkeys as a second factor alongside TOTP
3. **Cross-Device Support**: Enable both platform authenticators (Face ID, Touch ID, Windows Hello) and roaming authenticators (YubiKey, security keys)
4. **User Experience**: Provide seamless registration, authentication, and management of multiple passkeys
5. **Backward Compatibility**: Maintain existing password + TOTP flows without disruption
## Architecture Overview
### Technology Stack
- **webauthn gem** (~3.0): Ruby library for WebAuthn server implementation
- **Rails 8.1**: Existing framework
- **Browser WebAuthn API**: Native browser support (all modern browsers)
### Core Components
1. **WebAuthn Credentials Model**: Store registered authenticators
2. **WebAuthn Controller**: Handle registration and authentication ceremonies
3. **Session Flow Updates**: Integrate passkey authentication into existing login flow
4. **User Management UI**: Allow users to register, name, and delete passkeys
5. **Admin Controls**: Configure WebAuthn policies per user/group
---
## Database Schema
### New Table: `webauthn_credentials`
```ruby
create_table :webauthn_credentials do |t|
t.references :user, null: false, foreign_key: true, index: true
# WebAuthn specification fields
t.string :external_id, null: false, index: { unique: true } # credential ID (base64)
t.string :public_key, null: false # public key (base64)
t.integer :sign_count, null: false, default: 0 # signature counter (clone detection)
# Metadata
t.string :nickname # User-friendly name ("MacBook Touch ID")
t.string :authenticator_type # "platform" or "cross-platform"
t.boolean :backup_eligible, default: false # Can be backed up (passkey sync)
t.boolean :backup_state, default: false # Currently backed up
# Tracking
t.datetime :last_used_at
t.string :last_used_ip
t.string :user_agent # Browser/OS info
timestamps
end
add_index :webauthn_credentials, [:user_id, :external_id], unique: true
```
### Update `users` table
```ruby
add_column :users, :webauthn_required, :boolean, default: false, null: false
add_column :users, :webauthn_id, :string # WebAuthn user handle (random, stable, opaque)
add_index :users, :webauthn_id, unique: true
```
---
## Implementation Phases
### Phase 1: Foundation (Core WebAuthn Support)
**Objective**: Enable basic passkey registration and authentication
#### 1.1 Setup & Dependencies
- [ ] Add `webauthn` gem to Gemfile (~3.0)
- [ ] Create WebAuthn initializer with configuration
- [ ] Generate migration for `webauthn_credentials` table
- [ ] Add WebAuthn user handle generation to User model
#### 1.2 Models
**File**: `app/models/webauthn_credential.rb`
```ruby
class WebauthnCredential < ApplicationRecord
belongs_to :user
validates :external_id, presence: true, uniqueness: true
validates :public_key, presence: true
validates :sign_count, presence: true, numericality: { greater_than_or_equal_to: 0 }
scope :active, -> { where(revoked_at: nil) }
scope :platform_authenticators, -> { where(authenticator_type: "platform") }
scope :roaming_authenticators, -> { where(authenticator_type: "cross-platform") }
# Update last used timestamp and sign count after successful authentication
def update_usage!(sign_count:, ip_address: nil)
update!(
last_used_at: Time.current,
last_used_ip: ip_address,
sign_count: sign_count
)
end
end
```
**Update**: `app/models/user.rb`
```ruby
has_many :webauthn_credentials, dependent: :destroy
# Generate stable WebAuthn user handle on first use
def webauthn_user_handle
return webauthn_id if webauthn_id.present?
# Generate random 64-byte opaque identifier (base64url encoded)
handle = SecureRandom.urlsafe_base64(64)
update_column(:webauthn_id, handle)
handle
end
def webauthn_enabled?
webauthn_credentials.active.exists?
end
def can_authenticate_with_webauthn?
webauthn_enabled? && active?
end
```
#### 1.3 WebAuthn Configuration
**File**: `config/initializers/webauthn.rb`
```ruby
WebAuthn.configure do |config|
# Relying Party name (displayed in authenticator)
config.origin = ENV.fetch("CLINCH_HOST", "http://localhost:3000")
# Relying Party ID (must match origin domain)
config.rp_name = "Clinch Identity Provider"
# Credential timeout (60 seconds)
config.credential_options_timeout = 60_000
# Supported algorithms (ES256, RS256)
config.algorithms = ["ES256", "RS256"]
end
```
#### 1.4 Registration Flow (Ceremony)
**File**: `app/controllers/webauthn_controller.rb`
Key actions:
- `GET /webauthn/new` - Display registration page
- `POST /webauthn/challenge` - Generate registration challenge
- `POST /webauthn/create` - Verify and store credential
**Registration Process**:
1. User clicks "Add Passkey" in profile settings
2. Server generates challenge options (stored in session)
3. Browser calls `navigator.credentials.create()`
4. User authenticates with device (Touch ID, Face ID, etc.)
5. Browser returns signed credential
6. Server verifies signature and stores credential
#### 1.5 Authentication Flow (Ceremony)
**Update**: `app/controllers/sessions_controller.rb`
New actions:
- `POST /sessions/webauthn/challenge` - Generate authentication challenge
- `POST /sessions/webauthn/verify` - Verify credential and sign in
**Authentication Process**:
1. User clicks "Sign in with Passkey" on login page
2. Server generates challenge (stored in session)
3. Browser calls `navigator.credentials.get()`
4. User authenticates with device
5. Browser returns signed assertion
6. Server verifies signature, checks sign count, creates session
#### 1.6 Frontend JavaScript
**File**: `app/javascript/controllers/webauthn_controller.js` (Stimulus)
Responsibilities:
- Encode/decode base64url data for WebAuthn API
- Handle browser WebAuthn API calls
- Error handling and user feedback
- Progressive enhancement (feature detection)
**Example registration**:
```javascript
async register() {
const options = await this.fetchChallenge()
const credential = await navigator.credentials.create(options)
await this.submitCredential(credential)
}
```
---
### Phase 2: User Experience & Management
**Objective**: Provide intuitive UI for managing passkeys
#### 2.1 Profile Management
**File**: `app/views/profiles/show.html.erb` (update)
Features:
- List all registered passkeys with nicknames
- Show last used timestamp
- Badge for platform vs roaming authenticators
- Add new passkey button
- Delete passkey button (with confirmation)
- Show "synced passkey" badge if backup_state is true
#### 2.2 Registration Improvements
- Auto-detect device type and suggest nickname ("Chrome on MacBook")
- Show preview of what authenticator will display
- Require at least one authentication method (password OR passkey)
- Warning if removing last authentication method
#### 2.3 Login Page Updates
**File**: `app/views/sessions/new.html.erb` (update)
- Add "Sign in with Passkey" button (conditional rendering)
- Show button only if WebAuthn is supported by browser
- Progressive enhancement: fallback to password if WebAuthn fails
- Email field for identifying which user's passkeys to request
**Flow**:
1. User enters email address
2. Server checks if user has passkeys
3. If yes, show "Continue with Passkey" button
4. If no passkeys, show password field
#### 2.4 First-Run Wizard Update
**File**: `app/views/users/new.html.erb` (first-run wizard)
- Option to register passkey immediately after creating account
- Skip passkey registration if not supported or user declines
- Encourage passkey setup but don't require it
---
### Phase 3: Security & Advanced Features
**Objective**: Harden security and add enterprise features
#### 3.1 Sign Count Verification
**Purpose**: Detect cloned authenticators
Implementation:
- Store sign_count after each authentication
- Verify new sign_count > old sign_count
- If count doesn't increase: log warning, optionally disable credential
- Add admin alert for suspicious activity
#### 3.2 Attestation Validation (Optional)
**Purpose**: Verify authenticator is genuine hardware
Options:
- None (most compatible, recommended for self-hosted)
- Indirect (some validation)
- Direct (strict validation, enterprise)
**Configuration** (per-application):
```ruby
class Application < ApplicationRecord
enum webauthn_attestation: {
none: 0,
indirect: 1,
direct: 2
}, _default: :none
end
```
#### 3.3 User Verification Requirements
**Levels**:
- `discouraged`: No user verification (not recommended)
- `preferred`: Request if available (default)
- `required`: Must have PIN/biometric (high security apps)
**Configuration**: Per-application setting
#### 3.4 Resident Keys (Discoverable Credentials)
**Feature**: Passkey contains username, no email entry needed
**Implementation**:
- Set `residentKey: "preferred"` or `"required"` in credential options
- Allow users to sign in without entering email first
- Add `POST /sessions/webauthn/discoverable` endpoint
**Benefits**:
- Faster login (no email typing)
- Better UX on mobile devices
- Works with password managers (1Password, etc.)
#### 3.5 Admin Controls
**File**: `app/views/admin/users/edit.html.erb`
Admin capabilities:
- View all user passkeys
- Revoke compromised passkeys
- Require WebAuthn for specific users/groups
- View WebAuthn authentication audit log
- Configure WebAuthn policies
**New fields**:
```ruby
# On User model
webauthn_required: boolean # Must have at least one passkey
# On Group model
webauthn_enforcement: enum # :none, :encouraged, :required
```
---
### Phase 4: Integration with Existing Flows
**Objective**: Seamlessly integrate with OIDC, ForwardAuth, and 2FA
#### 4.1 OIDC Authorization Flow
**Update**: `app/controllers/oidc_controller.rb`
Integration points:
- If user has no password but has passkey, trigger WebAuthn
- Application can request `webauthn` in `acr_values` parameter
- Include `amr` claim in ID token: `["webauthn"]` or `["pwd", "totp"]`
**Example ID token**:
```json
{
"sub": "user-123",
"email": "user@example.com",
"amr": ["webauthn"], // Authentication Methods References
"acr": "urn:mace:incommon:iap:silver"
}
```
#### 4.2 WebAuthn as Second Factor
**Scenario**: User signs in with password, then WebAuthn as 2FA
**Flow**:
1. User enters password (first factor)
2. If `webauthn_required` is true OR user chooses WebAuthn
3. Trigger WebAuthn challenge (instead of TOTP)
4. User authenticates with passkey
5. Create session
**Configuration**:
```ruby
# User can choose 2FA method
user.preferred_2fa # :totp or :webauthn
# Admin can require specific 2FA method
user.required_2fa # :any, :totp, :webauthn
```
#### 4.3 ForwardAuth Integration
**Update**: `app/controllers/api/forward_auth_controller.rb`
No changes needed! WebAuthn creates standard sessions, ForwardAuth works as-is.
**Header injection**:
```
Remote-User: user@example.com
Remote-Groups: admin,family
Remote-Auth-Method: webauthn # NEW optional header
```
#### 4.4 Backup Codes
**Consideration**: What if user loses all passkeys?
**Options**:
1. Keep existing backup codes system (works for TOTP, not WebAuthn-only)
2. Require email verification for account recovery
3. Require at least one roaming authenticator (YubiKey) + platform authenticator
**Recommended**: Require password OR email-verified recovery flow
---
### Phase 5: Testing & Documentation
**Objective**: Ensure reliability and provide clear documentation
#### 5.1 Automated Tests
**Test Coverage**:
1. **Model tests** (`test/models/webauthn_credential_test.rb`)
- Credential creation and validation
- Sign count updates
- Credential scopes and queries
2. **Controller tests** (`test/controllers/webauthn_controller_test.rb`)
- Registration challenge generation
- Credential verification
- Authentication challenge generation
- Assertion verification
3. **Integration tests** (`test/integration/webauthn_authentication_test.rb`)
- Full registration flow
- Full authentication flow
- Error handling (invalid signatures, expired challenges)
4. **System tests** (`test/system/webauthn_test.rb`)
- End-to-end browser testing with virtual authenticator
- Chrome DevTools Protocol virtual authenticator
**Example virtual authenticator test**:
```ruby
test "user registers passkey" do
driver.add_virtual_authenticator(protocol: :ctap2)
visit profile_path
click_on "Add Passkey"
fill_in "Nickname", with: "Test Key"
click_on "Register"
assert_text "Passkey registered successfully"
end
```
#### 5.2 Documentation
**Files to create/update**:
1. **User Guide** (`docs/webauthn-user-guide.md`)
- What are passkeys?
- How to register a passkey
- How to sign in with a passkey
- Managing multiple passkeys
- Troubleshooting
2. **Admin Guide** (`docs/webauthn-admin-guide.md`)
- WebAuthn policies and configuration
- Enforcing passkeys for users/groups
- Security considerations
- Audit logging
3. **Developer Guide** (`docs/webauthn-developer-guide.md`)
- Architecture overview
- WebAuthn ceremony flows
- Testing with virtual authenticators
- OIDC integration details
4. **README Update** (`README.md`)
- Add WebAuthn/Passkeys to Authentication Methods section
- Update feature list
#### 5.3 Browser Compatibility
**Supported Browsers**:
- Chrome/Edge 90+ (Chromium)
- Firefox 90+
- Safari 14+ (macOS Big Sur, iOS 14)
**Graceful Degradation**:
- Feature detection: check `window.PublicKeyCredential`
- Hide passkey UI if not supported
- Always provide password fallback
---
## Security Considerations
### 1. Challenge Storage
- Store challenges in server-side session (not cookies)
- Challenges expire after 60 seconds
- One-time use (mark as used after verification)
### 2. Origin Validation
- WebAuthn library automatically validates origin
- Ensure `CLINCH_HOST` environment variable is correct
- Must use HTTPS in production (required by WebAuthn spec)
### 3. Relying Party ID
- Must match the origin domain
- Cannot be changed after credentials are registered
- Use apex domain for subdomain compatibility (e.g., `example.com` works for `auth.example.com` and `app.example.com`)
### 4. User Handle Privacy
- User handle is opaque, random, and stable
- Never use email or user ID as user handle
- Store in `users.webauthn_id` column
### 5. Sign Count Verification
- Always check sign_count increases
- Log suspicious activity (counter didn't increase)
- Consider disabling credential if counter resets
### 6. Credential Backup Awareness
- Track `backup_eligible` and `backup_state` flags
- Inform users about synced passkeys
- Higher security apps may want to disallow backed-up credentials
### 7. Account Recovery
- Don't lock users out if they lose all passkeys
- Require email verification for recovery
- Send alerts when recovery is used
---
## Migration Strategy
### For Existing Users
**Option 1: Opt-in (Recommended)**
- Add "Register Passkey" button in profile settings
- Show banner encouraging passkey setup
- Don't require passkeys initially
- Gradually increase adoption through UI prompts
**Option 2: Mandatory Migration**
- Set deadline for passkey registration
- Email users with instructions
- Admins can enforce passkey requirement per group
- Provide support documentation
### For New Users
**During First-Run Wizard**:
1. Create account with email + password (existing flow)
2. Offer optional passkey registration
3. If accepted, walk through registration
4. If declined, remind later in dashboard
---
## Performance Considerations
### Database Indexes
```ruby
# Essential indexes for performance
add_index :webauthn_credentials, :user_id
add_index :webauthn_credentials, :external_id, unique: true
add_index :webauthn_credentials, [:user_id, :last_used_at]
```
### Query Optimization
- Eager load credentials with user: `User.includes(:webauthn_credentials)`
- Cache credential count: `user.webauthn_credentials.count`
### Cleanup Jobs
- Remove expired challenges from session store
- Archive old credentials (last_used > 1 year ago)
---
## Rollout Plan
### Phase 1: Development (Week 1-2)
- [ ] Setup gem and database schema
- [ ] Implement registration ceremony
- [ ] Implement authentication ceremony
- [ ] Add basic UI components
### Phase 2: Testing (Week 2-3)
- [ ] Write unit and integration tests
- [ ] Test with virtual authenticators
- [ ] Test on real devices (iOS, Android, Windows, macOS)
- [ ] Security audit
### Phase 3: Beta (Week 3-4)
- [ ] Deploy to staging environment
- [ ] Enable for admin users only
- [ ] Gather feedback
- [ ] Fix bugs and UX issues
### Phase 4: Production (Week 4-5)
- [ ] Deploy to production
- [ ] Enable for all users (opt-in)
- [ ] Monitor error rates and adoption
- [ ] Document and share user guides
### Phase 5: Enforcement (Week 6+)
- [ ] Analyze adoption metrics
- [ ] Consider enforcement for high-security groups
- [ ] Continuous improvement based on feedback
---
## Open Questions & Decisions Needed
1. **Attestation Level**: Should we validate authenticator attestation? (Recommendation: No for v1)
2. **Resident Key Strategy**: Require resident keys (discoverable credentials)? (Recommendation: Preferred, not required)
3. **Backup Credential Policy**: Allow synced passkeys (iCloud Keychain, Google Password Manager)? (Recommendation: Yes, allow)
4. **Account Recovery**: How should users recover if they lose all passkeys? (Recommendation: Email verification + temporary password)
5. **2FA Replacement**: Should WebAuthn replace TOTP for 2FA? (Recommendation: Offer both, user choice)
6. **Enforcement Timeline**: When should we require passkeys for admins? (Recommendation: 3 months after launch)
7. **Cross-Platform Keys**: Encourage users to register both platform and roaming authenticators? (Recommendation: Yes, show prompt)
8. **Audit Logging**: Log all WebAuthn events? (Recommendation: Yes, use Rails ActiveSupport::Notifications)
---
## Dependencies
### Ruby Gems
- `webauthn` (~> 3.0) - WebAuthn server library
- `base64` (stdlib) - Encoding/decoding credentials
### JavaScript Libraries
- Native WebAuthn API (no libraries needed)
- Stimulus controller for UX
### Browser Requirements
- WebAuthn API support
- HTTPS (required in production)
- Modern browser (Chrome 90+, Firefox 90+, Safari 14+)
---
## Success Metrics
### Adoption Metrics
- % of users with at least one passkey registered
- % of logins using passkey vs password
- Time to register passkey (UX metric)
### Security Metrics
- Reduction in password reset requests
- Reduction in account takeover attempts
- Phishing resistance (passkeys can't be phished)
### Performance Metrics
- Average authentication time (should be faster)
- Error rate during registration/authentication
- Browser compatibility issues
---
## Future Enhancements
### Post-Launch Improvements
1. **Conditional UI**: Show passkey option only if user has credentials for that device
2. **Cross-Device Flow**: QR code to authenticate on one device, complete login on another
3. **Passkey Sync Status**: Show which passkeys are synced vs device-only
4. **Authenticator Icons**: Display icons for known authenticators (YubiKey, etc.)
5. **Security Key Attestation**: Verify hardware security keys for high-security apps
6. **Multi-Device Registration**: Easy workflow to register passkey on multiple devices
7. **Admin Analytics**: Dashboard showing WebAuthn adoption and usage stats
8. **FIDO2 Compliance**: Full FIDO2 conformance certification
---
## References
### Specifications
- [W3C WebAuthn Level 2](https://www.w3.org/TR/webauthn-2/)
- [FIDO2 Overview](https://fidoalliance.org/fido2/)
- [WebAuthn Guide](https://webauthn.guide/)
### Ruby Libraries
- [webauthn-ruby gem](https://github.com/cedarcode/webauthn-ruby)
- [webauthn-ruby documentation](https://github.com/cedarcode/webauthn-ruby#usage)
### Browser APIs
- [MDN: Web Authentication API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API)
- [Chrome: WebAuthn](https://developer.chrome.com/docs/devtools/webauthn/)
### Best Practices
- [FIDO2 Server Best Practices](https://fidoalliance.org/specifications/)
- [WebAuthn Awesome List](https://github.com/herrjemand/awesome-webauthn)
---
## Appendix A: File Changes Summary
### New Files
- `app/models/webauthn_credential.rb`
- `app/controllers/webauthn_controller.rb`
- `app/javascript/controllers/webauthn_controller.js`
- `app/views/webauthn/new.html.erb`
- `app/views/webauthn/show.html.erb`
- `config/initializers/webauthn.rb`
- `db/migrate/YYYYMMDD_create_webauthn_credentials.rb`
- `db/migrate/YYYYMMDD_add_webauthn_to_users.rb`
- `test/models/webauthn_credential_test.rb`
- `test/controllers/webauthn_controller_test.rb`
- `test/integration/webauthn_authentication_test.rb`
- `test/system/webauthn_test.rb`
- `docs/webauthn-user-guide.md`
- `docs/webauthn-admin-guide.md`
- `docs/webauthn-developer-guide.md`
### Modified Files
- `Gemfile` - Add webauthn gem
- `app/models/user.rb` - Add webauthn associations and methods
- `app/controllers/sessions_controller.rb` - Add webauthn authentication
- `app/views/sessions/new.html.erb` - Add "Sign in with Passkey" button
- `app/views/profiles/show.html.erb` - Add passkey management section
- `app/controllers/oidc_controller.rb` - Add AMR claim support
- `config/routes.rb` - Add webauthn routes
- `README.md` - Document WebAuthn feature
### Database Migrations
1. Create `webauthn_credentials` table
2. Add `webauthn_id` and `webauthn_required` to `users` table
---
## Appendix B: Example User Flows
### Flow 1: Register First Passkey
1. User logs in with password
2. Sees banner: "Secure your account with a passkey"
3. Clicks "Set up passkey"
4. Browser prompts: "Save a passkey for auth.example.com?"
5. User authenticates with Touch ID
6. Success message: "Passkey registered as 'MacBook Touch ID'"
### Flow 2: Sign In with Passkey
1. User visits login page
2. Enters email address
3. Clicks "Continue with Passkey"
4. Browser prompts: "Sign in to auth.example.com with your passkey?"
5. User authenticates with Touch ID
6. Immediately signed in, redirected to dashboard
### Flow 3: WebAuthn as 2FA
1. User enters password (first factor)
2. Instead of TOTP, prompted for passkey
3. User authenticates with Face ID
4. Signed in successfully
### Flow 4: Cross-Device Authentication
1. User on desktop enters email
2. Clicks "Use passkey from phone"
3. QR code displayed
4. User scans with phone, authenticates
5. Desktop session created
---
## Conclusion
This plan provides a comprehensive roadmap for adding WebAuthn/Passkeys to Clinch. The phased approach allows for iterative development, testing, and rollout while maintaining backward compatibility with existing authentication methods.
**Key Benefits**:
- Enhanced security (phishing-resistant)
- Better UX (faster, no passwords to remember)
- Modern authentication standard (FIDO2)
- Cross-platform support (iOS, Android, Windows, macOS)
- Synced passkeys (iCloud, Google Password Manager)
**Estimated Timeline**: 4-6 weeks for full implementation and testing.
**Next Steps**:
1. Review and approve this plan
2. Create GitHub issues for each phase
3. Begin Phase 1 implementation
4. Set up development environment for testing
---
*Document Version: 1.0*
*Last Updated: 2025-10-26*
*Author: Claude (Anthropic)*
*Status: Awaiting Review*

View File

@@ -1,5 +1,5 @@
require "test_helper"
class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
driven_by :selenium, using: :headless_chrome, screen_size: [ 1400, 1400 ]
driven_by :selenium, using: :headless_chrome, screen_size: [1400, 1400]
end

View File

@@ -13,7 +13,7 @@ module Api
# Authentication Tests
test "should redirect to login when no session cookie" do
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
assert_response 302
assert_match %r{/signin}, response.location
@@ -23,7 +23,7 @@ module Api
test "should redirect when user is inactive" do
sign_in_as(@inactive_user)
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
assert_response 302
assert_equal "User account is not active", response.headers["x-auth-reason"]
@@ -32,7 +32,7 @@ module Api
test "should return 200 when user is authenticated" do
sign_in_as(@user)
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
assert_response 200
end
@@ -41,7 +41,7 @@ module Api
test "should return 200 when matching rule exists" do
sign_in_as(@user)
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
assert_response 200
end
@@ -49,7 +49,7 @@ module Api
test "should return 403 when no rule matches (fail-closed security)" do
sign_in_as(@user)
get "/api/verify", headers: { "X-Forwarded-Host" => "unknown.example.com" }
get "/api/verify", headers: {"X-Forwarded-Host" => "unknown.example.com"}
assert_response 403
assert_equal "No authentication rule configured for this domain", response.headers["x-auth-reason"]
@@ -58,7 +58,7 @@ module Api
test "should return 403 when rule exists but is inactive" do
sign_in_as(@user)
get "/api/verify", headers: { "X-Forwarded-Host" => "inactive.example.com" }
get "/api/verify", headers: {"X-Forwarded-Host" => "inactive.example.com"}
assert_response 403
assert_equal "No authentication rule configured for this domain", response.headers["x-auth-reason"]
@@ -68,7 +68,7 @@ module Api
@rule.allowed_groups << @group
sign_in_as(@user) # User not in group
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
assert_response 403
assert_match %r{permission to access this domain}, response.headers["x-auth-reason"]
@@ -79,35 +79,35 @@ module Api
@user.groups << @group
sign_in_as(@user)
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
assert_response 200
end
# Domain Pattern Tests
test "should match wildcard domains correctly" do
wildcard_rule = Application.create!(name: "Wildcard App", slug: "wildcard-app", app_type: "forward_auth", domain_pattern: "*.example.com", active: true)
Application.create!(name: "Wildcard App", slug: "wildcard-app", app_type: "forward_auth", domain_pattern: "*.example.com", active: true)
sign_in_as(@user)
get "/api/verify", headers: { "X-Forwarded-Host" => "app.example.com" }
get "/api/verify", headers: {"X-Forwarded-Host" => "app.example.com"}
assert_response 200
get "/api/verify", headers: { "X-Forwarded-Host" => "api.example.com" }
get "/api/verify", headers: {"X-Forwarded-Host" => "api.example.com"}
assert_response 200
get "/api/verify", headers: { "X-Forwarded-Host" => "other.com" }
get "/api/verify", headers: {"X-Forwarded-Host" => "other.com"}
assert_response 403 # No rule configured - fail-closed
assert_equal "No authentication rule configured for this domain", response.headers["x-auth-reason"]
end
test "should match exact domains correctly" do
exact_rule = Application.create!(name: "Exact App", slug: "exact-app", app_type: "forward_auth", domain_pattern: "api.example.com", active: true)
Application.create!(name: "Exact App", slug: "exact-app", app_type: "forward_auth", domain_pattern: "api.example.com", active: true)
sign_in_as(@user)
get "/api/verify", headers: { "X-Forwarded-Host" => "api.example.com" }
get "/api/verify", headers: {"X-Forwarded-Host" => "api.example.com"}
assert_response 200
get "/api/verify", headers: { "X-Forwarded-Host" => "app.api.example.com" }
get "/api/verify", headers: {"X-Forwarded-Host" => "app.api.example.com"}
assert_response 403 # No rule configured - fail-closed
assert_equal "No authentication rule configured for this domain", response.headers["x-auth-reason"]
end
@@ -116,7 +116,7 @@ module Api
test "should return default headers when rule has no custom config" do
sign_in_as(@user)
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
assert_response 200
assert_equal @user.email_address, response.headers["x-remote-user"]
@@ -126,7 +126,7 @@ module Api
end
test "should return custom headers when configured" do
custom_rule = Application.create!(
Application.create!(
name: "Custom App",
slug: "custom-app",
app_type: "forward_auth",
@@ -140,7 +140,7 @@ module Api
)
sign_in_as(@user)
get "/api/verify", headers: { "X-Forwarded-Host" => "custom.example.com" }
get "/api/verify", headers: {"X-Forwarded-Host" => "custom.example.com"}
assert_response 200
assert_equal @user.email_address, response.headers["x-webauth-user"]
@@ -151,17 +151,17 @@ module Api
end
test "should return no headers when all headers disabled" do
no_headers_rule = Application.create!(
Application.create!(
name: "No Headers App",
slug: "no-headers-app",
app_type: "forward_auth",
domain_pattern: "noheaders.example.com",
active: true,
headers_config: { user: "", email: "", name: "", groups: "", admin: "" }
headers_config: {user: "", email: "", name: "", groups: "", admin: ""}
)
sign_in_as(@user)
get "/api/verify", headers: { "X-Forwarded-Host" => "noheaders.example.com" }
get "/api/verify", headers: {"X-Forwarded-Host" => "noheaders.example.com"}
assert_response 200
# Check that auth-specific headers are not present (exclude Rails security headers)
@@ -173,7 +173,7 @@ module Api
@user.groups << @group
sign_in_as(@user)
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
assert_response 200
groups_header = response.headers["x-remote-groups"]
@@ -186,7 +186,7 @@ module Api
@user.groups.clear # Remove fixture groups
sign_in_as(@user)
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
assert_response 200
assert_nil response.headers["x-remote-groups"]
@@ -195,7 +195,7 @@ module Api
test "should include admin header correctly" do
sign_in_as(@admin_user) # Assuming users(:two) is admin
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
assert_response 200
assert_equal "true", response.headers["x-remote-admin"]
@@ -207,7 +207,7 @@ module Api
@user.groups << group2
sign_in_as(@user)
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
assert_response 200
groups_header = response.headers["x-remote-groups"]
@@ -219,7 +219,7 @@ module Api
test "should fall back to Host header when X-Forwarded-Host is missing" do
sign_in_as(@user)
get "/api/verify", headers: { "Host" => "test.example.com" }
get "/api/verify", headers: {"Host" => "test.example.com"}
assert_response 200
end
@@ -239,7 +239,7 @@ module Api
long_domain = "a" * 250 + ".example.com"
sign_in_as(@user)
get "/api/verify", headers: { "X-Forwarded-Host" => long_domain }
get "/api/verify", headers: {"X-Forwarded-Host" => long_domain}
assert_response 403 # No rule configured - fail-closed
assert_equal "No authentication rule configured for this domain", response.headers["x-auth-reason"]
@@ -248,7 +248,7 @@ module Api
test "should handle case insensitive domain matching" do
sign_in_as(@user)
get "/api/verify", headers: { "X-Forwarded-Host" => "TEST.Example.COM" }
get "/api/verify", headers: {"X-Forwarded-Host" => "TEST.Example.COM"}
assert_response 200
end
@@ -262,7 +262,7 @@ module Api
get "/api/verify", headers: {
"X-Forwarded-Host" => "test.example.com",
"X-Forwarded-Uri" => "/admin"
}, params: { rd: evil_url }
}, params: {rd: evil_url}
assert_response 302
assert_match %r{/signin}, response.location
@@ -292,8 +292,8 @@ module Api
# This should be allowed (domain has ForwardAuthRule)
allowed_url = "https://test.example.com/dashboard"
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" },
params: { rd: allowed_url }
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"},
params: {rd: allowed_url}
assert_response 302
assert_match allowed_url, response.location
@@ -305,8 +305,8 @@ module Api
# This should be rejected (no ForwardAuthRule for evil-site.com)
evil_url = "https://evil-site.com/steal-credentials"
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" },
params: { rd: evil_url }
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"},
params: {rd: evil_url}
assert_response 302
# Should redirect to login page or default URL, NOT to evil_url
@@ -320,8 +320,8 @@ module Api
# This should be rejected (HTTP not HTTPS)
http_url = "http://test.example.com/dashboard"
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" },
params: { rd: http_url }
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"},
params: {rd: http_url}
assert_response 302
# Should redirect to login page or default URL, NOT to HTTP URL
@@ -340,8 +340,8 @@ module Api
]
dangerous_schemes.each do |dangerous_url|
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" },
params: { rd: dangerous_url }
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"},
params: {rd: dangerous_url}
assert_response 302, "Should reject dangerous URL: #{dangerous_url}"
# Should redirect to login page or default URL, NOT to dangerous URL
@@ -355,7 +355,7 @@ module Api
sign_in_as(@user)
# Authenticated GET requests should return 200
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
assert_response 200
end
@@ -461,11 +461,11 @@ module Api
sign_in_as(@user)
# First request
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
assert_response 200
# Second request with same session
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
assert_response 200
# Should maintain user identity across requests
@@ -481,8 +481,8 @@ module Api
5.times do |i|
threads << Thread.new do
get "/api/verify", headers: { "X-Forwarded-Host" => "app#{i}.example.com" }
results << { status: response.status }
get "/api/verify", headers: {"X-Forwarded-Host" => "app#{i}.example.com"}
results << {status: response.status}
end
end
@@ -524,7 +524,7 @@ module Api
request_count = 10
request_count.times do |i|
get "/api/verify", headers: { "X-Forwarded-Host" => "app#{i}.example.com" }
get "/api/verify", headers: {"X-Forwarded-Host" => "app#{i}.example.com"}
assert_response 403 # No rules configured for these domains
end

View File

@@ -10,10 +10,14 @@ class AuthenticationTest < ActiveSupport::TestCase
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
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
begin
return nil if IPAddr.new(host_without_port)
rescue
false
end
# Use Public Suffix List for accurate domain parsing
domain = PublicSuffix.parse(host_without_port)

View File

@@ -31,7 +31,7 @@ class InputValidationTest < ActionDispatch::IntegrationTest
user = User.create!(email_address: "xss_test@example.com", password: "password123", name: xss_payload)
# Sign in
post signin_path, params: { email_address: "xss_test@example.com", password: "password123" }
post signin_path, params: {email_address: "xss_test@example.com", password: "password123"}
assert_response :redirect
# Get a page that displays user name
@@ -59,7 +59,7 @@ class InputValidationTest < ActionDispatch::IntegrationTest
)
# Sign in
post signin_path, params: { email_address: "oauth_tamper_test@example.com", password: "password123" }
post signin_path, params: {email_address: "oauth_tamper_test@example.com", password: "password123"}
assert_response :redirect
# Try to tamper with OAuth authorization parameters
@@ -112,7 +112,7 @@ class InputValidationTest < ActionDispatch::IntegrationTest
test "JSON input validation prevents malicious payloads" do
# Try to send malformed JSON
post "/oauth/token", params: '{"grant_type":"authorization_code",}'.to_json,
headers: { "CONTENT_TYPE" => "application/json" }
headers: {"CONTENT_TYPE" => "application/json"}
# Should handle malformed JSON gracefully
assert_includes [400, 422], response.status
@@ -124,9 +124,9 @@ class InputValidationTest < ActionDispatch::IntegrationTest
grant_type: "authorization_code",
code: "test_code",
redirect_uri: "http://localhost:4000/callback",
nested: { __proto__: "tampered", constructor: { prototype: "tampered" } }
nested: {__proto__: "tampered", constructor: {prototype: "tampered"}}
}.to_json,
headers: { "CONTENT_TYPE" => "application/json" }
headers: {"CONTENT_TYPE" => "application/json"}
# Should sanitize or reject prototype pollution attempts
# The request should be handled (either accept or reject, not crash)
@@ -165,7 +165,7 @@ class InputValidationTest < ActionDispatch::IntegrationTest
malicious_paths.each do |malicious_path|
# Try to access files with path traversal
get root_path, params: { file: malicious_path }
get root_path, params: {file: malicious_path}
# Should prevent access to files outside public directory
assert_response :redirect, "Should reject path traversal attempt"

View File

@@ -100,7 +100,7 @@ class InvitationsControllerTest < ActionDispatch::IntegrationTest
test "should destroy existing sessions when accepting invitation" do
# Create an existing session for the user
existing_session = @user.sessions.create!
@user.sessions.create!
put invitation_path(@token), params: {
password: "newpassword123",

View File

@@ -35,7 +35,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
test "prevents authorization code reuse - sequential attempts" do
# Create consent
consent = OidcUserConsent.create!(
OidcUserConsent.create!(
user: @user,
application: @application,
scopes_granted: "openid profile",
@@ -81,7 +81,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
test "revokes existing tokens when authorization code is reused" do
# Create consent
consent = OidcUserConsent.create!(
OidcUserConsent.create!(
user: @user,
application: @application,
scopes_granted: "openid profile",
@@ -135,7 +135,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
test "rejects already used authorization code" do
# Create consent
consent = OidcUserConsent.create!(
OidcUserConsent.create!(
user: @user,
application: @application,
scopes_granted: "openid profile",
@@ -171,7 +171,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
test "rejects expired authorization code" do
# Create consent
consent = OidcUserConsent.create!(
OidcUserConsent.create!(
user: @user,
application: @application,
scopes_granted: "openid profile",
@@ -206,7 +206,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
test "rejects authorization code with mismatched redirect_uri" do
# Create consent
consent = OidcUserConsent.create!(
OidcUserConsent.create!(
user: @user,
application: @application,
scopes_granted: "openid profile",
@@ -256,7 +256,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
test "rejects authorization code for different application" do
# Create consent for the first application
consent = OidcUserConsent.create!(
OidcUserConsent.create!(
user: @user,
application: @application,
scopes_granted: "openid profile",
@@ -308,7 +308,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
test "rejects invalid client_id in Basic auth" do
# Create consent
consent = OidcUserConsent.create!(
OidcUserConsent.create!(
user: @user,
application: @application,
scopes_granted: "openid profile",
@@ -341,7 +341,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
test "rejects invalid client_secret in Basic auth" do
# Create consent
consent = OidcUserConsent.create!(
OidcUserConsent.create!(
user: @user,
application: @application,
scopes_granted: "openid profile",
@@ -374,7 +374,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
test "accepts client credentials in POST body" do
# Create consent
consent = OidcUserConsent.create!(
OidcUserConsent.create!(
user: @user,
application: @application,
scopes_granted: "openid profile",
@@ -408,7 +408,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
test "rejects request with no client authentication" do
# Create consent
consent = OidcUserConsent.create!(
OidcUserConsent.create!(
user: @user,
application: @application,
scopes_granted: "openid profile",
@@ -474,7 +474,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
test "client authentication uses constant-time comparison" do
# Create consent
consent = OidcUserConsent.create!(
OidcUserConsent.create!(
user: @user,
application: @application,
scopes_granted: "openid profile",
@@ -546,7 +546,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
)
# Sign in first
post signin_path, params: { email_address: "security_test@example.com", password: "password123" }
post signin_path, params: {email_address: "security_test@example.com", password: "password123"}
# Test authorization with state parameter
get "/oauth/authorize", params: {
@@ -573,7 +573,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
)
# Sign in first
post signin_path, params: { email_address: "security_test@example.com", password: "password123" }
post signin_path, params: {email_address: "security_test@example.com", password: "password123"}
# Test authorization without state parameter
get "/oauth/authorize", params: {
@@ -593,7 +593,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
test "nonce parameter is included in ID token" do
# Create consent
consent = OidcUserConsent.create!(
OidcUserConsent.create!(
user: @user,
application: @application,
scopes_granted: "openid profile",
@@ -637,7 +637,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
test "access tokens are not exposed in referer header" do
# Create consent and authorization code
consent = OidcUserConsent.create!(
OidcUserConsent.create!(
user: @user,
application: @application,
scopes_granted: "openid profile",
@@ -664,7 +664,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
assert_response :success
response_body = JSON.parse(@response.body)
access_token = response_body["access_token"]
response_body["access_token"]
# Verify token is not in response headers (especially Referer)
assert_nil response.headers["Referer"], "Access token should not leak in Referer header"
@@ -677,7 +677,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
test "PKCE code_verifier is required when code_challenge was provided" do
# Create consent
consent = OidcUserConsent.create!(
OidcUserConsent.create!(
user: @user,
application: @application,
scopes_granted: "openid profile",
@@ -716,7 +716,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
test "PKCE with S256 method validates correctly" do
# Create consent
consent = OidcUserConsent.create!(
OidcUserConsent.create!(
user: @user,
application: @application,
scopes_granted: "openid profile",
@@ -755,7 +755,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
test "PKCE rejects invalid code_verifier" do
# Create consent
consent = OidcUserConsent.create!(
OidcUserConsent.create!(
user: @user,
application: @application,
scopes_granted: "openid profile",
@@ -798,7 +798,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
test "refresh token rotation is enforced" do
# Create consent for the refresh token endpoint
consent = OidcUserConsent.create!(
OidcUserConsent.create!(
user: @user,
application: @application,
scopes_granted: "openid profile",

View File

@@ -38,7 +38,6 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
end
test "authorization endpoint accepts PKCE parameters (S256)" do
code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
code_challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
auth_params = {
@@ -56,7 +55,7 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
# Should show consent page (user is already authenticated)
assert_response :success
assert_match /consent/, @response.body.downcase
assert_match(/consent/, @response.body.downcase)
end
test "authorization endpoint accepts PKCE parameters (plain)" do
@@ -77,7 +76,7 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
# Should show consent page (user is already authenticated)
assert_response :success
assert_match /consent/, @response.body.downcase
assert_match(/consent/, @response.body.downcase)
end
test "authorization endpoint rejects invalid code_challenge_method" do
@@ -478,7 +477,7 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
assert_response :bad_request
error = JSON.parse(@response.body)
assert_equal "invalid_request", error["error"]
assert_match /PKCE is required for public clients/, error["error_description"]
assert_match(/PKCE is required for public clients/, error["error_description"])
# Cleanup
OidcRefreshToken.where(application: public_app).delete_all
@@ -525,7 +524,7 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
assert_response :bad_request
error = JSON.parse(@response.body)
assert_equal "invalid_request", error["error"]
assert_match /PKCE is required/, error["error_description"]
assert_match(/PKCE is required/, error["error_description"])
end
# ====================

View File

@@ -9,8 +9,8 @@ class PasswordsControllerTest < ActionDispatch::IntegrationTest
end
test "create" do
post passwords_path, params: { email_address: @user.email_address }
assert_enqueued_email_with PasswordsMailer, :reset, args: [ @user ]
post passwords_path, params: {email_address: @user.email_address}
assert_enqueued_email_with PasswordsMailer, :reset, args: [@user]
assert_redirected_to signin_path
follow_redirect!
@@ -18,7 +18,7 @@ class PasswordsControllerTest < ActionDispatch::IntegrationTest
end
test "create for an unknown user redirects but sends no mail" do
post passwords_path, params: { email_address: "missing-user@example.com" }
post passwords_path, params: {email_address: "missing-user@example.com"}
assert_enqueued_emails 0
assert_redirected_to signin_path
@@ -41,7 +41,7 @@ class PasswordsControllerTest < ActionDispatch::IntegrationTest
test "update" do
assert_changes -> { @user.reload.password_digest } do
put password_path(@user.generate_token_for(:password_reset)), params: { password: "newpassword", password_confirmation: "newpassword" }
put password_path(@user.generate_token_for(:password_reset)), params: {password: "newpassword", password_confirmation: "newpassword"}
assert_redirected_to signin_path
end
@@ -52,7 +52,7 @@ class PasswordsControllerTest < ActionDispatch::IntegrationTest
test "update with non matching passwords" do
token = @user.password_reset_token
assert_no_changes -> { @user.reload.password_digest } do
put password_path(token), params: { password: "no", password_confirmation: "match" }
put password_path(token), params: {password: "no", password_confirmation: "match"}
assert_redirected_to edit_password_path(token)
end
@@ -61,6 +61,7 @@ class PasswordsControllerTest < ActionDispatch::IntegrationTest
end
private
def assert_notice(text)
assert_select "div", /#{text}/
end

View File

@@ -9,14 +9,14 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest
end
test "create with valid credentials" do
post session_path, params: { email_address: @user.email_address, password: "password" }
post session_path, params: {email_address: @user.email_address, password: "password"}
assert_redirected_to root_path
assert cookies[:session_id]
end
test "create with invalid credentials" do
post session_path, params: { email_address: @user.email_address, password: "wrong" }
post session_path, params: {email_address: @user.email_address, password: "wrong"}
assert_redirected_to signin_path
assert_nil cookies[:session_id]

View File

@@ -14,11 +14,11 @@ class TotpSecurityTest < ActionDispatch::IntegrationTest
valid_code = totp.now
# Set up pending TOTP session
post signin_path, params: { email_address: "totp_replay_test@example.com", password: "password123" }
post signin_path, params: {email_address: "totp_replay_test@example.com", password: "password123"}
assert_redirected_to totp_verification_path
# First use of the code should succeed
post totp_verification_path, params: { code: valid_code }
post totp_verification_path, params: {code: valid_code}
assert_response :redirect
assert_redirected_to root_path
@@ -50,12 +50,12 @@ class TotpSecurityTest < ActionDispatch::IntegrationTest
original_codes = user.reload.backup_codes
# Set up pending TOTP session
post signin_path, params: { email_address: "backup_code_test@example.com", password: "password123" }
post signin_path, params: {email_address: "backup_code_test@example.com", password: "password123"}
assert_redirected_to totp_verification_path
# Use a backup code
backup_code = backup_codes.first
post totp_verification_path, params: { code: backup_code }
post totp_verification_path, params: {code: backup_code}
# Should successfully sign in
assert_response :redirect
@@ -70,11 +70,11 @@ class TotpSecurityTest < ActionDispatch::IntegrationTest
assert_response :redirect
# Sign in again
post signin_path, params: { email_address: "backup_code_test@example.com", password: "password123" }
post signin_path, params: {email_address: "backup_code_test@example.com", password: "password123"}
assert_redirected_to totp_verification_path
# Try the same backup code
post totp_verification_path, params: { code: backup_code }
post totp_verification_path, params: {code: backup_code}
# Should fail - backup code already used
assert_response :redirect
@@ -91,13 +91,13 @@ class TotpSecurityTest < ActionDispatch::IntegrationTest
# Generate backup codes
user.totp_secret = ROTP::Base32.random
backup_codes = user.send(:generate_backup_codes) # Call private method
user.send(:generate_backup_codes) # Call private method
user.save!
# Check that stored codes are BCrypt hashes (start with $2a$)
# backup_codes is already an Array (JSON column), no need to parse
user.backup_codes.each do |code|
assert_match /^\$2[aby]\$/, code, "Backup codes should be BCrypt hashed"
assert_match(/^\$2[aby]\$/, code, "Backup codes should be BCrypt hashed")
end
user.destroy
@@ -116,7 +116,7 @@ class TotpSecurityTest < ActionDispatch::IntegrationTest
user.save!
# Set up pending TOTP session
post signin_path, params: { email_address: "totp_time_test@example.com", password: "password123" }
post signin_path, params: {email_address: "totp_time_test@example.com", password: "password123"}
assert_redirected_to totp_verification_path
# Generate a TOTP code for a time far in the future (outside valid window)
@@ -124,7 +124,7 @@ class TotpSecurityTest < ActionDispatch::IntegrationTest
future_code = totp.at(Time.now.to_i + 300) # 5 minutes in the future
# Try to use the future code
post totp_verification_path, params: { code: future_code }
post totp_verification_path, params: {code: future_code}
# Should fail - code is outside valid time window
assert_response :redirect
@@ -145,16 +145,16 @@ class TotpSecurityTest < ActionDispatch::IntegrationTest
# Verify the TOTP secret exists (sanity check)
assert user.totp_secret.present?
totp_secret = user.totp_secret
user.totp_secret
# Sign in with TOTP
post signin_path, params: { email_address: "totp_secret_test@example.com", password: "password123" }
post signin_path, params: {email_address: "totp_secret_test@example.com", password: "password123"}
assert_redirected_to totp_verification_path
# Complete TOTP verification
totp = ROTP::TOTP.new(user.totp_secret)
valid_code = totp.now
post totp_verification_path, params: { code: valid_code }
post totp_verification_path, params: {code: valid_code}
assert_response :redirect
# The TOTP secret should never be exposed in the response body or headers
@@ -210,7 +210,7 @@ class TotpSecurityTest < ActionDispatch::IntegrationTest
user.update!(totp_required: true, totp_secret: nil)
# Sign in
post signin_path, params: { email_address: "totp_setup_test@example.com", password: "password123" }
post signin_path, params: {email_address: "totp_setup_test@example.com", password: "password123"}
# Should redirect to TOTP setup, not verification
assert_response :redirect
@@ -232,7 +232,7 @@ class TotpSecurityTest < ActionDispatch::IntegrationTest
user.save!
# Set up pending TOTP session
post signin_path, params: { email_address: "totp_format_test@example.com", password: "password123" }
post signin_path, params: {email_address: "totp_format_test@example.com", password: "password123"}
assert_redirected_to totp_verification_path
# Try invalid formats
@@ -245,7 +245,7 @@ class TotpSecurityTest < ActionDispatch::IntegrationTest
]
invalid_codes.each do |invalid_code|
post totp_verification_path, params: { code: invalid_code }
post totp_verification_path, params: {code: invalid_code}
assert_response :redirect
assert_redirected_to totp_verification_path
end
@@ -266,11 +266,11 @@ class TotpSecurityTest < ActionDispatch::IntegrationTest
user.save!
# Sign in
post signin_path, params: { email_address: "totp_recovery_test@example.com", password: "password123" }
post signin_path, params: {email_address: "totp_recovery_test@example.com", password: "password123"}
assert_redirected_to totp_verification_path
# Use backup code instead of TOTP
post totp_verification_path, params: { code: backup_codes.first }
post totp_verification_path, params: {code: backup_codes.first}
# Should successfully sign in
assert_response :redirect

View File

@@ -20,27 +20,27 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
# Basic Authentication Flow Tests
test "complete authentication flow: unauthenticated to authenticated" do
# Step 1: Unauthenticated request should redirect
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
assert_response 302
assert_match %r{/signin}, response.location
assert_equal "No session cookie", response.headers["x-auth-reason"]
# Step 2: Sign in
post "/signin", params: { email_address: @user.email_address, password: "password" }
post "/signin", params: {email_address: @user.email_address, password: "password"}
assert_response 302
# Signin now redirects back with fa_token parameter
assert_match(/\?fa_token=/, response.location)
assert cookies[:session_id]
# Step 3: Authenticated request should succeed
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
assert_response 200
assert_equal @user.email_address, response.headers["x-remote-user"]
end
test "session expiration handling" do
# Sign in
post "/signin", params: { email_address: @user.email_address, password: "password" }
post "/signin", params: {email_address: @user.email_address, password: "password"}
# Manually expire the session (get the most recent session for this user)
session = Session.where(user: @user).order(created_at: :desc).first
@@ -48,7 +48,7 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
session.update!(expires_at: 1.hour.ago)
# Request should fail and redirect to login
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
assert_response 302
assert_equal "Session expired", response.headers["x-auth-reason"]
end
@@ -56,24 +56,24 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
# Domain and Rule Integration Tests
test "different domain patterns with same session" do
# Create test rules
wildcard_rule = Application.create!(name: "Wildcard App", slug: "wildcard-app", app_type: "forward_auth", domain_pattern: "*.example.com", active: true)
exact_rule = Application.create!(name: "Exact App", slug: "exact-app", app_type: "forward_auth", domain_pattern: "api.example.com", active: true)
Application.create!(name: "Wildcard App", slug: "wildcard-app", app_type: "forward_auth", domain_pattern: "*.example.com", active: true)
Application.create!(name: "Exact App", slug: "exact-app", app_type: "forward_auth", domain_pattern: "api.example.com", active: true)
# Sign in
post "/signin", params: { email_address: @user.email_address, password: "password" }
post "/signin", params: {email_address: @user.email_address, password: "password"}
# Test wildcard domain
get "/api/verify", headers: { "X-Forwarded-Host" => "app.example.com" }
get "/api/verify", headers: {"X-Forwarded-Host" => "app.example.com"}
assert_response 200
assert_equal @user.email_address, response.headers["x-remote-user"]
# Test exact domain
get "/api/verify", headers: { "X-Forwarded-Host" => "api.example.com" }
get "/api/verify", headers: {"X-Forwarded-Host" => "api.example.com"}
assert_response 200
assert_equal @user.email_address, response.headers["x-remote-user"]
# Test non-matching domain (should use defaults)
get "/api/verify", headers: { "X-Forwarded-Host" => "other.example.com" }
get "/api/verify", headers: {"X-Forwarded-Host" => "other.example.com"}
assert_response 200
assert_equal @user.email_address, response.headers["x-remote-user"]
end
@@ -84,10 +84,10 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
restricted_rule.allowed_groups << @group
# Sign in user without group
post "/signin", params: { email_address: @user.email_address, password: "password" }
post "/signin", params: {email_address: @user.email_address, password: "password"}
# Should be denied access
get "/api/verify", headers: { "X-Forwarded-Host" => "restricted.example.com" }
get "/api/verify", headers: {"X-Forwarded-Host" => "restricted.example.com"}
assert_response 403
assert_match %r{permission to access this domain}, response.headers["x-auth-reason"]
@@ -95,7 +95,7 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
@user.groups << @group
# Should now be allowed
get "/api/verify", headers: { "X-Forwarded-Host" => "restricted.example.com" }
get "/api/verify", headers: {"X-Forwarded-Host" => "restricted.example.com"}
assert_response 200
assert_equal @user.email_address, response.headers["x-remote-user"]
end
@@ -103,18 +103,18 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
# Header Configuration Integration Tests
test "different header configurations with same user" do
# Create applications with different configs
default_rule = Application.create!(name: "Default App", slug: "default-app", app_type: "forward_auth", domain_pattern: "default.example.com", active: true)
custom_rule = Application.create!(
Application.create!(name: "Default App", slug: "default-app", app_type: "forward_auth", domain_pattern: "default.example.com", active: true)
Application.create!(
name: "Custom App", slug: "custom-app", app_type: "forward_auth",
domain_pattern: "custom.example.com",
active: true,
headers_config: { user: "X-WEBAUTH-USER", groups: "X-WEBAUTH-ROLES" }
headers_config: {user: "X-WEBAUTH-USER", groups: "X-WEBAUTH-ROLES"}
)
no_headers_rule = Application.create!(
Application.create!(
name: "No Headers App", slug: "no-headers-app", app_type: "forward_auth",
domain_pattern: "noheaders.example.com",
active: true,
headers_config: { user: "", email: "", name: "", groups: "", admin: "" }
headers_config: {user: "", email: "", name: "", groups: "", admin: ""}
)
# Add user to groups
@@ -122,10 +122,10 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
@user.groups << @group2
# Sign in
post "/signin", params: { email_address: @user.email_address, password: "password" }
post "/signin", params: {email_address: @user.email_address, password: "password"}
# Test default headers
get "/api/verify", headers: { "X-Forwarded-Host" => "default.example.com" }
get "/api/verify", headers: {"X-Forwarded-Host" => "default.example.com"}
assert_response 200
# Rails normalizes header keys to lowercase
assert_equal @user.email_address, response.headers["x-remote-user"]
@@ -133,7 +133,7 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
assert_equal "Group Two,Group One", response.headers["x-remote-groups"]
# Test custom headers
get "/api/verify", headers: { "X-Forwarded-Host" => "custom.example.com" }
get "/api/verify", headers: {"X-Forwarded-Host" => "custom.example.com"}
assert_response 200
# Custom headers are also normalized to lowercase
assert_equal @user.email_address, response.headers["x-webauth-user"]
@@ -141,7 +141,7 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
assert_equal "Group Two,Group One", response.headers["x-webauth-roles"]
# Test no headers
get "/api/verify", headers: { "X-Forwarded-Host" => "noheaders.example.com" }
get "/api/verify", headers: {"X-Forwarded-Host" => "noheaders.example.com"}
assert_response 200
# Check that no auth-related headers are present (excluding security headers)
auth_headers = response.headers.select { |k, v| k.match?(/^x-remote-|^x-webauth-|^x-admin-/i) }
@@ -174,7 +174,7 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
get "/api/verify", headers: {
"X-Forwarded-Host" => "app.example.com",
"X-Forwarded-Uri" => "/admin"
}, params: { rd: "https://app.example.com/admin" }
}, params: {rd: "https://app.example.com/admin"}
assert_response 302
location = response.location
@@ -194,16 +194,16 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
admin_user = users(:two)
# Create restricted rule
admin_rule = Application.create!(
Application.create!(
name: "Admin App", slug: "admin-app", app_type: "forward_auth",
domain_pattern: "admin.example.com",
active: true,
headers_config: { user: "X-Admin-User", admin: "X-Admin-Flag" }
headers_config: {user: "X-Admin-User", admin: "X-Admin-Flag"}
)
# Test regular user
post "/signin", params: { email_address: regular_user.email_address, password: "password" }
get "/api/verify", headers: { "X-Forwarded-Host" => "admin.example.com" }
post "/signin", params: {email_address: regular_user.email_address, password: "password"}
get "/api/verify", headers: {"X-Forwarded-Host" => "admin.example.com"}
assert_response 200
assert_equal regular_user.email_address, response.headers["x-admin-user"]
@@ -211,8 +211,8 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
delete "/session"
# Test admin user
post "/signin", params: { email_address: admin_user.email_address, password: "password" }
get "/api/verify", headers: { "X-Forwarded-Host" => "admin.example.com" }
post "/signin", params: {email_address: admin_user.email_address, password: "password"}
get "/api/verify", headers: {"X-Forwarded-Host" => "admin.example.com"}
assert_response 200
assert_equal admin_user.email_address, response.headers["x-admin-user"]
assert_equal "true", response.headers["x-admin-flag"]
@@ -221,10 +221,10 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
# Security Integration Tests
test "session hijacking prevention" do
# User A signs in
post "/signin", params: { email_address: @user.email_address, password: "password" }
post "/signin", params: {email_address: @user.email_address, password: "password"}
# Verify User A can access protected resources
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
assert_response 200
assert_equal @user.email_address, response.headers["x-remote-user"]
user_a_session_id = Session.where(user: @user).last.id
@@ -233,10 +233,10 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
reset!
# User B signs in (creates a new session)
post "/signin", params: { email_address: @admin_user.email_address, password: "password" }
post "/signin", params: {email_address: @admin_user.email_address, password: "password"}
# Verify User B can access protected resources
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
assert_response 200
assert_equal @admin_user.email_address, response.headers["x-remote-user"]
user_b_session_id = Session.where(user: @admin_user).last.id
@@ -245,5 +245,4 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
assert Session.exists?(user_a_session_id), "User A's session should still exist"
assert Session.exists?(user_b_session_id), "User B's session should still exist"
end
end

View File

@@ -94,7 +94,7 @@ class InvitationFlowTest < ActionDispatch::IntegrationTest
end
test "expired invitation token flow" do
user = User.create!(
User.create!(
email_address: "expired@example.com",
password: "temppassword",
status: :pending_invitation

View File

@@ -9,7 +9,7 @@ class SessionSecurityTest < ActionDispatch::IntegrationTest
user = User.create!(email_address: "session_test@example.com", password: "password123")
# Sign in
post signin_path, params: { email_address: "session_test@example.com", password: "password123" }
post signin_path, params: {email_address: "session_test@example.com", password: "password123"}
assert_response :redirect
follow_redirect!
assert_response :success
@@ -75,7 +75,7 @@ class SessionSecurityTest < ActionDispatch::IntegrationTest
user = User.create!(email_address: "session_fixation_test@example.com", password: "password123")
# Sign in creates a new session
post signin_path, params: { email_address: "session_fixation_test@example.com", password: "password123" }
post signin_path, params: {email_address: "session_fixation_test@example.com", password: "password123"}
assert_response :redirect
# User should be authenticated after sign in
@@ -92,21 +92,21 @@ class SessionSecurityTest < ActionDispatch::IntegrationTest
user = User.create!(email_address: "concurrent_session_test@example.com", password: "password123")
# Create multiple sessions from different devices
session1 = user.sessions.create!(
user.sessions.create!(
ip_address: "192.168.1.1",
user_agent: "Mozilla/5.0 (Windows)",
device_name: "Windows PC",
last_activity_at: Time.current
)
session2 = user.sessions.create!(
user.sessions.create!(
ip_address: "192.168.1.2",
user_agent: "Mozilla/5.0 (iPhone)",
device_name: "iPhone",
last_activity_at: Time.current
)
session3 = user.sessions.create!(
user.sessions.create!(
ip_address: "192.168.1.3",
user_agent: "Mozilla/5.0 (Macintosh)",
device_name: "MacBook",
@@ -157,14 +157,14 @@ class SessionSecurityTest < ActionDispatch::IntegrationTest
user = User.create!(email_address: "logout_test@example.com", password: "password123")
# Create multiple sessions
session1 = user.sessions.create!(
user.sessions.create!(
ip_address: "192.168.1.1",
user_agent: "Mozilla/5.0 (Windows)",
device_name: "Windows PC",
last_activity_at: Time.current
)
session2 = user.sessions.create!(
user.sessions.create!(
ip_address: "192.168.1.2",
user_agent: "Mozilla/5.0 (iPhone)",
device_name: "iPhone",
@@ -172,7 +172,7 @@ class SessionSecurityTest < ActionDispatch::IntegrationTest
)
# Sign in (creates a new session via the sign-in flow)
post signin_path, params: { email_address: "logout_test@example.com", password: "password123" }
post signin_path, params: {email_address: "logout_test@example.com", password: "password123"}
assert_response :redirect
# Should have 3 sessions now
@@ -204,7 +204,7 @@ class SessionSecurityTest < ActionDispatch::IntegrationTest
)
# Create consent with backchannel logout enabled
consent = OidcUserConsent.create!(
OidcUserConsent.create!(
user: user,
application: application,
scopes_granted: "openid profile",
@@ -212,7 +212,7 @@ class SessionSecurityTest < ActionDispatch::IntegrationTest
)
# Sign in
post signin_path, params: { email_address: "logout_notification_test@example.com", password: "password123" }
post signin_path, params: {email_address: "logout_notification_test@example.com", password: "password123"}
assert_response :redirect
# Sign out
@@ -237,8 +237,8 @@ class SessionSecurityTest < ActionDispatch::IntegrationTest
user = User.create!(email_address: "hijacking_test@example.com", password: "password123")
# Sign in
post signin_path, params: { email_address: "hijacking_test@example.com", password: "password123" },
headers: { "HTTP_USER_AGENT" => "TestBrowser/1.0" }
post signin_path, params: {email_address: "hijacking_test@example.com", password: "password123"},
headers: {"HTTP_USER_AGENT" => "TestBrowser/1.0"}
assert_response :redirect
# Check that session includes IP and user agent
@@ -295,7 +295,7 @@ class SessionSecurityTest < ActionDispatch::IntegrationTest
# Test forward auth endpoint with valid session
get api_verify_path(rd: "https://test.example.com/protected"),
headers: { cookie: "_session_id=#{user_session.id}" }
headers: {cookie: "_session_id=#{user_session.id}"}
# Should accept the request and redirect back
assert_response :redirect

View File

@@ -10,7 +10,7 @@ class WebauthnCredentialEnumerationTest < ActionDispatch::IntegrationTest
user2 = User.create!(email_address: "user2@example.com", password: "password123")
# Create a credential for user1
credential1 = user1.webauthn_credentials.create!(
user1.webauthn_credentials.create!(
external_id: Base64.urlsafe_encode64("user1_credential"),
public_key: Base64.urlsafe_encode64("public_key_1"),
sign_count: 0,
@@ -28,7 +28,7 @@ class WebauthnCredentialEnumerationTest < ActionDispatch::IntegrationTest
)
# Sign in as user1
post signin_path, params: { email_address: "user1@example.com", password: "password123" }
post signin_path, params: {email_address: "user1@example.com", password: "password123"}
assert_response :redirect
follow_redirect!
@@ -66,7 +66,7 @@ class WebauthnCredentialEnumerationTest < ActionDispatch::IntegrationTest
)
# Sign in
post signin_path, params: { email_address: "user@example.com", password: "password123" }
post signin_path, params: {email_address: "user@example.com", password: "password123"}
assert_response :redirect
follow_redirect!

View File

@@ -37,7 +37,7 @@ class ApplicationJobTest < ActiveJob::TestCase
end
assert_enqueued_jobs 1 do
test_job.perform_later("arg1", "arg2", { "key" => "value" })
test_job.perform_later("arg1", "arg2", {"key" => "value"})
end
# ActiveJob serializes all hash keys as strings
@@ -77,7 +77,7 @@ class ApplicationJobTest < ActiveJob::TestCase
args = enqueued_jobs.last[:args]
if args.is_a?(Array) && args.first.is_a?(Hash)
# GlobalID serialization format
assert_equal user.to_global_id.to_s, args.first['_aj_globalid']
assert_equal user.to_global_id.to_s, args.first["_aj_globalid"]
else
# Direct object serialization
assert_equal user.id, args.first.id

View File

@@ -166,7 +166,7 @@ class PasswordsMailerTest < ActionMailer::TestCase
# Should not include sensitive data in headers (except Subject which legitimately mentions password)
email.header.fields.each do |field|
next if field.name =~ /^subject$/i
next if /^subject$/i.match?(field.name)
# Check for actual tokens (not just the word "token" which is common in emails)
refute_includes field.value.to_s.downcase, "password"
end

View File

@@ -10,7 +10,7 @@ class ApplicationUserClaimTest < ActiveSupport::TestCase
claim = ApplicationUserClaim.new(
user: @user,
application: @application,
custom_claims: { "role": "admin" }
custom_claims: {role: "admin"}
)
assert claim.valid?
assert claim.save
@@ -20,13 +20,13 @@ class ApplicationUserClaimTest < ActiveSupport::TestCase
ApplicationUserClaim.create!(
user: @user,
application: @application,
custom_claims: { "role": "admin" }
custom_claims: {role: "admin"}
)
duplicate = ApplicationUserClaim.new(
user: @user,
application: @application,
custom_claims: { "role": "user" }
custom_claims: {role: "user"}
)
assert_not duplicate.valid?
@@ -37,7 +37,7 @@ class ApplicationUserClaimTest < ActiveSupport::TestCase
claim = ApplicationUserClaim.new(
user: @user,
application: @application,
custom_claims: { "role": "admin", "level": 5 }
custom_claims: {role: "admin", level: 5}
)
parsed = claim.parsed_custom_claims
@@ -59,7 +59,7 @@ class ApplicationUserClaimTest < ActiveSupport::TestCase
claim = ApplicationUserClaim.new(
user: @user,
application: @application,
custom_claims: { "groups": ["admin"], "role": "user" }
custom_claims: {groups: ["admin"], role: "user"}
)
assert_not claim.valid?
@@ -70,7 +70,7 @@ class ApplicationUserClaimTest < ActiveSupport::TestCase
claim = ApplicationUserClaim.new(
user: @user,
application: @application,
custom_claims: { "kavita_groups": ["admin"], "role": "user" }
custom_claims: {kavita_groups: ["admin"], role: "user"}
)
assert claim.valid?

View File

@@ -27,7 +27,7 @@ class OidcAccessTokenTest < ActiveSupport::TestCase
assert_nil new_token.plaintext_token
assert new_token.save
assert_not_nil new_token.plaintext_token
assert_match /^[A-Za-z0-9_-]+$/, new_token.plaintext_token
assert_match(/^[A-Za-z0-9_-]+$/, new_token.plaintext_token)
end
test "should set expiry before validation on create" do
@@ -144,7 +144,7 @@ class OidcAccessTokenTest < ActiveSupport::TestCase
# All tokens should match the expected pattern
tokens.each do |token|
assert_match /^[A-Za-z0-9_-]+$/, token
assert_match(/^[A-Za-z0-9_-]+$/, token)
# Base64 token length may vary due to padding, just ensure it's reasonable
assert token.length >= 43, "Token should be at least 43 characters"
assert token.length <= 64, "Token should not exceed 64 characters"

View File

@@ -28,7 +28,7 @@ class OidcAuthorizationCodeTest < ActiveSupport::TestCase
assert_nil new_code.code_hmac
assert new_code.save
assert_not_nil new_code.code_hmac
assert_match /^[a-f0-9]{64}$/, new_code.code_hmac # SHA256 hex digest
assert_match(/^[a-f0-9]{64}$/, new_code.code_hmac) # SHA256 hex digest
end
test "should set expiry before validation on create" do
@@ -186,7 +186,7 @@ class OidcAuthorizationCodeTest < ActiveSupport::TestCase
# All codes should be SHA256 hex digests
codes.each do |code|
assert_match /^[a-f0-9]{64}$/, code
assert_match(/^[a-f0-9]{64}$/, code)
assert_equal 64, code.length # SHA256 hex digest
end
end

View File

@@ -73,7 +73,7 @@ class UserPasswordManagementTest < ActiveSupport::TestCase
assert_not authenticated_user.authenticate("WrongPassword"), "Should not authenticate with wrong password"
# Test password changes invalidate old sessions
old_password_digest = @user.password_digest
@user.password_digest
@user.password = "NewPassword123!"
@user.save!
@@ -102,7 +102,7 @@ class UserPasswordManagementTest < ActiveSupport::TestCase
assert new_user.password_digest.length > 50, "Password digest should be substantial"
# Test digest format (bcrypt hashes start with $2a$)
assert_match /^\$2a\$/, new_user.password_digest, "Password digest should be bcrypt format"
assert_match(/^\$2a\$/, new_user.password_digest, "Password digest should be bcrypt format")
# Test authentication against digest
authenticated_user = User.find(new_user.id)

View File

@@ -33,7 +33,7 @@ class UserTest < ActiveSupport::TestCase
end
test "does not find user with invalid invitation token" do
user = User.create!(
User.create!(
email_address: "test@example.com",
password: "password123",
status: :pending_invitation
@@ -222,7 +222,7 @@ class UserTest < ActiveSupport::TestCase
# Should store 10 BCrypt hashes
assert_equal 10, stored_hashes.length
stored_hashes.each do |hash|
assert hash.start_with?('$2a$'), "Should be BCrypt hash"
assert hash.start_with?("$2a$"), "Should be BCrypt hash"
end
# Verify each plain code matches its corresponding hash
@@ -298,7 +298,7 @@ class UserTest < ActiveSupport::TestCase
# Make 5 failed attempts to trigger rate limit
5.times do |i|
result = user.verify_backup_code("INVALID123")
assert_not result, "Failed attempt #{i+1} should return false"
assert_not result, "Failed attempt #{i + 1} should return false"
end
# Check that the cache is tracking attempts

View File

@@ -61,18 +61,18 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
assert_not_nil token, "Should generate token"
assert token.length > 100, "Token should be substantial"
assert token.include?('.')
assert token.include?(".")
# Decode without verification for testing the payload
decoded = JWT.decode(token, nil, false).first
assert_equal @application.client_id, decoded['aud'], "Should have correct audience"
assert_equal @user.id.to_s, decoded['sub'], "Should have correct subject"
assert_equal @user.email_address, decoded['email'], "Should have correct email"
assert_equal true, decoded['email_verified'], "Should have email verified"
assert_equal @user.email_address, decoded['preferred_username'], "Should have preferred username"
assert_equal @user.email_address, decoded['name'], "Should have name"
assert_equal @service.issuer_url, decoded['iss'], "Should have correct issuer"
assert_in_delta Time.current.to_i + 3600, decoded['exp'], 5, "Should have correct expiration"
assert_equal @application.client_id, decoded["aud"], "Should have correct audience"
assert_equal @user.id.to_s, decoded["sub"], "Should have correct subject"
assert_equal @user.email_address, decoded["email"], "Should have correct email"
assert_equal true, decoded["email_verified"], "Should have email verified"
assert_equal @user.email_address, decoded["preferred_username"], "Should have preferred username"
assert_equal @user.email_address, decoded["name"], "Should have name"
assert_equal @service.issuer_url, decoded["iss"], "Should have correct issuer"
assert_in_delta Time.current.to_i + 3600, decoded["exp"], 5, "Should have correct expiration"
end
test "should handle nonce in id token" do
@@ -80,8 +80,8 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
token = @service.generate_id_token(@user, @application, nonce: nonce)
decoded = JWT.decode(token, nil, false).first
assert_equal nonce, decoded['nonce'], "Should preserve nonce in token"
assert_in_delta Time.current.to_i + 3600, decoded['exp'], 5, "Should have correct expiration with nonce"
assert_equal nonce, decoded["nonce"], "Should preserve nonce in token"
assert_in_delta Time.current.to_i + 3600, decoded["exp"], 5, "Should have correct expiration with nonce"
end
test "should include groups in token when user has groups" do
@@ -91,7 +91,7 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
token = @service.generate_id_token(@user, @application)
decoded = JWT.decode(token, nil, false).first
assert_includes decoded['groups'], "Administrators", "Should include user's groups"
assert_includes decoded["groups"], "Administrators", "Should include user's groups"
end
test "admin claim should not be included in token" do
@@ -100,14 +100,14 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
token = @service.generate_id_token(@user, @application)
decoded = JWT.decode(token, nil, false).first
refute decoded.key?('admin'), "Admin claim should not be included in ID tokens (use groups instead)"
refute decoded.key?("admin"), "Admin claim should not be included in ID tokens (use groups instead)"
end
test "should handle missing roles gracefully" do
token = @service.generate_id_token(@user, @application)
decoded = JWT.decode(token, nil, false).first
refute_includes decoded, 'roles', "Should not have roles when not configured"
refute_includes decoded, "roles", "Should not have roles when not configured"
end
test "should load RSA private key from environment with escaped newlines" do
@@ -168,7 +168,7 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
OidcJwtService.send(:private_key)
end
assert_match /Invalid OIDC private key format/, error.message
assert_match(/Invalid OIDC private key format/, error.message)
ensure
# Restore original value and clear cached key
ENV["OIDC_PRIVATE_KEY"] = original_value
@@ -193,7 +193,7 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
OidcJwtService.send(:private_key)
end
assert_match /OIDC private key not configured/, error.message
assert_match(/OIDC private key not configured/, error.message)
ensure
# Restore original environment and clear cached key
ENV["OIDC_PRIVATE_KEY"] = original_value if original_value
@@ -214,9 +214,9 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
assert_not_nil decoded_array, "Should decode valid token"
decoded = decoded_array.first # JWT.decode returns an array
assert_equal @user.id.to_s, decoded['sub'], "Should decode subject correctly"
assert_equal @application.client_id, decoded['aud'], "Should decode audience correctly"
assert decoded['exp'] > Time.current.to_i, "Token should not be expired"
assert_equal @user.id.to_s, decoded["sub"], "Should decode subject correctly"
assert_equal @application.client_id, decoded["aud"], "Should decode audience correctly"
assert decoded["exp"] > Time.current.to_i, "Token should not be expired"
end
test "should reject invalid id tokens" do
@@ -252,9 +252,9 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
decoded = JWT.decode(token, nil, false).first
# ID tokens always include email_verified
assert_includes decoded.keys, 'email_verified'
assert_equal @user.id.to_s, decoded['sub'], "Should decode subject correctly"
assert_equal @application.client_id, decoded['aud'], "Should decode audience correctly"
assert_includes decoded.keys, "email_verified"
assert_equal @user.id.to_s, decoded["sub"], "Should decode subject correctly"
assert_equal @application.client_id, decoded["aud"], "Should decode audience correctly"
end
test "should validate JWT configuration" do
@@ -275,7 +275,7 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
ApplicationUserClaim.create!(
user: user,
application: app,
custom_claims: { "app_groups": ["admin"], "library_access": "all" }
custom_claims: {app_groups: ["admin"], library_access: "all"}
)
token = @service.generate_id_token(user, app)
@@ -292,17 +292,17 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
# Add user to group with claims
group = groups(:admin_group)
group.update!(custom_claims: { "role": "viewer", "max_items": 10 })
group.update!(custom_claims: {role: "viewer", max_items: 10})
user.groups << group
# Add user custom claims
user.update!(custom_claims: { "role": "editor", "theme": "dark" })
user.update!(custom_claims: {role: "editor", theme: "dark"})
# Add app-specific claims (should override both)
ApplicationUserClaim.create!(
user: user,
application: app,
custom_claims: { "role": "admin", "app_specific": true }
custom_claims: {role: "admin", app_specific: true}
)
token = @service.generate_id_token(user, app)
@@ -324,11 +324,11 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
# Group has roles: ["user"]
group = groups(:admin_group)
group.update!(custom_claims: { "roles" => ["user"], "permissions" => ["read"] })
group.update!(custom_claims: {"roles" => ["user"], "permissions" => ["read"]})
user.groups << group
# User adds roles: ["admin"]
user.update!(custom_claims: { "roles" => ["admin"], "permissions" => ["write"] })
user.update!(custom_claims: {"roles" => ["admin"], "permissions" => ["write"]})
token = @service.generate_id_token(user, app)
decoded = JWT.decode(token, nil, false).first
@@ -349,16 +349,16 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
# First group has roles: ["user"]
group1 = groups(:admin_group)
group1.update!(custom_claims: { "roles" => ["user"] })
group1.update!(custom_claims: {"roles" => ["user"]})
user.groups << group1
# Second group has roles: ["moderator"]
group2 = Group.create!(name: "moderators", description: "Moderators group")
group2.update!(custom_claims: { "roles" => ["moderator"] })
group2.update!(custom_claims: {"roles" => ["moderator"]})
user.groups << group2
# User adds roles: ["admin"]
user.update!(custom_claims: { "roles" => ["admin"] })
user.update!(custom_claims: {"roles" => ["admin"]})
token = @service.generate_id_token(user, app)
decoded = JWT.decode(token, nil, false).first
@@ -376,11 +376,11 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
# Group has roles: ["user", "reader"]
group = groups(:admin_group)
group.update!(custom_claims: { "roles" => ["user", "reader"] })
group.update!(custom_claims: {"roles" => ["user", "reader"]})
user.groups << group
# User also has "user" role (duplicate)
user.update!(custom_claims: { "roles" => ["user", "admin"] })
user.update!(custom_claims: {"roles" => ["user", "admin"]})
token = @service.generate_id_token(user, app)
decoded = JWT.decode(token, nil, false).first
@@ -398,11 +398,11 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
# Group has roles array and max_items scalar
group = groups(:admin_group)
group.update!(custom_claims: { "roles" => ["user"], "max_items" => 10, "theme" => "light" })
group.update!(custom_claims: {"roles" => ["user"], "max_items" => 10, "theme" => "light"})
user.groups << group
# User overrides max_items and theme, adds to roles
user.update!(custom_claims: { "roles" => ["admin"], "max_items" => 100, "theme" => "dark" })
user.update!(custom_claims: {"roles" => ["admin"], "max_items" => 100, "theme" => "dark"})
token = @service.generate_id_token(user, app)
decoded = JWT.decode(token, nil, false).first
@@ -425,7 +425,7 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
group.update!(custom_claims: {
"config" => {
"theme" => "light",
"notifications" => { "email" => true }
"notifications" => {"email" => true}
}
})
user.groups << group
@@ -434,7 +434,7 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
user.update!(custom_claims: {
"config" => {
"language" => "en",
"notifications" => { "sms" => true }
"notifications" => {"sms" => true}
}
})
@@ -454,17 +454,17 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
# Group has roles: ["user"]
group = groups(:admin_group)
group.update!(custom_claims: { "roles" => ["user"] })
group.update!(custom_claims: {"roles" => ["user"]})
user.groups << group
# User has roles: ["moderator"]
user.update!(custom_claims: { "roles" => ["moderator"] })
user.update!(custom_claims: {"roles" => ["moderator"]})
# App-specific has roles: ["app_admin"]
ApplicationUserClaim.create!(
user: user,
application: app,
custom_claims: { "roles" => ["app_admin"] }
custom_claims: {"roles" => ["app_admin"]}
)
token = @service.generate_id_token(user, app)

View File

@@ -13,13 +13,13 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
# End-to-End Authentication Flow Tests
test "complete forward auth flow with default headers" do
# Create an application with default headers
rule = Application.create!(name: "App", slug: "app-system-test", app_type: "forward_auth", domain_pattern: "app.example.com", active: true)
Application.create!(name: "App", slug: "app-system-test", app_type: "forward_auth", domain_pattern: "app.example.com", active: true)
# Step 1: Unauthenticated request to protected resource
get "/api/verify", headers: {
"X-Forwarded-Host" => "app.example.com",
"X-Forwarded-Uri" => "/dashboard"
}, params: { rd: "https://app.example.com/dashboard" }
}, params: {rd: "https://app.example.com/dashboard"}
assert_response 302
location = response.location
@@ -30,13 +30,13 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
assert_equal "https://app.example.com/dashboard", session[:return_to_after_authenticating]
# Step 3: Sign in
post "/signin", params: { email_address: @user.email_address, password: "password" }
post "/signin", params: {email_address: @user.email_address, password: "password"}
assert_response 302
assert_redirected_to "https://app.example.com/dashboard"
# Step 4: Authenticated request to protected resource
get "/api/verify", headers: { "X-Forwarded-Host" => "app.example.com" }
get "/api/verify", headers: {"X-Forwarded-Host" => "app.example.com"}
assert_response 200
assert_equal @user.email_address, response.headers["x-remote-user"]
@@ -46,38 +46,38 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
test "multiple domain access with single session" do
# Create applications for different domains
app_rule = Application.create!(name: "App Domain", slug: "app-domain", app_type: "forward_auth", domain_pattern: "app.example.com", active: true)
grafana_rule = Application.create!(
Application.create!(name: "App Domain", slug: "app-domain", app_type: "forward_auth", domain_pattern: "app.example.com", active: true)
Application.create!(
name: "Grafana", slug: "grafana-system-test", app_type: "forward_auth",
domain_pattern: "grafana.example.com",
active: true,
headers_config: { user: "X-WEBAUTH-USER", email: "X-WEBAUTH-EMAIL" }
headers_config: {user: "X-WEBAUTH-USER", email: "X-WEBAUTH-EMAIL"}
)
metube_rule = Application.create!(
Application.create!(
name: "Metube", slug: "metube-system-test", app_type: "forward_auth",
domain_pattern: "metube.example.com",
active: true,
headers_config: { user: "", email: "", name: "", groups: "", admin: "" }
headers_config: {user: "", email: "", name: "", groups: "", admin: ""}
)
# Sign in once
post "/signin", params: { email_address: @user.email_address, password: "password" }
post "/signin", params: {email_address: @user.email_address, password: "password"}
assert_response 302
assert_redirected_to "/"
# Test access to different applications
# App with default headers
get "/api/verify", headers: { "X-Forwarded-Host" => "app.example.com" }
get "/api/verify", headers: {"X-Forwarded-Host" => "app.example.com"}
assert_response 200
assert response.headers.key?("x-remote-user")
# Grafana with custom headers
get "/api/verify", headers: { "X-Forwarded-Host" => "grafana.example.com" }
get "/api/verify", headers: {"X-Forwarded-Host" => "grafana.example.com"}
assert_response 200
assert response.headers.key?("x-webauth-user")
# Metube with no headers
get "/api/verify", headers: { "X-Forwarded-Host" => "metube.example.com" }
get "/api/verify", headers: {"X-Forwarded-Host" => "metube.example.com"}
assert_response 200
auth_headers = response.headers.select { |k, v| k.match?(/^x-remote-|^x-webauth-|^x-admin-/i) }
assert_empty auth_headers
@@ -98,11 +98,11 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
@user.groups << @group
# Sign in
post "/signin", params: { email_address: @user.email_address, password: "password" }
post "/signin", params: {email_address: @user.email_address, password: "password"}
assert_response 302
# Should have access (in allowed group)
get "/api/verify", headers: { "X-Forwarded-Host" => "admin.example.com" }
get "/api/verify", headers: {"X-Forwarded-Host" => "admin.example.com"}
assert_response 200
assert_equal @group.name, response.headers["x-remote-groups"]
@@ -110,7 +110,7 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
@user.groups << @group2
# Should show multiple groups
get "/api/verify", headers: { "X-Forwarded-Host" => "admin.example.com" }
get "/api/verify", headers: {"X-Forwarded-Host" => "admin.example.com"}
assert_response 200
groups_header = response.headers["x-remote-groups"]
assert_includes groups_header, @group.name
@@ -120,13 +120,13 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
@user.groups.clear
# Should be denied
get "/api/verify", headers: { "X-Forwarded-Host" => "admin.example.com" }
get "/api/verify", headers: {"X-Forwarded-Host" => "admin.example.com"}
assert_response 403
end
test "bypass mode when no groups assigned to rule" do
# Create bypass application (no groups)
bypass_rule = Application.create!(
Application.create!(
name: "Public", slug: "public-system-test", app_type: "forward_auth",
domain_pattern: "public.example.com",
active: true
@@ -136,11 +136,11 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
@user.groups.clear
# Sign in
post "/signin", params: { email_address: @user.email_address, password: "password" }
post "/signin", params: {email_address: @user.email_address, password: "password"}
assert_response 302
# Should have access (bypass mode)
get "/api/verify", headers: { "X-Forwarded-Host" => "public.example.com" }
get "/api/verify", headers: {"X-Forwarded-Host" => "public.example.com"}
assert_response 200
assert_equal @user.email_address, response.headers["x-remote-user"]
end
@@ -148,12 +148,12 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
# Security System Tests
test "session security and isolation" do
# User A signs in
post "/signin", params: { email_address: @user.email_address, password: "password" }
post "/signin", params: {email_address: @user.email_address, password: "password"}
user_a_session = cookies[:session_id]
# User B signs in
delete "/session"
post "/signin", params: { email_address: @admin_user.email_address, password: "password" }
post "/signin", params: {email_address: @admin_user.email_address, password: "password"}
user_b_session = cookies[:session_id]
# User A should still be able to access resources
@@ -178,11 +178,11 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
test "session expiration and cleanup" do
# Sign in
post "/signin", params: { email_address: @user.email_address, password: "password" }
post "/signin", params: {email_address: @user.email_address, password: "password"}
session_id = cookies[:session_id]
# Should work initially
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
assert_response 200
# Manually expire session
@@ -190,7 +190,7 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
session.update!(expires_at: 1.hour.ago)
# Should redirect to login
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
assert_response 302
assert_equal "Session expired", response.headers["x-auth-reason"]
@@ -200,7 +200,7 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
test "concurrent access with rate limiting considerations" do
# Sign in
post "/signin", params: { email_address: @user.email_address, password: "password" }
post "/signin", params: {email_address: @user.email_address, password: "password"}
session_cookie = cookies[:session_id]
# Simulate multiple concurrent requests from different IPs
@@ -244,23 +244,23 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
apps = [
{
domain: "dashboard.example.com",
headers_config: { user: "X-DASHBOARD-USER", groups: "X-DASHBOARD-GROUPS" },
headers_config: {user: "X-DASHBOARD-USER", groups: "X-DASHBOARD-GROUPS"},
groups: [@group]
},
{
domain: "api.example.com",
headers_config: { user: "X-API-USER", email: "X-API-EMAIL" },
headers_config: {user: "X-API-USER", email: "X-API-EMAIL"},
groups: []
},
{
domain: "logs.example.com",
headers_config: { user: "", email: "", name: "", groups: "", admin: "" },
headers_config: {user: "", email: "", name: "", groups: "", admin: ""},
groups: []
}
]
# Create applications for each app
rules = apps.map.with_index do |app, idx|
apps.map.with_index do |app, idx|
rule = Application.create!(
name: "Multi App #{idx}", slug: "multi-app-#{idx}", app_type: "forward_auth",
domain_pattern: app[:domain],
@@ -275,12 +275,12 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
@user.groups << @group
# Sign in once
post "/signin", params: { email_address: @user.email_address, password: "password" }
post "/signin", params: {email_address: @user.email_address, password: "password"}
assert_response 302
# Test access to each application
apps.each do |app|
get "/api/verify", headers: { "X-Forwarded-Host" => app[:domain] }
get "/api/verify", headers: {"X-Forwarded-Host" => app[:domain]}
assert_response 200, "Failed for #{app[:domain]}"
# Verify headers are correct
@@ -300,24 +300,24 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
test "domain pattern edge cases" do
# Test various domain patterns
patterns = [
{ pattern: "*.example.com", domains: ["app.example.com", "api.example.com", "sub.app.example.com"] },
{ pattern: "api.*.com", domains: ["api.example.com", "api.test.com"] },
{ pattern: "*.*.example.com", domains: ["app.dev.example.com", "api.staging.example.com"] }
{pattern: "*.example.com", domains: ["app.example.com", "api.example.com", "sub.app.example.com"]},
{pattern: "api.*.com", domains: ["api.example.com", "api.test.com"]},
{pattern: "*.*.example.com", domains: ["app.dev.example.com", "api.staging.example.com"]}
]
patterns.each_with_index do |pattern_config, idx|
rule = Application.create!(
Application.create!(
name: "Pattern Test #{idx}", slug: "pattern-test-#{idx}", app_type: "forward_auth",
domain_pattern: pattern_config[:pattern],
active: true
)
# Sign in
post "/signin", params: { email_address: @user.email_address, password: "password" }
post "/signin", params: {email_address: @user.email_address, password: "password"}
# Test each domain
pattern_config[:domains].each do |domain|
get "/api/verify", headers: { "X-Forwarded-Host" => domain }
get "/api/verify", headers: {"X-Forwarded-Host" => domain}
assert_response 200, "Failed for pattern #{pattern_config[:pattern]} with domain #{domain}"
assert_equal @user.email_address, response.headers["x-remote-user"]
end
@@ -330,10 +330,10 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
# Performance System Tests
test "system performance under load" do
# Create test application
rule = Application.create!(name: "Load Test", slug: "loadtest", app_type: "forward_auth", domain_pattern: "loadtest.example.com", active: true)
Application.create!(name: "Load Test", slug: "loadtest", app_type: "forward_auth", domain_pattern: "loadtest.example.com", active: true)
# Sign in
post "/signin", params: { email_address: @user.email_address, password: "password" }
post "/signin", params: {email_address: @user.email_address, password: "password"}
session_cookie = cookies[:session_id]
# Performance test
@@ -374,7 +374,7 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
# Error Recovery System Tests
test "graceful degradation with database issues" do
# Sign in first
post "/signin", params: { email_address: @user.email_address, password: "password" }
post "/signin", params: {email_address: @user.email_address, password: "password"}
assert_response 302
# Simulate database connection issue by mocking
@@ -387,7 +387,7 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
begin
# Request should handle the error gracefully
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
# Should return 302 (redirect to login) rather than 500 error
assert_response 302, "Should gracefully handle database issues"
@@ -398,7 +398,7 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
end
# Normal operation should still work
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
assert_response 200
end
end

View File

@@ -78,7 +78,7 @@ class WebauthnSecurityTest < ActionDispatch::SystemTestCase
user = User.create!(email_address: "webauthn_handle_auth_test@example.com", password: "password123")
user_handle = SecureRandom.uuid
credential = user.webauthn_credentials.create!(
user.webauthn_credentials.create!(
external_id: Base64.urlsafe_encode64("fake_credential_id"),
public_key: Base64.urlsafe_encode64("fake_public_key"),
sign_count: 0,
@@ -99,7 +99,7 @@ class WebauthnSecurityTest < ActionDispatch::SystemTestCase
test "WebAuthn request validates origin" do
user = User.create!(email_address: "webauthn_origin_test@example.com", password: "password123")
credential = user.webauthn_credentials.create!(
user.webauthn_credentials.create!(
external_id: Base64.urlsafe_encode64("fake_credential_id"),
public_key: Base64.urlsafe_encode64("fake_public_key"),
sign_count: 0,
@@ -107,14 +107,14 @@ class WebauthnSecurityTest < ActionDispatch::SystemTestCase
)
# Test WebAuthn challenge from valid origin
post webauthn_challenge_path, params: { email: "webauthn_origin_test@example.com" },
headers: { "HTTP_ORIGIN": "http://localhost:3000" }
post webauthn_challenge_path, params: {email: "webauthn_origin_test@example.com"},
headers: {HTTP_ORIGIN: "http://localhost:3000"}
# Should succeed for valid origin
# Test WebAuthn challenge from invalid origin
post webauthn_challenge_path, params: { email: "webauthn_origin_test@example.com" },
headers: { "HTTP_ORIGIN": "http://evil.com" }
post webauthn_challenge_path, params: {email: "webauthn_origin_test@example.com"},
headers: {HTTP_ORIGIN: "http://evil.com"}
# Should reject invalid origin
@@ -125,7 +125,7 @@ class WebauthnSecurityTest < ActionDispatch::SystemTestCase
user = User.create!(email_address: "webauthn_verify_origin_test@example.com", password: "password123")
user.update!(webauthn_id: SecureRandom.uuid)
credential = user.webauthn_credentials.create!(
user.webauthn_credentials.create!(
external_id: Base64.urlsafe_encode64("fake_credential_id"),
public_key: Base64.urlsafe_encode64("fake_public_key"),
sign_count: 0,
@@ -133,10 +133,10 @@ class WebauthnSecurityTest < ActionDispatch::SystemTestCase
)
# Sign in with WebAuthn
post webauthn_challenge_path, params: { email: "webauthn_verify_origin_test@example.com" }
post webauthn_challenge_path, params: {email: "webauthn_verify_origin_test@example.com"}
assert_response :success
challenge = JSON.parse(@response.body)["challenge"]
JSON.parse(@response.body)["challenge"]
# Simulate WebAuthn verification with wrong origin
# This should fail
@@ -155,7 +155,7 @@ class WebauthnSecurityTest < ActionDispatch::SystemTestCase
# Standard attestation formats: none, packed, tpm, android-key, android-safetynet, fido-u2f, etc.
# Test with 'none' attestation (most common for privacy)
attestation_object = {
{
fmt: "none",
attStmt: {},
authData: Base64.strict_encode64("fake_auth_data")
@@ -170,7 +170,7 @@ class WebauthnSecurityTest < ActionDispatch::SystemTestCase
user = User.create!(email_address: "webauthn_invalid_attestation_test@example.com", password: "password123")
# Try to register with invalid attestation format
invalid_attestation = {
{
fmt: "invalid_format",
attStmt: {},
authData: Base64.strict_encode64("fake_auth_data")
@@ -263,7 +263,7 @@ class WebauthnSecurityTest < ActionDispatch::SystemTestCase
test "WebAuthn requires user presence for authentication" do
user = User.create!(email_address: "webauthn_presence_test@example.com", password: "password123")
credential = user.webauthn_credentials.create!(
user.webauthn_credentials.create!(
external_id: Base64.urlsafe_encode64("fake_credential_id"),
public_key: Base64.urlsafe_encode64("fake_public_key"),
sign_count: 0,
@@ -291,7 +291,7 @@ class WebauthnSecurityTest < ActionDispatch::SystemTestCase
nickname: "USB Key"
)
credential2 = user.webauthn_credentials.create!(
user.webauthn_credentials.create!(
external_id: Base64.urlsafe_encode64("credential_2"),
public_key: Base64.urlsafe_encode64("public_key_2"),
sign_count: 0,
@@ -317,7 +317,7 @@ class WebauthnSecurityTest < ActionDispatch::SystemTestCase
user.update!(webauthn_enabled: true)
# Sign in with password should still work
post signin_path, params: { email_address: "webauthn_required_test@example.com", password: "password123" }
post signin_path, params: {email_address: "webauthn_required_test@example.com", password: "password123"}
# If WebAuthn is enabled, should offer WebAuthn as an option
# Implementation should handle password + WebAuthn or passwordless flow
@@ -329,7 +329,7 @@ class WebauthnSecurityTest < ActionDispatch::SystemTestCase
user = User.create!(email_address: "webauthn_passwordless_test@example.com", password: "password123")
user.update!(webauthn_enabled: true)
credential = user.webauthn_credentials.create!(
user.webauthn_credentials.create!(
external_id: Base64.urlsafe_encode64("passwordless_credential"),
public_key: Base64.urlsafe_encode64("public_key"),
sign_count: 0,