Compare commits
3 Commits
9234904e47
...
93a0edb0a2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93a0edb0a2 | ||
|
|
7d3af2bcec | ||
|
|
c03034c49f |
14
.github/workflows/ci.yml
vendored
14
.github/workflows/ci.yml
vendored
@@ -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
7
.standard.yml
Normal 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
|
||||
9
Gemfile
9
Gemfile
@@ -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
|
||||
|
||||
|
||||
24
Gemfile.lock
24
Gemfile.lock
@@ -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
|
||||
|
||||
@@ -7,10 +7,11 @@ module ApplicationCable
|
||||
end
|
||||
|
||||
private
|
||||
def set_current_user
|
||||
if session = Session.find_by(id: cookies.signed[:session_id])
|
||||
self.current_user = session.user
|
||||
end
|
||||
|
||||
def set_current_user
|
||||
if (session = Session.find_by(id: cookies.signed[:session_id]))
|
||||
self.current_user = session.user
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -29,9 +29,9 @@ class ActiveSessionsController < ApplicationController
|
||||
# Revoke all tokens for this user-application pair
|
||||
now = Time.current
|
||||
revoked_access_tokens = OidcAccessToken.where(application: application, user: @user, revoked_at: nil)
|
||||
.update_all(revoked_at: now)
|
||||
.update_all(revoked_at: now)
|
||||
revoked_refresh_tokens = OidcRefreshToken.where(application: application, user: @user, revoked_at: nil)
|
||||
.update_all(revoked_at: now)
|
||||
.update_all(revoked_at: now)
|
||||
|
||||
Rails.logger.info "ActiveSessionsController: Revoked #{revoked_access_tokens} access tokens and #{revoked_refresh_tokens} refresh tokens for #{application.name}"
|
||||
|
||||
@@ -64,9 +64,9 @@ class ActiveSessionsController < ApplicationController
|
||||
# Revoke all tokens for this user-application pair
|
||||
now = Time.current
|
||||
revoked_access_tokens = OidcAccessToken.where(application: application, user: @user, revoked_at: nil)
|
||||
.update_all(revoked_at: now)
|
||||
.update_all(revoked_at: now)
|
||||
revoked_refresh_tokens = OidcRefreshToken.where(application: application, user: @user, revoked_at: nil)
|
||||
.update_all(revoked_at: now)
|
||||
.update_all(revoked_at: now)
|
||||
|
||||
Rails.logger.info "ActiveSessionsController: Logged out from #{application.name} - revoked #{revoked_access_tokens} access tokens and #{revoked_refresh_tokens} refresh tokens"
|
||||
|
||||
|
||||
@@ -32,13 +32,11 @@ module Admin
|
||||
client_secret = @application.generate_new_client_secret!
|
||||
end
|
||||
|
||||
flash[:notice] = "Application created successfully."
|
||||
if @application.oidc?
|
||||
flash[:notice] = "Application created successfully."
|
||||
flash[:client_id] = @application.client_id
|
||||
flash[:client_secret] = client_secret if client_secret
|
||||
flash[:public_client] = true if @application.public_client?
|
||||
else
|
||||
flash[:notice] = "Application created successfully."
|
||||
end
|
||||
|
||||
redirect_to admin_application_path(@application)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -81,22 +81,26 @@ module Api
|
||||
|
||||
# User is authenticated and authorized
|
||||
# Return 200 with user information headers using app-specific configuration
|
||||
headers = app ? app.headers_for_user(user) : Application::DEFAULT_HEADERS.map { |key, header_name|
|
||||
case key
|
||||
when :user, :email, :name
|
||||
[header_name, user.email_address]
|
||||
when :groups
|
||||
user.groups.any? ? [header_name, user.groups.pluck(:name).join(",")] : nil
|
||||
when :admin
|
||||
[header_name, user.admin? ? "true" : "false"]
|
||||
end
|
||||
}.compact.to_h
|
||||
headers = if app
|
||||
app.headers_for_user(user)
|
||||
else
|
||||
Application::DEFAULT_HEADERS.map { |key, header_name|
|
||||
case key
|
||||
when :user, :email, :name
|
||||
[header_name, user.email_address]
|
||||
when :groups
|
||||
user.groups.any? ? [header_name, user.groups.pluck(:name).join(",")] : nil
|
||||
when :admin
|
||||
[header_name, user.admin? ? "true" : "false"]
|
||||
end
|
||||
}.compact.to_h
|
||||
end
|
||||
|
||||
headers.each { |key, value| response.headers[key] = value }
|
||||
|
||||
# Log what headers we're sending (helpful for debugging)
|
||||
if headers.any?
|
||||
Rails.logger.debug "ForwardAuth: Headers sent: #{headers.keys.join(', ')}"
|
||||
Rails.logger.debug "ForwardAuth: Headers sent: #{headers.keys.join(", ")}"
|
||||
else
|
||||
Rails.logger.debug "ForwardAuth: No headers sent (access only)"
|
||||
end
|
||||
@@ -123,14 +127,13 @@ module Api
|
||||
# Delete the token immediately (one-time use)
|
||||
Rails.cache.delete("forward_auth_token:#{token}")
|
||||
|
||||
session_id
|
||||
session_id
|
||||
end
|
||||
|
||||
def extract_session_id
|
||||
# Extract session ID from cookie
|
||||
# Rails uses signed cookies by default
|
||||
session_id = cookies.signed[:session_id]
|
||||
session_id
|
||||
cookies.signed[:session_id]
|
||||
end
|
||||
|
||||
def extract_app_from_headers
|
||||
@@ -155,7 +158,7 @@ module Api
|
||||
original_uri = request.headers["X-Forwarded-Uri"] || request.headers["X-Forwarded-Path"] || "/"
|
||||
|
||||
# Debug logging to see what headers we're getting
|
||||
Rails.logger.info "ForwardAuth Headers: Host=#{request.headers['Host']}, X-Forwarded-Host=#{original_host}, X-Forwarded-Uri=#{request.headers['X-Forwarded-Uri']}, X-Forwarded-Path=#{request.headers['X-Forwarded-Path']}"
|
||||
Rails.logger.info "ForwardAuth Headers: Host=#{request.headers["Host"]}, X-Forwarded-Host=#{original_host}, X-Forwarded-Uri=#{request.headers["X-Forwarded-Uri"]}, X-Forwarded-Path=#{request.headers["X-Forwarded-Path"]}"
|
||||
|
||||
original_url = if original_host
|
||||
# Use the forwarded host and URI (original behavior)
|
||||
@@ -203,7 +206,7 @@ module Api
|
||||
return nil unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
|
||||
|
||||
# Only allow HTTPS in production
|
||||
return nil unless Rails.env.development? || uri.scheme == 'https'
|
||||
return nil unless Rails.env.development? || uri.scheme == "https"
|
||||
|
||||
redirect_domain = uri.host.downcase
|
||||
return nil unless redirect_domain.present?
|
||||
@@ -214,7 +217,6 @@ module Api
|
||||
end
|
||||
|
||||
matching_app ? url : nil
|
||||
|
||||
rescue URI::InvalidURIError
|
||||
nil
|
||||
end
|
||||
@@ -233,13 +235,13 @@ module Api
|
||||
return redirect_url if redirect_url.present?
|
||||
|
||||
# Try CLINCH_HOST environment variable first
|
||||
if ENV['CLINCH_HOST'].present?
|
||||
host = ENV['CLINCH_HOST']
|
||||
if ENV["CLINCH_HOST"].present?
|
||||
host = ENV["CLINCH_HOST"]
|
||||
# Ensure URL has https:// protocol
|
||||
host.match?(/^https?:\/\//) ? host : "https://#{host}"
|
||||
else
|
||||
# Fallback to the request host
|
||||
request_host = request.host || request.headers['X-Forwarded-Host']
|
||||
request_host = request.host || request.headers["X-Forwarded-Host"]
|
||||
if request_host.present?
|
||||
Rails.logger.warn "ForwardAuth: CLINCH_HOST not set, using request host: #{request_host}"
|
||||
"https://#{request_host}"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
require 'uri'
|
||||
require 'public_suffix'
|
||||
require 'ipaddr'
|
||||
require "uri"
|
||||
require "public_suffix"
|
||||
require "ipaddr"
|
||||
|
||||
module Authentication
|
||||
extend ActiveSupport::Concern
|
||||
@@ -17,133 +17,137 @@ module Authentication
|
||||
end
|
||||
|
||||
private
|
||||
def authenticated?
|
||||
resume_session
|
||||
|
||||
def authenticated?
|
||||
resume_session
|
||||
end
|
||||
|
||||
def require_authentication
|
||||
resume_session || request_authentication
|
||||
end
|
||||
|
||||
def resume_session
|
||||
Current.session ||= find_session_by_cookie
|
||||
end
|
||||
|
||||
def find_session_by_cookie
|
||||
Session.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id]
|
||||
end
|
||||
|
||||
def request_authentication
|
||||
session[:return_to_after_authenticating] = request.url
|
||||
redirect_to signin_path
|
||||
end
|
||||
|
||||
def after_authentication_url
|
||||
session[:return_to_after_authenticating]
|
||||
session.delete(:return_to_after_authenticating) || root_url
|
||||
end
|
||||
|
||||
def start_new_session_for(user, acr: "1")
|
||||
user.update!(last_sign_in_at: Time.current)
|
||||
user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip, acr: acr).tap do |session|
|
||||
Current.session = session
|
||||
|
||||
# Extract root domain for cross-subdomain cookies (required for forward auth)
|
||||
domain = extract_root_domain(request.host)
|
||||
|
||||
cookie_options = {
|
||||
value: session.id,
|
||||
httponly: true,
|
||||
same_site: :lax,
|
||||
secure: Rails.env.production?
|
||||
}
|
||||
|
||||
# Set domain for cross-subdomain authentication if we can extract it
|
||||
cookie_options[:domain] = domain if domain.present?
|
||||
|
||||
cookies.signed.permanent[:session_id] = cookie_options
|
||||
|
||||
# Create a one-time token for immediate forward auth after authentication
|
||||
# This solves the race condition where browser hasn't processed cookie yet
|
||||
create_forward_auth_token(session)
|
||||
end
|
||||
end
|
||||
|
||||
def terminate_session
|
||||
Current.session.destroy
|
||||
cookies.delete(:session_id)
|
||||
end
|
||||
|
||||
# Extract root domain for cross-subdomain cookies in SSO forward_auth system.
|
||||
#
|
||||
# PURPOSE: Enables a single authentication session to work across multiple subdomains
|
||||
# by setting cookies with the domain parameter (e.g., .example.com allows access from
|
||||
# both app.example.com and api.example.com).
|
||||
#
|
||||
# CRITICAL: Returns nil for IP addresses (IPv4 and IPv6) and localhost - this is intentional!
|
||||
# When accessing services by IP, there are no subdomains to share cookies with,
|
||||
# and setting a domain cookie would break authentication.
|
||||
#
|
||||
# Uses the Public Suffix List (industry standard maintained by Mozilla) to
|
||||
# correctly handle complex domain patterns like co.uk, com.au, appspot.com, etc.
|
||||
#
|
||||
# Examples:
|
||||
# - app.example.com -> .example.com (enables cross-subdomain SSO)
|
||||
# - api.example.co.uk -> .example.co.uk (handles complex TLDs)
|
||||
# - myapp.appspot.com -> .myapp.appspot.com (handles platform domains)
|
||||
# - localhost -> nil (local development, no domain cookie)
|
||||
# - 192.168.1.1 -> nil (IP access, no domain cookie - prevents SSO breakage)
|
||||
#
|
||||
# @param host [String] The request host (may include port)
|
||||
# @return [String, nil] Root domain with leading dot for cookies, or nil for no domain setting
|
||||
def extract_root_domain(host)
|
||||
return nil if host.blank? || host.match?(/^(localhost|127\.0\.0\.1|::1)$/)
|
||||
|
||||
# Strip port number for domain parsing
|
||||
host_without_port = host.split(":").first
|
||||
|
||||
# Check if it's an IP address (IPv4 or IPv6) - if so, don't set domain cookie
|
||||
begin
|
||||
return nil if IPAddr.new(host_without_port)
|
||||
rescue
|
||||
false
|
||||
end
|
||||
|
||||
def require_authentication
|
||||
resume_session || request_authentication
|
||||
end
|
||||
# Use Public Suffix List for accurate domain parsing
|
||||
domain = PublicSuffix.parse(host_without_port)
|
||||
".#{domain.domain}"
|
||||
rescue PublicSuffix::DomainInvalid
|
||||
# Fallback for invalid domains or IPs
|
||||
nil
|
||||
end
|
||||
|
||||
def resume_session
|
||||
Current.session ||= find_session_by_cookie
|
||||
end
|
||||
# Create a one-time token for forward auth to handle the race condition
|
||||
# where the browser hasn't processed the session cookie yet
|
||||
def create_forward_auth_token(session_obj)
|
||||
# Generate a secure random token
|
||||
token = SecureRandom.urlsafe_base64(32)
|
||||
|
||||
def find_session_by_cookie
|
||||
Session.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id]
|
||||
end
|
||||
# Store it with an expiry of 60 seconds
|
||||
Rails.cache.write(
|
||||
"forward_auth_token:#{token}",
|
||||
session_obj.id,
|
||||
expires_in: 60.seconds
|
||||
)
|
||||
|
||||
def request_authentication
|
||||
session[:return_to_after_authenticating] = request.url
|
||||
redirect_to signin_path
|
||||
end
|
||||
# Set the token as a query parameter on the redirect URL
|
||||
# We need to store this in the controller's session
|
||||
controller_session = session
|
||||
if controller_session[:return_to_after_authenticating].present?
|
||||
original_url = controller_session[:return_to_after_authenticating]
|
||||
uri = URI.parse(original_url)
|
||||
|
||||
def after_authentication_url
|
||||
return_url = session[:return_to_after_authenticating]
|
||||
final_url = session.delete(:return_to_after_authenticating) || root_url
|
||||
final_url
|
||||
end
|
||||
# Skip adding fa_token for OAuth URLs (OAuth flow should not have forward auth tokens)
|
||||
unless uri.path&.start_with?("/oauth/")
|
||||
# Add token as query parameter
|
||||
query_params = URI.decode_www_form(uri.query || "").to_h
|
||||
query_params["fa_token"] = token
|
||||
uri.query = URI.encode_www_form(query_params)
|
||||
|
||||
def start_new_session_for(user, acr: "1")
|
||||
user.update!(last_sign_in_at: Time.current)
|
||||
user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip, acr: acr).tap do |session|
|
||||
Current.session = session
|
||||
|
||||
# Extract root domain for cross-subdomain cookies (required for forward auth)
|
||||
domain = extract_root_domain(request.host)
|
||||
|
||||
cookie_options = {
|
||||
value: session.id,
|
||||
httponly: true,
|
||||
same_site: :lax,
|
||||
secure: Rails.env.production?
|
||||
}
|
||||
|
||||
# Set domain for cross-subdomain authentication if we can extract it
|
||||
cookie_options[:domain] = domain if domain.present?
|
||||
|
||||
cookies.signed.permanent[:session_id] = cookie_options
|
||||
|
||||
# Create a one-time token for immediate forward auth after authentication
|
||||
# This solves the race condition where browser hasn't processed cookie yet
|
||||
create_forward_auth_token(session)
|
||||
end
|
||||
end
|
||||
|
||||
def terminate_session
|
||||
Current.session.destroy
|
||||
cookies.delete(:session_id)
|
||||
end
|
||||
|
||||
# Extract root domain for cross-subdomain cookies in SSO forward_auth system.
|
||||
#
|
||||
# PURPOSE: Enables a single authentication session to work across multiple subdomains
|
||||
# by setting cookies with the domain parameter (e.g., .example.com allows access from
|
||||
# both app.example.com and api.example.com).
|
||||
#
|
||||
# CRITICAL: Returns nil for IP addresses (IPv4 and IPv6) and localhost - this is intentional!
|
||||
# When accessing services by IP, there are no subdomains to share cookies with,
|
||||
# and setting a domain cookie would break authentication.
|
||||
#
|
||||
# Uses the Public Suffix List (industry standard maintained by Mozilla) to
|
||||
# correctly handle complex domain patterns like co.uk, com.au, appspot.com, etc.
|
||||
#
|
||||
# Examples:
|
||||
# - app.example.com -> .example.com (enables cross-subdomain SSO)
|
||||
# - api.example.co.uk -> .example.co.uk (handles complex TLDs)
|
||||
# - myapp.appspot.com -> .myapp.appspot.com (handles platform domains)
|
||||
# - localhost -> nil (local development, no domain cookie)
|
||||
# - 192.168.1.1 -> nil (IP access, no domain cookie - prevents SSO breakage)
|
||||
#
|
||||
# @param host [String] The request host (may include port)
|
||||
# @return [String, nil] Root domain with leading dot for cookies, or nil for no domain setting
|
||||
def extract_root_domain(host)
|
||||
return nil if host.blank? || host.match?(/^(localhost|127\.0\.0\.1|::1)$/)
|
||||
|
||||
# Strip port number for domain parsing
|
||||
host_without_port = host.split(':').first
|
||||
|
||||
# Check if it's an IP address (IPv4 or IPv6) - if so, don't set domain cookie
|
||||
return nil if IPAddr.new(host_without_port) rescue false
|
||||
|
||||
# Use Public Suffix List for accurate domain parsing
|
||||
domain = PublicSuffix.parse(host_without_port)
|
||||
".#{domain.domain}"
|
||||
rescue PublicSuffix::DomainInvalid
|
||||
# Fallback for invalid domains or IPs
|
||||
nil
|
||||
end
|
||||
|
||||
# Create a one-time token for forward auth to handle the race condition
|
||||
# where the browser hasn't processed the session cookie yet
|
||||
def create_forward_auth_token(session_obj)
|
||||
# Generate a secure random token
|
||||
token = SecureRandom.urlsafe_base64(32)
|
||||
|
||||
# Store it with an expiry of 60 seconds
|
||||
Rails.cache.write(
|
||||
"forward_auth_token:#{token}",
|
||||
session_obj.id,
|
||||
expires_in: 60.seconds
|
||||
)
|
||||
|
||||
# Set the token as a query parameter on the redirect URL
|
||||
# We need to store this in the controller's session
|
||||
controller_session = session
|
||||
if controller_session[:return_to_after_authenticating].present?
|
||||
original_url = controller_session[:return_to_after_authenticating]
|
||||
uri = URI.parse(original_url)
|
||||
|
||||
# Skip adding fa_token for OAuth URLs (OAuth flow should not have forward auth tokens)
|
||||
unless uri.path&.start_with?("/oauth/")
|
||||
# Add token as query parameter
|
||||
query_params = URI.decode_www_form(uri.query || "").to_h
|
||||
query_params['fa_token'] = token
|
||||
uri.query = URI.encode_www_form(query_params)
|
||||
|
||||
# Update the session with the tokenized URL
|
||||
controller_session[:return_to_after_authenticating] = uri.to_s
|
||||
end
|
||||
# Update the session with the tokenized URL
|
||||
controller_session[:return_to_after_authenticating] = uri.to_s
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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
|
||||
@@ -5,7 +5,7 @@ class OidcController < ApplicationController
|
||||
|
||||
# Rate limiting to prevent brute force and abuse
|
||||
rate_limit to: 60, within: 1.minute, only: [:token, :revoke], with: -> {
|
||||
render json: { error: "too_many_requests", error_description: "Rate limit exceeded. Try again later." }, status: :too_many_requests
|
||||
render json: {error: "too_many_requests", error_description: "Rate limit exceeded. Try again later."}, status: :too_many_requests
|
||||
}
|
||||
rate_limit to: 30, within: 1.minute, only: [:authorize, :consent], with: -> {
|
||||
render plain: "Too many authorization attempts. Try again later.", status: :too_many_requests
|
||||
@@ -63,7 +63,7 @@ class OidcController < ApplicationController
|
||||
error_details << "redirect_uri is required" unless redirect_uri.present?
|
||||
error_details << "response_type must be 'code'" unless response_type == "code"
|
||||
|
||||
render plain: "Invalid request: #{error_details.join(', ')}", status: :bad_request
|
||||
render plain: "Invalid request: #{error_details.join(", ")}", status: :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
@@ -90,7 +90,7 @@ class OidcController < ApplicationController
|
||||
Rails.logger.error "OAuth: Available OIDC applications: #{all_oidc_apps.pluck(:id, :client_id, :name)}"
|
||||
|
||||
error_msg = if Rails.env.development?
|
||||
"Invalid request: Application not found for client_id '#{client_id}'. Available OIDC applications: #{all_oidc_apps.pluck(:name, :client_id).map { |name, id| "#{name} (#{id})" }.join(', ')}"
|
||||
"Invalid request: Application not found for client_id '#{client_id}'. Available OIDC applications: #{all_oidc_apps.pluck(:name, :client_id).map { |name, id| "#{name} (#{id})" }.join(", ")}"
|
||||
else
|
||||
"Invalid request: Application not found"
|
||||
end
|
||||
@@ -105,7 +105,7 @@ class OidcController < ApplicationController
|
||||
|
||||
# For development, show detailed error
|
||||
error_msg = if Rails.env.development?
|
||||
"Invalid request: Redirect URI mismatch. Application is configured for: #{@application.parsed_redirect_uris.join(', ')}, but received: #{redirect_uri}"
|
||||
"Invalid request: Redirect URI mismatch. Application is configured for: #{@application.parsed_redirect_uris.join(", ")}, but received: #{redirect_uri}"
|
||||
else
|
||||
"Invalid request: Redirect URI not registered for this application"
|
||||
end
|
||||
@@ -223,22 +223,22 @@ class OidcController < ApplicationController
|
||||
# User denied consent
|
||||
if params[:deny].present?
|
||||
session.delete(:oauth_params)
|
||||
error_uri = "#{oauth_params['redirect_uri']}?error=access_denied"
|
||||
error_uri += "&state=#{CGI.escape(oauth_params['state'])}" if oauth_params['state']
|
||||
error_uri = "#{oauth_params["redirect_uri"]}?error=access_denied"
|
||||
error_uri += "&state=#{CGI.escape(oauth_params["state"])}" if oauth_params["state"]
|
||||
redirect_to error_uri, allow_other_host: true
|
||||
return
|
||||
end
|
||||
|
||||
# Find the application
|
||||
client_id = oauth_params['client_id']
|
||||
client_id = oauth_params["client_id"]
|
||||
application = Application.find_by(client_id: client_id, app_type: "oidc")
|
||||
|
||||
# Check if application is active (redirect with OAuth error)
|
||||
unless application&.active?
|
||||
Rails.logger.error "OAuth: Application is not active: #{application&.name || client_id}"
|
||||
session.delete(:oauth_params)
|
||||
error_uri = "#{oauth_params['redirect_uri']}?error=unauthorized_client&error_description=Application+is+not+active"
|
||||
error_uri += "&state=#{CGI.escape(oauth_params['state'])}" if oauth_params['state'].present?
|
||||
error_uri = "#{oauth_params["redirect_uri"]}?error=unauthorized_client&error_description=Application+is+not+active"
|
||||
error_uri += "&state=#{CGI.escape(oauth_params["state"])}" if oauth_params["state"].present?
|
||||
redirect_to error_uri, allow_other_host: true
|
||||
return
|
||||
end
|
||||
@@ -246,9 +246,9 @@ class OidcController < ApplicationController
|
||||
user = Current.session.user
|
||||
|
||||
# Record user consent
|
||||
requested_scopes = oauth_params['scope'].split(' ')
|
||||
requested_scopes = oauth_params["scope"].split(" ")
|
||||
consent = OidcUserConsent.find_or_initialize_by(user: user, application: application)
|
||||
consent.scopes_granted = requested_scopes.join(' ')
|
||||
consent.scopes_granted = requested_scopes.join(" ")
|
||||
consent.granted_at = Time.current
|
||||
consent.save!
|
||||
|
||||
@@ -256,11 +256,11 @@ class OidcController < ApplicationController
|
||||
auth_code = OidcAuthorizationCode.create!(
|
||||
application: application,
|
||||
user: user,
|
||||
redirect_uri: oauth_params['redirect_uri'],
|
||||
scope: oauth_params['scope'],
|
||||
nonce: oauth_params['nonce'],
|
||||
code_challenge: oauth_params['code_challenge'],
|
||||
code_challenge_method: oauth_params['code_challenge_method'],
|
||||
redirect_uri: oauth_params["redirect_uri"],
|
||||
scope: oauth_params["scope"],
|
||||
nonce: oauth_params["nonce"],
|
||||
code_challenge: oauth_params["code_challenge"],
|
||||
code_challenge_method: oauth_params["code_challenge_method"],
|
||||
auth_time: Current.session.created_at.to_i,
|
||||
acr: Current.session.acr,
|
||||
expires_at: 10.minutes.from_now
|
||||
@@ -270,8 +270,8 @@ class OidcController < ApplicationController
|
||||
session.delete(:oauth_params)
|
||||
|
||||
# Redirect back to client with authorization code (plaintext)
|
||||
redirect_uri = "#{oauth_params['redirect_uri']}?code=#{auth_code.plaintext_code}"
|
||||
redirect_uri += "&state=#{CGI.escape(oauth_params['state'])}" if oauth_params['state']
|
||||
redirect_uri = "#{oauth_params["redirect_uri"]}?code=#{auth_code.plaintext_code}"
|
||||
redirect_uri += "&state=#{CGI.escape(oauth_params["state"])}" if oauth_params["state"]
|
||||
|
||||
redirect_to redirect_uri, allow_other_host: true
|
||||
end
|
||||
@@ -286,7 +286,7 @@ class OidcController < ApplicationController
|
||||
when "refresh_token"
|
||||
handle_refresh_token_grant
|
||||
else
|
||||
render json: { error: "unsupported_grant_type" }, status: :bad_request
|
||||
render json: {error: "unsupported_grant_type"}, status: :bad_request
|
||||
end
|
||||
end
|
||||
|
||||
@@ -295,14 +295,14 @@ class OidcController < ApplicationController
|
||||
client_id, client_secret = extract_client_credentials
|
||||
|
||||
unless client_id
|
||||
render json: { error: "invalid_client", error_description: "client_id is required" }, status: :unauthorized
|
||||
render json: {error: "invalid_client", error_description: "client_id is required"}, status: :unauthorized
|
||||
return
|
||||
end
|
||||
|
||||
# Find the application
|
||||
application = Application.find_by(client_id: client_id)
|
||||
unless application
|
||||
render json: { error: "invalid_client", error_description: "Unknown client" }, status: :unauthorized
|
||||
render json: {error: "invalid_client", error_description: "Unknown client"}, status: :unauthorized
|
||||
return
|
||||
end
|
||||
|
||||
@@ -313,7 +313,7 @@ class OidcController < ApplicationController
|
||||
else
|
||||
# Confidential clients MUST provide valid client_secret
|
||||
unless client_secret.present? && application.authenticate_client_secret(client_secret)
|
||||
render json: { error: "invalid_client", error_description: "Invalid client credentials" }, status: :unauthorized
|
||||
render json: {error: "invalid_client", error_description: "Invalid client credentials"}, status: :unauthorized
|
||||
return
|
||||
end
|
||||
end
|
||||
@@ -321,7 +321,7 @@ class OidcController < ApplicationController
|
||||
# Check if application is active
|
||||
unless application.active?
|
||||
Rails.logger.error "OAuth: Token request for inactive application: #{application.name}"
|
||||
render json: { error: "invalid_client", error_description: "Application is not active" }, status: :forbidden
|
||||
render json: {error: "invalid_client", error_description: "Application is not active"}, status: :forbidden
|
||||
return
|
||||
end
|
||||
|
||||
@@ -334,7 +334,7 @@ class OidcController < ApplicationController
|
||||
auth_code = OidcAuthorizationCode.find_by_plaintext(code)
|
||||
|
||||
unless auth_code && auth_code.application == application
|
||||
render json: { error: "invalid_grant" }, status: :bad_request
|
||||
render json: {error: "invalid_grant"}, status: :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
@@ -365,13 +365,13 @@ class OidcController < ApplicationController
|
||||
|
||||
# Check if code is expired
|
||||
if auth_code.expires_at < Time.current
|
||||
render json: { error: "invalid_grant", error_description: "Authorization code expired" }, status: :bad_request
|
||||
render json: {error: "invalid_grant", error_description: "Authorization code expired"}, status: :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
# Validate redirect URI matches
|
||||
unless auth_code.redirect_uri == redirect_uri
|
||||
render json: { error: "invalid_grant", error_description: "Redirect URI mismatch" }, status: :bad_request
|
||||
render json: {error: "invalid_grant", error_description: "Redirect URI mismatch"}, status: :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
@@ -413,7 +413,7 @@ class OidcController < ApplicationController
|
||||
|
||||
unless consent
|
||||
Rails.logger.error "OIDC Security: Token requested without consent record (user: #{user.id}, app: #{application.id})"
|
||||
render json: { error: "invalid_grant", error_description: "Authorization consent not found" }, status: :bad_request
|
||||
render json: {error: "invalid_grant", error_description: "Authorization consent not found"}, status: :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
@@ -440,7 +440,7 @@ class OidcController < ApplicationController
|
||||
}
|
||||
end
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
render json: { error: "invalid_grant" }, status: :bad_request
|
||||
render json: {error: "invalid_grant"}, status: :bad_request
|
||||
end
|
||||
end
|
||||
|
||||
@@ -449,14 +449,14 @@ class OidcController < ApplicationController
|
||||
client_id, client_secret = extract_client_credentials
|
||||
|
||||
unless client_id
|
||||
render json: { error: "invalid_client", error_description: "client_id is required" }, status: :unauthorized
|
||||
render json: {error: "invalid_client", error_description: "client_id is required"}, status: :unauthorized
|
||||
return
|
||||
end
|
||||
|
||||
# Find the application
|
||||
application = Application.find_by(client_id: client_id)
|
||||
unless application
|
||||
render json: { error: "invalid_client", error_description: "Unknown client" }, status: :unauthorized
|
||||
render json: {error: "invalid_client", error_description: "Unknown client"}, status: :unauthorized
|
||||
return
|
||||
end
|
||||
|
||||
@@ -467,7 +467,7 @@ class OidcController < ApplicationController
|
||||
else
|
||||
# Confidential clients MUST provide valid client_secret
|
||||
unless client_secret.present? && application.authenticate_client_secret(client_secret)
|
||||
render json: { error: "invalid_client", error_description: "Invalid client credentials" }, status: :unauthorized
|
||||
render json: {error: "invalid_client", error_description: "Invalid client credentials"}, status: :unauthorized
|
||||
return
|
||||
end
|
||||
end
|
||||
@@ -475,14 +475,14 @@ class OidcController < ApplicationController
|
||||
# Check if application is active
|
||||
unless application.active?
|
||||
Rails.logger.error "OAuth: Refresh token request for inactive application: #{application.name}"
|
||||
render json: { error: "invalid_client", error_description: "Application is not active" }, status: :forbidden
|
||||
render json: {error: "invalid_client", error_description: "Application is not active"}, status: :forbidden
|
||||
return
|
||||
end
|
||||
|
||||
# Get the refresh token
|
||||
refresh_token = params[:refresh_token]
|
||||
unless refresh_token.present?
|
||||
render json: { error: "invalid_request", error_description: "refresh_token is required" }, status: :bad_request
|
||||
render json: {error: "invalid_request", error_description: "refresh_token is required"}, status: :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
@@ -491,13 +491,13 @@ class OidcController < ApplicationController
|
||||
|
||||
# Verify the token belongs to the correct application
|
||||
unless refresh_token_record && refresh_token_record.application == application
|
||||
render json: { error: "invalid_grant", error_description: "Invalid refresh token" }, status: :bad_request
|
||||
render json: {error: "invalid_grant", error_description: "Invalid refresh token"}, status: :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
# Check if refresh token is expired
|
||||
if refresh_token_record.expired?
|
||||
render json: { error: "invalid_grant", error_description: "Refresh token expired" }, status: :bad_request
|
||||
render json: {error: "invalid_grant", error_description: "Refresh token expired"}, status: :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
@@ -508,7 +508,7 @@ class OidcController < ApplicationController
|
||||
Rails.logger.warn "OAuth Security: Revoked refresh token reuse detected for token family #{refresh_token_record.token_family_id}"
|
||||
refresh_token_record.revoke_family!
|
||||
|
||||
render json: { error: "invalid_grant", error_description: "Refresh token has been revoked" }, status: :bad_request
|
||||
render json: {error: "invalid_grant", error_description: "Refresh token has been revoked"}, status: :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
@@ -541,7 +541,7 @@ class OidcController < ApplicationController
|
||||
|
||||
unless consent
|
||||
Rails.logger.error "OIDC Security: Refresh token used without consent record (user: #{user.id}, app: #{application.id})"
|
||||
render json: { error: "invalid_grant", error_description: "Authorization consent not found" }, status: :bad_request
|
||||
render json: {error: "invalid_grant", error_description: "Authorization consent not found"}, status: :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
@@ -566,7 +566,7 @@ class OidcController < ApplicationController
|
||||
scope: refresh_token_record.scope
|
||||
}
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
render json: { error: "invalid_grant" }, status: :bad_request
|
||||
render json: {error: "invalid_grant"}, status: :bad_request
|
||||
end
|
||||
|
||||
# GET /oauth/userinfo
|
||||
@@ -650,7 +650,7 @@ class OidcController < ApplicationController
|
||||
|
||||
# Find and validate the application
|
||||
application = Application.find_by(client_id: client_id)
|
||||
unless application && application.authenticate_client_secret(client_secret)
|
||||
unless application&.authenticate_client_secret(client_secret)
|
||||
Rails.logger.warn "OAuth: Token revocation attempted for invalid application: #{client_id}"
|
||||
head :ok
|
||||
return
|
||||
@@ -669,7 +669,7 @@ class OidcController < ApplicationController
|
||||
|
||||
unless token.present?
|
||||
# RFC 7009: Missing token parameter is an error
|
||||
render json: { error: "invalid_request", error_description: "token parameter is required" }, status: :bad_request
|
||||
render json: {error: "invalid_request", error_description: "token parameter is required"}, status: :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
@@ -695,7 +695,7 @@ class OidcController < ApplicationController
|
||||
if access_token_record
|
||||
access_token_record.revoke!
|
||||
Rails.logger.info "OAuth: Access token revoked for application #{application.name}"
|
||||
revoked = true
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
@@ -709,7 +709,7 @@ class OidcController < ApplicationController
|
||||
# OpenID Connect RP-Initiated Logout
|
||||
# Handle id_token_hint and post_logout_redirect_uri parameters
|
||||
|
||||
id_token_hint = params[:id_token_hint]
|
||||
params[:id_token_hint]
|
||||
post_logout_redirect_uri = params[:post_logout_redirect_uri]
|
||||
state = params[:state]
|
||||
|
||||
@@ -763,7 +763,7 @@ class OidcController < ApplicationController
|
||||
end
|
||||
|
||||
# Skip validation if no code challenge was stored (legacy clients without PKCE requirement)
|
||||
return { valid: true } unless pkce_provided
|
||||
return {valid: true} unless pkce_provided
|
||||
|
||||
# PKCE was provided during authorization but no verifier sent with token request
|
||||
unless code_verifier.present?
|
||||
@@ -787,18 +787,18 @@ class OidcController < ApplicationController
|
||||
|
||||
# Recreate code challenge based on method
|
||||
expected_challenge = case auth_code.code_challenge_method
|
||||
when "plain"
|
||||
code_verifier
|
||||
when "S256"
|
||||
Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false)
|
||||
else
|
||||
return {
|
||||
valid: false,
|
||||
error: "server_error",
|
||||
error_description: "Unsupported code challenge method",
|
||||
status: :internal_server_error
|
||||
}
|
||||
end
|
||||
when "plain"
|
||||
code_verifier
|
||||
when "S256"
|
||||
Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false)
|
||||
else
|
||||
return {
|
||||
valid: false,
|
||||
error: "server_error",
|
||||
error_description: "Unsupported code challenge method",
|
||||
status: :internal_server_error
|
||||
}
|
||||
end
|
||||
|
||||
# Validate the code challenge
|
||||
unless auth_code.code_challenge == expected_challenge
|
||||
@@ -810,7 +810,7 @@ class OidcController < ApplicationController
|
||||
}
|
||||
end
|
||||
|
||||
{ valid: true }
|
||||
{valid: true}
|
||||
end
|
||||
|
||||
def extract_client_credentials
|
||||
@@ -835,7 +835,7 @@ class OidcController < ApplicationController
|
||||
return nil unless parsed_uri.is_a?(URI::HTTP) || parsed_uri.is_a?(URI::HTTPS)
|
||||
|
||||
# Only allow HTTPS in production
|
||||
return nil if Rails.env.production? && parsed_uri.scheme != 'https'
|
||||
return nil if Rails.env.production? && parsed_uri.scheme != "https"
|
||||
|
||||
# Check if URI matches any registered OIDC application's redirect URIs
|
||||
# According to OIDC spec, post_logout_redirect_uri should be pre-registered
|
||||
|
||||
@@ -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,10 +27,11 @@ 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?
|
||||
rescue ActiveSupport::MessageVerifier::InvalidSignature
|
||||
redirect_to new_password_path, alert: "Password reset link is invalid or has expired."
|
||||
end
|
||||
|
||||
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?
|
||||
rescue ActiveSupport::MessageVerifier::InvalidSignature
|
||||
redirect_to new_password_path, alert: "Password reset link is invalid or has expired."
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,7 +4,7 @@ class WebauthnController < ApplicationController
|
||||
|
||||
# Rate limit check endpoint to prevent enumeration attacks
|
||||
rate_limit to: 10, within: 1.minute, only: [:check], with: -> {
|
||||
render json: { error: "Too many requests. Try again later." }, status: :too_many_requests
|
||||
render json: {error: "Too many requests. Try again later."}, status: :too_many_requests
|
||||
}
|
||||
|
||||
# GET /webauthn/new
|
||||
@@ -16,7 +16,7 @@ class WebauthnController < ApplicationController
|
||||
# Generate registration challenge for creating a new passkey
|
||||
def challenge
|
||||
user = Current.session&.user
|
||||
return render json: { error: "Not authenticated" }, status: :unauthorized unless user
|
||||
return render json: {error: "Not authenticated"}, status: :unauthorized unless user
|
||||
|
||||
registration_options = WebAuthn::Credential.options_for_create(
|
||||
user: {
|
||||
@@ -44,7 +44,7 @@ class WebauthnController < ApplicationController
|
||||
credential_data, nickname = extract_credential_params
|
||||
|
||||
if credential_data.blank? || nickname.blank?
|
||||
render json: { error: "Credential and nickname are required" }, status: :unprocessable_entity
|
||||
render json: {error: "Credential and nickname are required"}, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
@@ -52,7 +52,7 @@ class WebauthnController < ApplicationController
|
||||
challenge = session.delete(:webauthn_challenge)
|
||||
|
||||
if challenge.blank?
|
||||
render json: { error: "Invalid or expired session" }, status: :unprocessable_entity
|
||||
render json: {error: "Invalid or expired session"}, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
@@ -68,10 +68,10 @@ class WebauthnController < ApplicationController
|
||||
client_extension_results = response["clientExtensionResults"] || {}
|
||||
|
||||
authenticator_type = if response["response"]["authenticatorAttachment"] == "cross-platform"
|
||||
"cross-platform"
|
||||
else
|
||||
"platform"
|
||||
end
|
||||
"cross-platform"
|
||||
else
|
||||
"platform"
|
||||
end
|
||||
|
||||
# Determine if this is a backup/synced credential
|
||||
backup_eligible = client_extension_results["credProps"]&.dig("rk") || false
|
||||
@@ -79,7 +79,7 @@ class WebauthnController < ApplicationController
|
||||
|
||||
# Store the credential
|
||||
user = Current.session&.user
|
||||
return render json: { error: "Not authenticated" }, status: :unauthorized unless user
|
||||
return render json: {error: "Not authenticated"}, status: :unauthorized unless user
|
||||
|
||||
@webauthn_credential = user.webauthn_credentials.create!(
|
||||
external_id: Base64.urlsafe_encode64(webauthn_credential.id),
|
||||
@@ -96,13 +96,12 @@ class WebauthnController < ApplicationController
|
||||
message: "Passkey '#{nickname}' registered successfully",
|
||||
credential_id: @webauthn_credential.id
|
||||
}
|
||||
|
||||
rescue WebAuthn::Error => e
|
||||
Rails.logger.error "WebAuthn registration error: #{e.message}"
|
||||
render json: { error: "Failed to register passkey: #{e.message}" }, status: :unprocessable_entity
|
||||
render json: {error: "Failed to register passkey: #{e.message}"}, status: :unprocessable_entity
|
||||
rescue => e
|
||||
Rails.logger.error "Unexpected WebAuthn registration error: #{e.class} - #{e.message}"
|
||||
render json: { error: "An unexpected error occurred" }, status: :internal_server_error
|
||||
render json: {error: "An unexpected error occurred"}, status: :internal_server_error
|
||||
end
|
||||
end
|
||||
|
||||
@@ -115,7 +114,7 @@ class WebauthnController < ApplicationController
|
||||
respond_to do |format|
|
||||
format.html {
|
||||
redirect_to profile_path,
|
||||
notice: "Passkey '#{nickname}' has been removed"
|
||||
notice: "Passkey '#{nickname}' has been removed"
|
||||
}
|
||||
format.json {
|
||||
render json: {
|
||||
@@ -133,7 +132,7 @@ class WebauthnController < ApplicationController
|
||||
email = params[:email]&.strip&.downcase
|
||||
|
||||
if email.blank?
|
||||
render json: { has_webauthn: false, requires_webauthn: false }
|
||||
render json: {has_webauthn: false, requires_webauthn: false}
|
||||
return
|
||||
end
|
||||
|
||||
@@ -142,7 +141,7 @@ class WebauthnController < ApplicationController
|
||||
# Security: Return identical response for non-existent users
|
||||
# Combined with rate limiting (10/min), this prevents account enumeration
|
||||
if user.nil?
|
||||
render json: { has_webauthn: false, requires_webauthn: false }
|
||||
render json: {has_webauthn: false, requires_webauthn: false}
|
||||
return
|
||||
end
|
||||
|
||||
@@ -158,37 +157,36 @@ class WebauthnController < ApplicationController
|
||||
def extract_credential_params
|
||||
# Use require.permit which is working and reliable
|
||||
# The JavaScript sends params both directly and wrapped in webauthn key
|
||||
begin
|
||||
# Try direct parameters first
|
||||
credential_params = params.require(:credential).permit(:id, :rawId, :type, response: {}, clientExtensionResults: {})
|
||||
nickname = params.require(:nickname)
|
||||
[credential_params, nickname]
|
||||
rescue ActionController::ParameterMissing
|
||||
Rails.logger.error("Using the fallback parameters")
|
||||
# Fallback to webauthn-wrapped parameters
|
||||
webauthn_params = params.require(:webauthn).permit(:nickname, credential: [:id, :rawId, :type, response: {}, clientExtensionResults: {}])
|
||||
[webauthn_params[:credential], webauthn_params[:nickname]]
|
||||
end
|
||||
|
||||
# Try direct parameters first
|
||||
credential_params = params.require(:credential).permit(:id, :rawId, :type, response: {}, clientExtensionResults: {})
|
||||
nickname = params.require(:nickname)
|
||||
[credential_params, nickname]
|
||||
rescue ActionController::ParameterMissing
|
||||
Rails.logger.error("Using the fallback parameters")
|
||||
# Fallback to webauthn-wrapped parameters
|
||||
webauthn_params = params.require(:webauthn).permit(:nickname, credential: [:id, :rawId, :type, response: {}, clientExtensionResults: {}])
|
||||
[webauthn_params[:credential], webauthn_params[:nickname]]
|
||||
end
|
||||
|
||||
def set_webauthn_credential
|
||||
user = Current.session&.user
|
||||
return render json: { error: "Not authenticated" }, status: :unauthorized unless user
|
||||
return render json: {error: "Not authenticated"}, status: :unauthorized unless user
|
||||
@webauthn_credential = user.webauthn_credentials.find(params[:id])
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
respond_to do |format|
|
||||
format.html { redirect_to profile_path, alert: "Passkey not found" }
|
||||
format.json { render json: { error: "Passkey not found" }, status: :not_found }
|
||||
format.json { render json: {error: "Passkey not found"}, status: :not_found }
|
||||
end
|
||||
end
|
||||
|
||||
# Helper method to convert Base64 to Base64URL if needed
|
||||
def base64_to_base64url(str)
|
||||
str.gsub('+', '-').gsub('/', '_').gsub(/=+$/, '')
|
||||
str.tr("+", "-").tr("/", "_").gsub(/=+$/, "")
|
||||
end
|
||||
|
||||
# Helper method to convert Base64URL to Base64 if needed
|
||||
def base64url_to_base64(str)
|
||||
str.gsub('-', '+').gsub('_', '/') + '=' * (4 - str.length % 4) % 4
|
||||
str.tr("-", "+").tr("_", "/") + "=" * (4 - str.length % 4) % 4
|
||||
end
|
||||
end
|
||||
@@ -6,10 +6,10 @@ module ApplicationHelper
|
||||
smtp_port = ENV["SMTP_PORT"]
|
||||
|
||||
smtp_address.present? &&
|
||||
smtp_port.present? &&
|
||||
smtp_address != "localhost" &&
|
||||
!smtp_address.start_with?("127.0.0.1") &&
|
||||
!smtp_address.start_with?("localhost")
|
||||
smtp_port.present? &&
|
||||
smtp_address != "localhost" &&
|
||||
!smtp_address.start_with?("127.0.0.1") &&
|
||||
!smtp_address.start_with?("localhost")
|
||||
end
|
||||
|
||||
def email_delivery_method
|
||||
@@ -22,11 +22,11 @@ module ApplicationHelper
|
||||
|
||||
def border_class_for(type)
|
||||
case type.to_s
|
||||
when 'notice' then 'border-green-200'
|
||||
when 'alert', 'error' then 'border-red-200'
|
||||
when 'warning' then 'border-yellow-200'
|
||||
when 'info' then 'border-blue-200'
|
||||
else 'border-gray-200'
|
||||
when "notice" then "border-green-200"
|
||||
when "alert", "error" then "border-red-200"
|
||||
when "warning" then "border-yellow-200"
|
||||
when "info" then "border-blue-200"
|
||||
else "border-gray-200"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -19,16 +19,16 @@ class Application < ApplicationRecord
|
||||
has_many :oidc_user_consents, dependent: :destroy
|
||||
|
||||
validates :name, presence: true
|
||||
validates :slug, presence: true, uniqueness: { case_sensitive: false },
|
||||
format: { with: /\A[a-z0-9\-]+\z/, message: "only lowercase letters, numbers, and hyphens" }
|
||||
validates :slug, presence: true, uniqueness: {case_sensitive: false},
|
||||
format: {with: /\A[a-z0-9-]+\z/, message: "only lowercase letters, numbers, and hyphens"}
|
||||
validates :app_type, presence: true,
|
||||
inclusion: { in: %w[oidc forward_auth] }
|
||||
validates :client_id, uniqueness: { allow_nil: true }
|
||||
inclusion: {in: %w[oidc forward_auth]}
|
||||
validates :client_id, uniqueness: {allow_nil: true}
|
||||
validates :client_secret, presence: true, on: :create, if: -> { oidc? && confidential_client? }
|
||||
validates :domain_pattern, presence: true, uniqueness: { case_sensitive: false }, if: :forward_auth?
|
||||
validates :landing_url, format: { with: URI::regexp(%w[http https]), allow_nil: true, message: "must be a valid URL" }
|
||||
validates :domain_pattern, presence: true, uniqueness: {case_sensitive: false}, if: :forward_auth?
|
||||
validates :landing_url, format: {with: URI::RFC2396_PARSER.make_regexp(%w[http https]), allow_nil: true, message: "must be a valid URL"}
|
||||
validates :backchannel_logout_uri, format: {
|
||||
with: URI::regexp(%w[http https]),
|
||||
with: URI::RFC2396_PARSER.make_regexp(%w[http https]),
|
||||
allow_nil: true,
|
||||
message: "must be a valid HTTP or HTTPS URL"
|
||||
}
|
||||
@@ -38,9 +38,9 @@ class Application < ApplicationRecord
|
||||
validate :icon_validation, if: -> { icon.attached? }
|
||||
|
||||
# Token TTL validations (for OIDC apps)
|
||||
validates :access_token_ttl, numericality: { greater_than_or_equal_to: 300, less_than_or_equal_to: 86400 }, if: :oidc? # 5 min - 24 hours
|
||||
validates :refresh_token_ttl, numericality: { greater_than_or_equal_to: 86400, less_than_or_equal_to: 7776000 }, if: :oidc? # 1 day - 90 days
|
||||
validates :id_token_ttl, numericality: { greater_than_or_equal_to: 300, less_than_or_equal_to: 86400 }, if: :oidc? # 5 min - 24 hours
|
||||
validates :access_token_ttl, numericality: {greater_than_or_equal_to: 300, less_than_or_equal_to: 86400}, if: :oidc? # 5 min - 24 hours
|
||||
validates :refresh_token_ttl, numericality: {greater_than_or_equal_to: 86400, less_than_or_equal_to: 7776000}, if: :oidc? # 1 day - 90 days
|
||||
validates :id_token_ttl, numericality: {greater_than_or_equal_to: 300, less_than_or_equal_to: 86400}, if: :oidc? # 5 min - 24 hours
|
||||
|
||||
normalizes :slug, with: ->(slug) { slug.strip.downcase }
|
||||
normalizes :domain_pattern, with: ->(pattern) {
|
||||
@@ -56,11 +56,11 @@ class Application < ApplicationRecord
|
||||
|
||||
# Default header configuration for ForwardAuth
|
||||
DEFAULT_HEADERS = {
|
||||
user: 'X-Remote-User',
|
||||
email: 'X-Remote-Email',
|
||||
name: 'X-Remote-Name',
|
||||
groups: 'X-Remote-Groups',
|
||||
admin: 'X-Remote-Admin'
|
||||
user: "X-Remote-User",
|
||||
email: "X-Remote-Email",
|
||||
name: "X-Remote-Name",
|
||||
groups: "X-Remote-Groups",
|
||||
admin: "X-Remote-Admin"
|
||||
}.freeze
|
||||
|
||||
# Scopes
|
||||
@@ -135,8 +135,8 @@ class Application < ApplicationRecord
|
||||
def matches_domain?(domain)
|
||||
return false if domain.blank? || !forward_auth?
|
||||
|
||||
pattern = domain_pattern.gsub('.', '\.')
|
||||
pattern = pattern.gsub('*', '[^.]*')
|
||||
pattern = domain_pattern.gsub(".", '\.')
|
||||
pattern = pattern.gsub("*", "[^.]*")
|
||||
|
||||
regex = Regexp.new("^#{pattern}$", Regexp::IGNORECASE)
|
||||
regex.match?(domain.downcase)
|
||||
@@ -144,18 +144,18 @@ class Application < ApplicationRecord
|
||||
|
||||
# Policy determination based on user status (for ForwardAuth)
|
||||
def policy_for_user(user)
|
||||
return 'deny' unless active?
|
||||
return 'deny' unless user.active?
|
||||
return "deny" unless active?
|
||||
return "deny" unless user.active?
|
||||
|
||||
# If no groups specified, bypass authentication
|
||||
return 'bypass' if allowed_groups.empty?
|
||||
return "bypass" if allowed_groups.empty?
|
||||
|
||||
# If user is in allowed groups, determine auth level
|
||||
if user_allowed?(user)
|
||||
# Require 2FA if user has TOTP configured, otherwise one factor
|
||||
user.totp_enabled? ? 'two_factor' : 'one_factor'
|
||||
user.totp_enabled? ? "two_factor" : "one_factor"
|
||||
else
|
||||
'deny'
|
||||
"deny"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -197,7 +197,7 @@ class Application < ApplicationRecord
|
||||
def generate_new_client_secret!
|
||||
secret = SecureRandom.urlsafe_base64(48)
|
||||
self.client_secret = secret
|
||||
self.save!
|
||||
save!
|
||||
secret
|
||||
end
|
||||
|
||||
@@ -242,7 +242,7 @@ class Application < ApplicationRecord
|
||||
# (i.e., has valid, non-revoked tokens)
|
||||
def user_has_active_session?(user)
|
||||
oidc_access_tokens.where(user: user).valid.exists? ||
|
||||
oidc_refresh_tokens.where(user: user).valid.exists?
|
||||
oidc_refresh_tokens.where(user: user).valid.exists?
|
||||
end
|
||||
|
||||
private
|
||||
@@ -260,14 +260,14 @@ class Application < ApplicationRecord
|
||||
return unless icon.attached?
|
||||
|
||||
# Check content type
|
||||
allowed_types = ['image/png', 'image/jpg', 'image/jpeg', 'image/gif', 'image/svg+xml']
|
||||
allowed_types = ["image/png", "image/jpg", "image/jpeg", "image/gif", "image/svg+xml"]
|
||||
unless allowed_types.include?(icon.content_type)
|
||||
errors.add(:icon, 'must be a PNG, JPG, GIF, or SVG image')
|
||||
errors.add(:icon, "must be a PNG, JPG, GIF, or SVG image")
|
||||
end
|
||||
|
||||
# Check file size (2MB limit)
|
||||
if icon.blob.byte_size > 2.megabytes
|
||||
errors.add(:icon, 'must be less than 2MB')
|
||||
errors.add(:icon, "must be less than 2MB")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -302,8 +302,8 @@ class Application < ApplicationRecord
|
||||
|
||||
begin
|
||||
uri = URI.parse(backchannel_logout_uri)
|
||||
unless uri.scheme == 'https'
|
||||
errors.add(:backchannel_logout_uri, 'must use HTTPS in production')
|
||||
unless uri.scheme == "https"
|
||||
errors.add(:backchannel_logout_uri, "must use HTTPS in production")
|
||||
end
|
||||
rescue URI::InvalidURIError
|
||||
# Let the format validator handle invalid URIs
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,6 +2,4 @@
|
||||
require "rubygems"
|
||||
require "bundler/setup"
|
||||
|
||||
ARGV.unshift("--ensure-latest")
|
||||
|
||||
load Gem.bin_path("brakeman", "brakeman")
|
||||
|
||||
@@ -24,16 +24,16 @@ module Clinch
|
||||
# config.time_zone = "Central Time (US & Canada)"
|
||||
# config.eager_load_paths << Rails.root.join("extras")
|
||||
|
||||
# Configure SMTP settings using environment variables
|
||||
# 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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -56,10 +56,9 @@ Rails.application.configure do
|
||||
policy.require_trusted_types_for :none
|
||||
|
||||
# CSP reporting using report_uri (supported method)
|
||||
policy.report_uri "/api/csp-violation-report"
|
||||
policy.report_uri "/api/csp-violation-report"
|
||||
end
|
||||
|
||||
|
||||
# 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?
|
||||
|
||||
@@ -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
|
||||
@@ -25,9 +25,9 @@ Rails.application.config.after_initialize do
|
||||
|
||||
# Skip logging if there's no meaningful violation data
|
||||
return if csp_data.empty? ||
|
||||
(csp_data[:violated_directive].nil? &&
|
||||
csp_data[:blocked_uri].nil? &&
|
||||
csp_data[:document_uri].nil?)
|
||||
(csp_data[:violated_directive].nil? &&
|
||||
csp_data[:blocked_uri].nil? &&
|
||||
csp_data[:document_uri].nil?)
|
||||
|
||||
# Build a structured log message
|
||||
violated_directive = csp_data[:violated_directive] || "unknown"
|
||||
@@ -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)"
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
|
||||
Rails.application.config.permissions_policy do |f|
|
||||
# Disable sensitive browser features for security
|
||||
f.camera :none
|
||||
f.gyroscope :none
|
||||
f.microphone :none
|
||||
f.payment :none
|
||||
f.usb :none
|
||||
f.magnetometer :none
|
||||
f.camera :none
|
||||
f.gyroscope :none
|
||||
f.microphone :none
|
||||
f.payment :none
|
||||
f.usb :none
|
||||
f.magnetometer :none
|
||||
|
||||
# You can enable specific features as needed:
|
||||
# f.fullscreen :self
|
||||
|
||||
@@ -74,7 +74,7 @@ Rails.application.configure do
|
||||
app_environment: Rails.env,
|
||||
# Add CSP policy status
|
||||
csp_enabled: defined?(Rails.application.config.content_security_policy) &&
|
||||
Rails.application.config.content_security_policy.present?
|
||||
Rails.application.config.content_security_policy.present?
|
||||
}
|
||||
end
|
||||
|
||||
@@ -120,13 +120,13 @@ Rails.application.configure do
|
||||
if breadcrumb[:data]
|
||||
breadcrumb[:data].reject! { |key, value|
|
||||
key.to_s.match?(/password|secret|token|key|authorization/i) ||
|
||||
value.to_s.match?(/password|secret/i)
|
||||
value.to_s.match?(/password|secret/i)
|
||||
}
|
||||
end
|
||||
|
||||
# Mark CSP-related events
|
||||
if breadcrumb[:message]&.include?("CSP Violation") ||
|
||||
breadcrumb[:category]&.include?("csp")
|
||||
breadcrumb[:category]&.include?("csp")
|
||||
breadcrumb[:data] ||= {}
|
||||
breadcrumb[:data][:security_event] = true
|
||||
breadcrumb[:data][:csp_violation] = true
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,13 +5,13 @@ class CreateActiveStorageTables < ActiveRecord::Migration[7.0]
|
||||
primary_key_type, foreign_key_type = primary_and_foreign_key_types
|
||||
|
||||
create_table :active_storage_blobs, id: primary_key_type do |t|
|
||||
t.string :key, null: false
|
||||
t.string :filename, null: false
|
||||
t.string :content_type
|
||||
t.text :metadata
|
||||
t.string :service_name, null: false
|
||||
t.bigint :byte_size, null: false
|
||||
t.string :checksum
|
||||
t.string :key, null: false
|
||||
t.string :filename, null: false
|
||||
t.string :content_type
|
||||
t.text :metadata
|
||||
t.string :service_name, null: false
|
||||
t.bigint :byte_size, null: false
|
||||
t.string :checksum
|
||||
|
||||
if connection.supports_datetime_with_precision?
|
||||
t.datetime :created_at, precision: 6, null: false
|
||||
@@ -19,13 +19,13 @@ 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|
|
||||
t.string :name, null: false
|
||||
t.references :record, null: false, polymorphic: true, index: false, type: foreign_key_type
|
||||
t.references :blob, null: false, type: foreign_key_type
|
||||
t.string :name, null: false
|
||||
t.references :record, null: false, polymorphic: true, index: false, type: foreign_key_type
|
||||
t.references :blob, null: false, type: foreign_key_type
|
||||
|
||||
if connection.supports_datetime_with_precision?
|
||||
t.datetime :created_at, precision: 6, null: false
|
||||
@@ -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 ]
|
||||
end
|
||||
|
||||
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]
|
||||
end
|
||||
end
|
||||
|
||||
275
docs/README_RODAUTH_ANALYSIS.md
Normal file
275
docs/README_RODAUTH_ANALYSIS.md
Normal 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!**
|
||||
426
docs/RODAUTH_DECISION_GUIDE.md
Normal file
426
docs/RODAUTH_DECISION_GUIDE.md
Normal 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
176
docs/caddy-example.md
Normal 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
|
||||
```
|
||||
227
docs/forward-auth-testing.md
Normal file
227
docs/forward-auth-testing.md
Normal 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
|
||||
611
docs/oidc-refresh-tokens-client-guide.md
Normal file
611
docs/oidc-refresh-tokens-client-guide.md
Normal 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
|
||||
913
docs/rodauth-oauth-analysis.md
Normal file
913
docs/rodauth-oauth-analysis.md
Normal 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 |
|
||||
|
||||
418
docs/rodauth-oauth-quick-reference.md
Normal file
418
docs/rodauth-oauth-quick-reference.md
Normal 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
330
docs/traefik-example.md
Normal 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!
|
||||
238
docs/webauthn-implementation-summary.md
Normal file
238
docs/webauthn-implementation-summary.md
Normal 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*
|
||||
787
docs/webauthn-passkeys-plan.md
Normal file
787
docs/webauthn-passkeys-plan.md
Normal 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*
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
# ====================
|
||||
|
||||
@@ -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,7 +61,8 @@ class PasswordsControllerTest < ActionDispatch::IntegrationTest
|
||||
end
|
||||
|
||||
private
|
||||
def assert_notice(text)
|
||||
assert_select "div", /#{text}/
|
||||
end
|
||||
|
||||
def assert_notice(text)
|
||||
assert_select "div", /#{text}/
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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!
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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"
|
||||
@@ -164,7 +164,7 @@ class OidcAccessTokenTest < ActiveSupport::TestCase
|
||||
)
|
||||
|
||||
assert access_token.plaintext_token.length > auth_code.plaintext_code.length,
|
||||
"Access tokens should be longer than authorization codes"
|
||||
"Access tokens should be longer than authorization codes"
|
||||
end
|
||||
|
||||
test "should have appropriate expiry times" do
|
||||
@@ -181,7 +181,7 @@ class OidcAccessTokenTest < ActiveSupport::TestCase
|
||||
|
||||
# Authorization codes expire in 10 minutes, access tokens in 1 hour
|
||||
assert access_token.expires_at > auth_code.expires_at,
|
||||
"Access tokens should have longer expiry than authorization codes"
|
||||
"Access tokens should have longer expiry than authorization codes"
|
||||
end
|
||||
|
||||
test "revoked tokens should not appear in valid scope" do
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -218,7 +218,7 @@ class OidcUserConsentTest < ActiveSupport::TestCase
|
||||
|
||||
# Application requests more than granted
|
||||
assert_not @consent.covers_scopes?(["openid", "profile", "groups"]),
|
||||
"Should not cover scopes not granted"
|
||||
"Should not cover scopes not granted"
|
||||
|
||||
# Application requests subset
|
||||
assert @consent.covers_scopes?(["email"]), "Should cover subset of granted scopes"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,19 +275,19 @@ 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
|
||||
if app[:headers_config][:user].present?
|
||||
assert_equal app[:headers_config][:user],
|
||||
response.headers.keys.find { |k| k.include?("USER") },
|
||||
"Wrong user header for #{app[:domain]}"
|
||||
response.headers.keys.find { |k| k.include?("USER") },
|
||||
"Wrong user header for #{app[:domain]}"
|
||||
assert_equal @user.email_address, response.headers[app[:headers_config][:user]]
|
||||
else
|
||||
# Should have no auth headers
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user