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:
|
lint:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
|
||||||
RUBOCOP_CACHE_ROOT: tmp/rubocop
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v5
|
||||||
@@ -52,18 +50,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
bundler-cache: true
|
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
|
- name: Lint code for consistent style
|
||||||
run: bin/rubocop -f github
|
run: bin/standardrb
|
||||||
|
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
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"
|
gem "sentry-rails", "~> 6.2"
|
||||||
|
|
||||||
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
|
# 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
|
# Use the database-backed adapters for Rails.cache and Action Cable
|
||||||
gem "solid_cache"
|
gem "solid_cache"
|
||||||
@@ -63,7 +63,7 @@ gem "image_processing", "~> 1.2"
|
|||||||
|
|
||||||
group :development, :test do
|
group :development, :test do
|
||||||
# See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
|
# 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)
|
# Audits gems for known security defects (use config/bundler-audit.yml to ignore issues)
|
||||||
gem "bundler-audit", require: false
|
gem "bundler-audit", require: false
|
||||||
@@ -71,8 +71,8 @@ group :development, :test do
|
|||||||
# Static analysis for security vulnerabilities [https://brakemanscanner.org/]
|
# Static analysis for security vulnerabilities [https://brakemanscanner.org/]
|
||||||
gem "brakeman", require: false
|
gem "brakeman", require: false
|
||||||
|
|
||||||
# Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/]
|
# Standard Ruby style guide, linter, and formatter [https://github.com/standardrb/standard]
|
||||||
gem "rubocop-rails-omakase", require: false
|
gem "standard", require: false
|
||||||
end
|
end
|
||||||
|
|
||||||
group :development do
|
group :development do
|
||||||
@@ -91,4 +91,3 @@ group :test do
|
|||||||
# Code coverage analysis
|
# Code coverage analysis
|
||||||
gem "simplecov", require: false
|
gem "simplecov", require: false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
24
Gemfile.lock
24
Gemfile.lock
@@ -316,16 +316,6 @@ GEM
|
|||||||
lint_roller (~> 1.1)
|
lint_roller (~> 1.1)
|
||||||
rubocop (>= 1.75.0, < 2.0)
|
rubocop (>= 1.75.0, < 2.0)
|
||||||
rubocop-ast (>= 1.47.1, < 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-progressbar (1.13.0)
|
||||||
ruby-vips (2.2.5)
|
ruby-vips (2.2.5)
|
||||||
ffi (~> 1.12)
|
ffi (~> 1.12)
|
||||||
@@ -382,6 +372,18 @@ GEM
|
|||||||
net-sftp (>= 2.1.2)
|
net-sftp (>= 2.1.2)
|
||||||
net-ssh (>= 2.8.0)
|
net-ssh (>= 2.8.0)
|
||||||
ostruct
|
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)
|
stimulus-rails (1.3.4)
|
||||||
railties (>= 6.0.0)
|
railties (>= 6.0.0)
|
||||||
stringio (3.1.8)
|
stringio (3.1.8)
|
||||||
@@ -467,7 +469,6 @@ DEPENDENCIES
|
|||||||
rails (~> 8.1.1)
|
rails (~> 8.1.1)
|
||||||
rotp (~> 6.3)
|
rotp (~> 6.3)
|
||||||
rqrcode (~> 3.1)
|
rqrcode (~> 3.1)
|
||||||
rubocop-rails-omakase
|
|
||||||
selenium-webdriver
|
selenium-webdriver
|
||||||
sentry-rails (~> 6.2)
|
sentry-rails (~> 6.2)
|
||||||
sentry-ruby (~> 6.2)
|
sentry-ruby (~> 6.2)
|
||||||
@@ -476,6 +477,7 @@ DEPENDENCIES
|
|||||||
solid_cache
|
solid_cache
|
||||||
solid_queue (~> 1.2)
|
solid_queue (~> 1.2)
|
||||||
sqlite3 (>= 2.1)
|
sqlite3 (>= 2.1)
|
||||||
|
standard
|
||||||
stimulus-rails
|
stimulus-rails
|
||||||
tailwindcss-rails
|
tailwindcss-rails
|
||||||
thruster
|
thruster
|
||||||
|
|||||||
@@ -7,10 +7,11 @@ module ApplicationCable
|
|||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
def set_current_user
|
|
||||||
if session = Session.find_by(id: cookies.signed[:session_id])
|
def set_current_user
|
||||||
self.current_user = session.user
|
if (session = Session.find_by(id: cookies.signed[:session_id]))
|
||||||
end
|
self.current_user = session.user
|
||||||
end
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -29,9 +29,9 @@ class ActiveSessionsController < ApplicationController
|
|||||||
# Revoke all tokens for this user-application pair
|
# Revoke all tokens for this user-application pair
|
||||||
now = Time.current
|
now = Time.current
|
||||||
revoked_access_tokens = OidcAccessToken.where(application: application, user: @user, revoked_at: nil)
|
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)
|
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}"
|
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
|
# Revoke all tokens for this user-application pair
|
||||||
now = Time.current
|
now = Time.current
|
||||||
revoked_access_tokens = OidcAccessToken.where(application: application, user: @user, revoked_at: nil)
|
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)
|
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"
|
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!
|
client_secret = @application.generate_new_client_secret!
|
||||||
end
|
end
|
||||||
|
|
||||||
|
flash[:notice] = "Application created successfully."
|
||||||
if @application.oidc?
|
if @application.oidc?
|
||||||
flash[:notice] = "Application created successfully."
|
|
||||||
flash[:client_id] = @application.client_id
|
flash[:client_id] = @application.client_id
|
||||||
flash[:client_secret] = client_secret if client_secret
|
flash[:client_secret] = client_secret if client_secret
|
||||||
flash[:public_client] = true if @application.public_client?
|
flash[:public_client] = true if @application.public_client?
|
||||||
else
|
|
||||||
flash[:notice] = "Application created successfully."
|
|
||||||
end
|
end
|
||||||
|
|
||||||
redirect_to admin_application_path(@application)
|
redirect_to admin_application_path(@application)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ module Api
|
|||||||
def violation_report
|
def violation_report
|
||||||
# Parse CSP violation report
|
# Parse CSP violation report
|
||||||
report_data = JSON.parse(request.body.read)
|
report_data = JSON.parse(request.body.read)
|
||||||
csp_report = report_data['csp-report']
|
csp_report = report_data["csp-report"]
|
||||||
|
|
||||||
# Validate that we have a proper CSP report
|
# Validate that we have a proper CSP report
|
||||||
unless csp_report.is_a?(Hash) && csp_report.present?
|
unless csp_report.is_a?(Hash) && csp_report.present?
|
||||||
@@ -19,28 +19,28 @@ module Api
|
|||||||
|
|
||||||
# Log the violation for security monitoring
|
# Log the violation for security monitoring
|
||||||
Rails.logger.warn "CSP Violation Report:"
|
Rails.logger.warn "CSP Violation Report:"
|
||||||
Rails.logger.warn " Blocked URI: #{csp_report['blocked-uri']}"
|
Rails.logger.warn " Blocked URI: #{csp_report["blocked-uri"]}"
|
||||||
Rails.logger.warn " Document URI: #{csp_report['document-uri']}"
|
Rails.logger.warn " Document URI: #{csp_report["document-uri"]}"
|
||||||
Rails.logger.warn " Referrer: #{csp_report['referrer']}"
|
Rails.logger.warn " Referrer: #{csp_report["referrer"]}"
|
||||||
Rails.logger.warn " Violated Directive: #{csp_report['violated-directive']}"
|
Rails.logger.warn " Violated Directive: #{csp_report["violated-directive"]}"
|
||||||
Rails.logger.warn " Original Policy: #{csp_report['original-policy']}"
|
Rails.logger.warn " Original Policy: #{csp_report["original-policy"]}"
|
||||||
Rails.logger.warn " User Agent: #{request.user_agent}"
|
Rails.logger.warn " User Agent: #{request.user_agent}"
|
||||||
Rails.logger.warn " IP Address: #{request.remote_ip}"
|
Rails.logger.warn " IP Address: #{request.remote_ip}"
|
||||||
|
|
||||||
# Emit structured event for CSP violation
|
# Emit structured event for CSP violation
|
||||||
# This allows multiple subscribers to process the event (Sentry, local logging, etc.)
|
# This allows multiple subscribers to process the event (Sentry, local logging, etc.)
|
||||||
Rails.event.notify("csp.violation", {
|
Rails.event.notify("csp.violation", {
|
||||||
blocked_uri: csp_report['blocked-uri'],
|
blocked_uri: csp_report["blocked-uri"],
|
||||||
document_uri: csp_report['document-uri'],
|
document_uri: csp_report["document-uri"],
|
||||||
referrer: csp_report['referrer'],
|
referrer: csp_report["referrer"],
|
||||||
violated_directive: csp_report['violated-directive'],
|
violated_directive: csp_report["violated-directive"],
|
||||||
original_policy: csp_report['original-policy'],
|
original_policy: csp_report["original-policy"],
|
||||||
disposition: csp_report['disposition'],
|
disposition: csp_report["disposition"],
|
||||||
effective_directive: csp_report['effective-directive'],
|
effective_directive: csp_report["effective-directive"],
|
||||||
source_file: csp_report['source-file'],
|
source_file: csp_report["source-file"],
|
||||||
line_number: csp_report['line-number'],
|
line_number: csp_report["line-number"],
|
||||||
column_number: csp_report['column-number'],
|
column_number: csp_report["column-number"],
|
||||||
status_code: csp_report['status-code'],
|
status_code: csp_report["status-code"],
|
||||||
user_agent: request.user_agent,
|
user_agent: request.user_agent,
|
||||||
ip_address: request.remote_ip,
|
ip_address: request.remote_ip,
|
||||||
current_user_id: Current.user&.id,
|
current_user_id: Current.user&.id,
|
||||||
|
|||||||
@@ -81,22 +81,26 @@ module Api
|
|||||||
|
|
||||||
# User is authenticated and authorized
|
# User is authenticated and authorized
|
||||||
# Return 200 with user information headers using app-specific configuration
|
# Return 200 with user information headers using app-specific configuration
|
||||||
headers = app ? app.headers_for_user(user) : Application::DEFAULT_HEADERS.map { |key, header_name|
|
headers = if app
|
||||||
case key
|
app.headers_for_user(user)
|
||||||
when :user, :email, :name
|
else
|
||||||
[header_name, user.email_address]
|
Application::DEFAULT_HEADERS.map { |key, header_name|
|
||||||
when :groups
|
case key
|
||||||
user.groups.any? ? [header_name, user.groups.pluck(:name).join(",")] : nil
|
when :user, :email, :name
|
||||||
when :admin
|
[header_name, user.email_address]
|
||||||
[header_name, user.admin? ? "true" : "false"]
|
when :groups
|
||||||
end
|
user.groups.any? ? [header_name, user.groups.pluck(:name).join(",")] : nil
|
||||||
}.compact.to_h
|
when :admin
|
||||||
|
[header_name, user.admin? ? "true" : "false"]
|
||||||
|
end
|
||||||
|
}.compact.to_h
|
||||||
|
end
|
||||||
|
|
||||||
headers.each { |key, value| response.headers[key] = value }
|
headers.each { |key, value| response.headers[key] = value }
|
||||||
|
|
||||||
# Log what headers we're sending (helpful for debugging)
|
# Log what headers we're sending (helpful for debugging)
|
||||||
if headers.any?
|
if headers.any?
|
||||||
Rails.logger.debug "ForwardAuth: Headers sent: #{headers.keys.join(', ')}"
|
Rails.logger.debug "ForwardAuth: Headers sent: #{headers.keys.join(", ")}"
|
||||||
else
|
else
|
||||||
Rails.logger.debug "ForwardAuth: No headers sent (access only)"
|
Rails.logger.debug "ForwardAuth: No headers sent (access only)"
|
||||||
end
|
end
|
||||||
@@ -123,14 +127,13 @@ module Api
|
|||||||
# Delete the token immediately (one-time use)
|
# Delete the token immediately (one-time use)
|
||||||
Rails.cache.delete("forward_auth_token:#{token}")
|
Rails.cache.delete("forward_auth_token:#{token}")
|
||||||
|
|
||||||
session_id
|
session_id
|
||||||
end
|
end
|
||||||
|
|
||||||
def extract_session_id
|
def extract_session_id
|
||||||
# Extract session ID from cookie
|
# Extract session ID from cookie
|
||||||
# Rails uses signed cookies by default
|
# Rails uses signed cookies by default
|
||||||
session_id = cookies.signed[:session_id]
|
cookies.signed[:session_id]
|
||||||
session_id
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def extract_app_from_headers
|
def extract_app_from_headers
|
||||||
@@ -155,7 +158,7 @@ module Api
|
|||||||
original_uri = request.headers["X-Forwarded-Uri"] || request.headers["X-Forwarded-Path"] || "/"
|
original_uri = request.headers["X-Forwarded-Uri"] || request.headers["X-Forwarded-Path"] || "/"
|
||||||
|
|
||||||
# Debug logging to see what headers we're getting
|
# 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
|
original_url = if original_host
|
||||||
# Use the forwarded host and URI (original behavior)
|
# 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)
|
return nil unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
|
||||||
|
|
||||||
# Only allow HTTPS in production
|
# 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
|
redirect_domain = uri.host.downcase
|
||||||
return nil unless redirect_domain.present?
|
return nil unless redirect_domain.present?
|
||||||
@@ -214,7 +217,6 @@ module Api
|
|||||||
end
|
end
|
||||||
|
|
||||||
matching_app ? url : nil
|
matching_app ? url : nil
|
||||||
|
|
||||||
rescue URI::InvalidURIError
|
rescue URI::InvalidURIError
|
||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
@@ -233,13 +235,13 @@ module Api
|
|||||||
return redirect_url if redirect_url.present?
|
return redirect_url if redirect_url.present?
|
||||||
|
|
||||||
# Try CLINCH_HOST environment variable first
|
# Try CLINCH_HOST environment variable first
|
||||||
if ENV['CLINCH_HOST'].present?
|
if ENV["CLINCH_HOST"].present?
|
||||||
host = ENV['CLINCH_HOST']
|
host = ENV["CLINCH_HOST"]
|
||||||
# Ensure URL has https:// protocol
|
# Ensure URL has https:// protocol
|
||||||
host.match?(/^https?:\/\//) ? host : "https://#{host}"
|
host.match?(/^https?:\/\//) ? host : "https://#{host}"
|
||||||
else
|
else
|
||||||
# Fallback to the request host
|
# Fallback to the request host
|
||||||
request_host = request.host || request.headers['X-Forwarded-Host']
|
request_host = request.host || request.headers["X-Forwarded-Host"]
|
||||||
if request_host.present?
|
if request_host.present?
|
||||||
Rails.logger.warn "ForwardAuth: CLINCH_HOST not set, using request host: #{request_host}"
|
Rails.logger.warn "ForwardAuth: CLINCH_HOST not set, using request host: #{request_host}"
|
||||||
"https://#{request_host}"
|
"https://#{request_host}"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
class ApplicationController < ActionController::Base
|
class ApplicationController < ActionController::Base
|
||||||
include Authentication
|
include Authentication
|
||||||
|
|
||||||
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
|
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
|
||||||
allow_browser versions: :modern
|
allow_browser versions: :modern
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
require 'uri'
|
require "uri"
|
||||||
require 'public_suffix'
|
require "public_suffix"
|
||||||
require 'ipaddr'
|
require "ipaddr"
|
||||||
|
|
||||||
module Authentication
|
module Authentication
|
||||||
extend ActiveSupport::Concern
|
extend ActiveSupport::Concern
|
||||||
@@ -17,133 +17,137 @@ module Authentication
|
|||||||
end
|
end
|
||||||
|
|
||||||
private
|
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
|
end
|
||||||
|
|
||||||
def require_authentication
|
# Use Public Suffix List for accurate domain parsing
|
||||||
resume_session || request_authentication
|
domain = PublicSuffix.parse(host_without_port)
|
||||||
end
|
".#{domain.domain}"
|
||||||
|
rescue PublicSuffix::DomainInvalid
|
||||||
|
# Fallback for invalid domains or IPs
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
def resume_session
|
# Create a one-time token for forward auth to handle the race condition
|
||||||
Current.session ||= find_session_by_cookie
|
# where the browser hasn't processed the session cookie yet
|
||||||
end
|
def create_forward_auth_token(session_obj)
|
||||||
|
# Generate a secure random token
|
||||||
|
token = SecureRandom.urlsafe_base64(32)
|
||||||
|
|
||||||
def find_session_by_cookie
|
# Store it with an expiry of 60 seconds
|
||||||
Session.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id]
|
Rails.cache.write(
|
||||||
end
|
"forward_auth_token:#{token}",
|
||||||
|
session_obj.id,
|
||||||
|
expires_in: 60.seconds
|
||||||
|
)
|
||||||
|
|
||||||
def request_authentication
|
# Set the token as a query parameter on the redirect URL
|
||||||
session[:return_to_after_authenticating] = request.url
|
# We need to store this in the controller's session
|
||||||
redirect_to signin_path
|
controller_session = session
|
||||||
end
|
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
|
# Skip adding fa_token for OAuth URLs (OAuth flow should not have forward auth tokens)
|
||||||
return_url = session[:return_to_after_authenticating]
|
unless uri.path&.start_with?("/oauth/")
|
||||||
final_url = session.delete(:return_to_after_authenticating) || root_url
|
# Add token as query parameter
|
||||||
final_url
|
query_params = URI.decode_www_form(uri.query || "").to_h
|
||||||
end
|
query_params["fa_token"] = token
|
||||||
|
uri.query = URI.encode_www_form(query_params)
|
||||||
|
|
||||||
def start_new_session_for(user, acr: "1")
|
# Update the session with the tokenized URL
|
||||||
user.update!(last_sign_in_at: Time.current)
|
controller_session[:return_to_after_authenticating] = uri.to_s
|
||||||
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
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
class InvitationsController < ApplicationController
|
class InvitationsController < ApplicationController
|
||||||
include Authentication
|
include Authentication
|
||||||
|
|
||||||
allow_unauthenticated_access
|
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
|
def show
|
||||||
# Show the password setup form
|
# Show the password setup form
|
||||||
@@ -35,16 +36,16 @@ class InvitationsController < ApplicationController
|
|||||||
# Check if user is still pending invitation
|
# Check if user is still pending invitation
|
||||||
if @user.nil?
|
if @user.nil?
|
||||||
redirect_to signin_path, alert: "Invitation link is invalid or has expired."
|
redirect_to signin_path, alert: "Invitation link is invalid or has expired."
|
||||||
return false
|
false
|
||||||
elsif @user.pending_invitation?
|
elsif @user.pending_invitation?
|
||||||
# User is valid and pending - proceed
|
# User is valid and pending - proceed
|
||||||
return true
|
true
|
||||||
else
|
else
|
||||||
redirect_to signin_path, alert: "This invitation has already been used or is no longer valid."
|
redirect_to signin_path, alert: "This invitation has already been used or is no longer valid."
|
||||||
return false
|
false
|
||||||
end
|
end
|
||||||
rescue ActiveSupport::MessageVerifier::InvalidSignature
|
rescue ActiveSupport::MessageVerifier::InvalidSignature
|
||||||
redirect_to signin_path, alert: "Invitation link is invalid or has expired."
|
redirect_to signin_path, alert: "Invitation link is invalid or has expired."
|
||||||
return false
|
false
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -5,7 +5,7 @@ class OidcController < ApplicationController
|
|||||||
|
|
||||||
# Rate limiting to prevent brute force and abuse
|
# Rate limiting to prevent brute force and abuse
|
||||||
rate_limit to: 60, within: 1.minute, only: [:token, :revoke], with: -> {
|
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: -> {
|
rate_limit to: 30, within: 1.minute, only: [:authorize, :consent], with: -> {
|
||||||
render plain: "Too many authorization attempts. Try again later.", status: :too_many_requests
|
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 << "redirect_uri is required" unless redirect_uri.present?
|
||||||
error_details << "response_type must be 'code'" unless response_type == "code"
|
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
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -90,7 +90,7 @@ class OidcController < ApplicationController
|
|||||||
Rails.logger.error "OAuth: Available OIDC applications: #{all_oidc_apps.pluck(:id, :client_id, :name)}"
|
Rails.logger.error "OAuth: Available OIDC applications: #{all_oidc_apps.pluck(:id, :client_id, :name)}"
|
||||||
|
|
||||||
error_msg = if Rails.env.development?
|
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
|
else
|
||||||
"Invalid request: Application not found"
|
"Invalid request: Application not found"
|
||||||
end
|
end
|
||||||
@@ -105,7 +105,7 @@ class OidcController < ApplicationController
|
|||||||
|
|
||||||
# For development, show detailed error
|
# For development, show detailed error
|
||||||
error_msg = if Rails.env.development?
|
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
|
else
|
||||||
"Invalid request: Redirect URI not registered for this application"
|
"Invalid request: Redirect URI not registered for this application"
|
||||||
end
|
end
|
||||||
@@ -223,22 +223,22 @@ class OidcController < ApplicationController
|
|||||||
# User denied consent
|
# User denied consent
|
||||||
if params[:deny].present?
|
if params[:deny].present?
|
||||||
session.delete(:oauth_params)
|
session.delete(:oauth_params)
|
||||||
error_uri = "#{oauth_params['redirect_uri']}?error=access_denied"
|
error_uri = "#{oauth_params["redirect_uri"]}?error=access_denied"
|
||||||
error_uri += "&state=#{CGI.escape(oauth_params['state'])}" if oauth_params['state']
|
error_uri += "&state=#{CGI.escape(oauth_params["state"])}" if oauth_params["state"]
|
||||||
redirect_to error_uri, allow_other_host: true
|
redirect_to error_uri, allow_other_host: true
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Find the application
|
# 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")
|
application = Application.find_by(client_id: client_id, app_type: "oidc")
|
||||||
|
|
||||||
# Check if application is active (redirect with OAuth error)
|
# Check if application is active (redirect with OAuth error)
|
||||||
unless application&.active?
|
unless application&.active?
|
||||||
Rails.logger.error "OAuth: Application is not active: #{application&.name || client_id}"
|
Rails.logger.error "OAuth: Application is not active: #{application&.name || client_id}"
|
||||||
session.delete(:oauth_params)
|
session.delete(:oauth_params)
|
||||||
error_uri = "#{oauth_params['redirect_uri']}?error=unauthorized_client&error_description=Application+is+not+active"
|
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 += "&state=#{CGI.escape(oauth_params["state"])}" if oauth_params["state"].present?
|
||||||
redirect_to error_uri, allow_other_host: true
|
redirect_to error_uri, allow_other_host: true
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -246,9 +246,9 @@ class OidcController < ApplicationController
|
|||||||
user = Current.session.user
|
user = Current.session.user
|
||||||
|
|
||||||
# Record user consent
|
# 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 = 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.granted_at = Time.current
|
||||||
consent.save!
|
consent.save!
|
||||||
|
|
||||||
@@ -256,11 +256,11 @@ class OidcController < ApplicationController
|
|||||||
auth_code = OidcAuthorizationCode.create!(
|
auth_code = OidcAuthorizationCode.create!(
|
||||||
application: application,
|
application: application,
|
||||||
user: user,
|
user: user,
|
||||||
redirect_uri: oauth_params['redirect_uri'],
|
redirect_uri: oauth_params["redirect_uri"],
|
||||||
scope: oauth_params['scope'],
|
scope: oauth_params["scope"],
|
||||||
nonce: oauth_params['nonce'],
|
nonce: oauth_params["nonce"],
|
||||||
code_challenge: oauth_params['code_challenge'],
|
code_challenge: oauth_params["code_challenge"],
|
||||||
code_challenge_method: oauth_params['code_challenge_method'],
|
code_challenge_method: oauth_params["code_challenge_method"],
|
||||||
auth_time: Current.session.created_at.to_i,
|
auth_time: Current.session.created_at.to_i,
|
||||||
acr: Current.session.acr,
|
acr: Current.session.acr,
|
||||||
expires_at: 10.minutes.from_now
|
expires_at: 10.minutes.from_now
|
||||||
@@ -270,8 +270,8 @@ class OidcController < ApplicationController
|
|||||||
session.delete(:oauth_params)
|
session.delete(:oauth_params)
|
||||||
|
|
||||||
# Redirect back to client with authorization code (plaintext)
|
# Redirect back to client with authorization code (plaintext)
|
||||||
redirect_uri = "#{oauth_params['redirect_uri']}?code=#{auth_code.plaintext_code}"
|
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 += "&state=#{CGI.escape(oauth_params["state"])}" if oauth_params["state"]
|
||||||
|
|
||||||
redirect_to redirect_uri, allow_other_host: true
|
redirect_to redirect_uri, allow_other_host: true
|
||||||
end
|
end
|
||||||
@@ -286,7 +286,7 @@ class OidcController < ApplicationController
|
|||||||
when "refresh_token"
|
when "refresh_token"
|
||||||
handle_refresh_token_grant
|
handle_refresh_token_grant
|
||||||
else
|
else
|
||||||
render json: { error: "unsupported_grant_type" }, status: :bad_request
|
render json: {error: "unsupported_grant_type"}, status: :bad_request
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -295,14 +295,14 @@ class OidcController < ApplicationController
|
|||||||
client_id, client_secret = extract_client_credentials
|
client_id, client_secret = extract_client_credentials
|
||||||
|
|
||||||
unless client_id
|
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
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Find the application
|
# Find the application
|
||||||
application = Application.find_by(client_id: client_id)
|
application = Application.find_by(client_id: client_id)
|
||||||
unless application
|
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
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -313,7 +313,7 @@ class OidcController < ApplicationController
|
|||||||
else
|
else
|
||||||
# Confidential clients MUST provide valid client_secret
|
# Confidential clients MUST provide valid client_secret
|
||||||
unless client_secret.present? && application.authenticate_client_secret(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
|
return
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -321,7 +321,7 @@ class OidcController < ApplicationController
|
|||||||
# Check if application is active
|
# Check if application is active
|
||||||
unless application.active?
|
unless application.active?
|
||||||
Rails.logger.error "OAuth: Token request for inactive application: #{application.name}"
|
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
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -334,7 +334,7 @@ class OidcController < ApplicationController
|
|||||||
auth_code = OidcAuthorizationCode.find_by_plaintext(code)
|
auth_code = OidcAuthorizationCode.find_by_plaintext(code)
|
||||||
|
|
||||||
unless auth_code && auth_code.application == application
|
unless auth_code && auth_code.application == application
|
||||||
render json: { error: "invalid_grant" }, status: :bad_request
|
render json: {error: "invalid_grant"}, status: :bad_request
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -365,13 +365,13 @@ class OidcController < ApplicationController
|
|||||||
|
|
||||||
# Check if code is expired
|
# Check if code is expired
|
||||||
if auth_code.expires_at < Time.current
|
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
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Validate redirect URI matches
|
# Validate redirect URI matches
|
||||||
unless auth_code.redirect_uri == redirect_uri
|
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
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -413,7 +413,7 @@ class OidcController < ApplicationController
|
|||||||
|
|
||||||
unless consent
|
unless consent
|
||||||
Rails.logger.error "OIDC Security: Token requested without consent record (user: #{user.id}, app: #{application.id})"
|
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
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -440,7 +440,7 @@ class OidcController < ApplicationController
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
rescue ActiveRecord::RecordNotFound
|
rescue ActiveRecord::RecordNotFound
|
||||||
render json: { error: "invalid_grant" }, status: :bad_request
|
render json: {error: "invalid_grant"}, status: :bad_request
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -449,14 +449,14 @@ class OidcController < ApplicationController
|
|||||||
client_id, client_secret = extract_client_credentials
|
client_id, client_secret = extract_client_credentials
|
||||||
|
|
||||||
unless client_id
|
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
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Find the application
|
# Find the application
|
||||||
application = Application.find_by(client_id: client_id)
|
application = Application.find_by(client_id: client_id)
|
||||||
unless application
|
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
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -467,7 +467,7 @@ class OidcController < ApplicationController
|
|||||||
else
|
else
|
||||||
# Confidential clients MUST provide valid client_secret
|
# Confidential clients MUST provide valid client_secret
|
||||||
unless client_secret.present? && application.authenticate_client_secret(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
|
return
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -475,14 +475,14 @@ class OidcController < ApplicationController
|
|||||||
# Check if application is active
|
# Check if application is active
|
||||||
unless application.active?
|
unless application.active?
|
||||||
Rails.logger.error "OAuth: Refresh token request for inactive application: #{application.name}"
|
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
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Get the refresh token
|
# Get the refresh token
|
||||||
refresh_token = params[:refresh_token]
|
refresh_token = params[:refresh_token]
|
||||||
unless refresh_token.present?
|
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
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -491,13 +491,13 @@ class OidcController < ApplicationController
|
|||||||
|
|
||||||
# Verify the token belongs to the correct application
|
# Verify the token belongs to the correct application
|
||||||
unless refresh_token_record && refresh_token_record.application == 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
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Check if refresh token is expired
|
# Check if refresh token is expired
|
||||||
if refresh_token_record.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
|
return
|
||||||
end
|
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}"
|
Rails.logger.warn "OAuth Security: Revoked refresh token reuse detected for token family #{refresh_token_record.token_family_id}"
|
||||||
refresh_token_record.revoke_family!
|
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
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -541,7 +541,7 @@ class OidcController < ApplicationController
|
|||||||
|
|
||||||
unless consent
|
unless consent
|
||||||
Rails.logger.error "OIDC Security: Refresh token used without consent record (user: #{user.id}, app: #{application.id})"
|
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
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -566,7 +566,7 @@ class OidcController < ApplicationController
|
|||||||
scope: refresh_token_record.scope
|
scope: refresh_token_record.scope
|
||||||
}
|
}
|
||||||
rescue ActiveRecord::RecordNotFound
|
rescue ActiveRecord::RecordNotFound
|
||||||
render json: { error: "invalid_grant" }, status: :bad_request
|
render json: {error: "invalid_grant"}, status: :bad_request
|
||||||
end
|
end
|
||||||
|
|
||||||
# GET /oauth/userinfo
|
# GET /oauth/userinfo
|
||||||
@@ -650,7 +650,7 @@ class OidcController < ApplicationController
|
|||||||
|
|
||||||
# Find and validate the application
|
# Find and validate the application
|
||||||
application = Application.find_by(client_id: client_id)
|
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}"
|
Rails.logger.warn "OAuth: Token revocation attempted for invalid application: #{client_id}"
|
||||||
head :ok
|
head :ok
|
||||||
return
|
return
|
||||||
@@ -669,7 +669,7 @@ class OidcController < ApplicationController
|
|||||||
|
|
||||||
unless token.present?
|
unless token.present?
|
||||||
# RFC 7009: Missing token parameter is an error
|
# 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
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -695,7 +695,7 @@ class OidcController < ApplicationController
|
|||||||
if access_token_record
|
if access_token_record
|
||||||
access_token_record.revoke!
|
access_token_record.revoke!
|
||||||
Rails.logger.info "OAuth: Access token revoked for application #{application.name}"
|
Rails.logger.info "OAuth: Access token revoked for application #{application.name}"
|
||||||
revoked = true
|
true
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -709,7 +709,7 @@ class OidcController < ApplicationController
|
|||||||
# OpenID Connect RP-Initiated Logout
|
# OpenID Connect RP-Initiated Logout
|
||||||
# Handle id_token_hint and post_logout_redirect_uri parameters
|
# 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]
|
post_logout_redirect_uri = params[:post_logout_redirect_uri]
|
||||||
state = params[:state]
|
state = params[:state]
|
||||||
|
|
||||||
@@ -763,7 +763,7 @@ class OidcController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
# Skip validation if no code challenge was stored (legacy clients without PKCE requirement)
|
# 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
|
# PKCE was provided during authorization but no verifier sent with token request
|
||||||
unless code_verifier.present?
|
unless code_verifier.present?
|
||||||
@@ -787,18 +787,18 @@ class OidcController < ApplicationController
|
|||||||
|
|
||||||
# Recreate code challenge based on method
|
# Recreate code challenge based on method
|
||||||
expected_challenge = case auth_code.code_challenge_method
|
expected_challenge = case auth_code.code_challenge_method
|
||||||
when "plain"
|
when "plain"
|
||||||
code_verifier
|
code_verifier
|
||||||
when "S256"
|
when "S256"
|
||||||
Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false)
|
Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false)
|
||||||
else
|
else
|
||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
error: "server_error",
|
error: "server_error",
|
||||||
error_description: "Unsupported code challenge method",
|
error_description: "Unsupported code challenge method",
|
||||||
status: :internal_server_error
|
status: :internal_server_error
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
# Validate the code challenge
|
# Validate the code challenge
|
||||||
unless auth_code.code_challenge == expected_challenge
|
unless auth_code.code_challenge == expected_challenge
|
||||||
@@ -810,7 +810,7 @@ class OidcController < ApplicationController
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
{ valid: true }
|
{valid: true}
|
||||||
end
|
end
|
||||||
|
|
||||||
def extract_client_credentials
|
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)
|
return nil unless parsed_uri.is_a?(URI::HTTP) || parsed_uri.is_a?(URI::HTTPS)
|
||||||
|
|
||||||
# Only allow HTTPS in production
|
# 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
|
# Check if URI matches any registered OIDC application's redirect URIs
|
||||||
# According to OIDC spec, post_logout_redirect_uri should be pre-registered
|
# According to OIDC spec, post_logout_redirect_uri should be pre-registered
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
class PasswordsController < ApplicationController
|
class PasswordsController < ApplicationController
|
||||||
allow_unauthenticated_access
|
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." }
|
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_password_path, alert: "Try again later." }
|
||||||
|
|
||||||
def new
|
def new
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
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
|
PasswordsMailer.reset(user).deliver_later
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -27,10 +27,11 @@ class PasswordsController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
def set_user_by_token
|
|
||||||
@user = User.find_by_token_for(:password_reset, params[:token])
|
def set_user_by_token
|
||||||
redirect_to new_password_path, alert: "Password reset link is invalid or has expired." if @user.nil?
|
@user = User.find_by_token_for(:password_reset, params[:token])
|
||||||
rescue ActiveSupport::MessageVerifier::InvalidSignature
|
redirect_to new_password_path, alert: "Password reset link is invalid or has expired." if @user.nil?
|
||||||
redirect_to new_password_path, alert: "Password reset link is invalid or has expired."
|
rescue ActiveSupport::MessageVerifier::InvalidSignature
|
||||||
end
|
redirect_to new_password_path, alert: "Password reset link is invalid or has expired."
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
class SessionsController < ApplicationController
|
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: 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: :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
|
def new
|
||||||
# Redirect to signup if this is first run
|
# Redirect to signup if this is first run
|
||||||
if User.count.zero?
|
if User.count.zero?
|
||||||
respond_to do |format|
|
respond_to do |format|
|
||||||
format.html { redirect_to signup_path }
|
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
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
respond_to do |format|
|
respond_to do |format|
|
||||||
format.html # render HTML login page
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -127,7 +127,7 @@ class SessionsController < ApplicationController
|
|||||||
|
|
||||||
# Invalid code
|
# Invalid code
|
||||||
redirect_to totp_verification_path, alert: "Invalid verification code. Please try again."
|
redirect_to totp_verification_path, alert: "Invalid verification code. Please try again."
|
||||||
return
|
nil
|
||||||
end
|
end
|
||||||
|
|
||||||
# Just render the form
|
# Just render the form
|
||||||
@@ -155,14 +155,14 @@ class SessionsController < ApplicationController
|
|||||||
email = params[:email]&.strip&.downcase
|
email = params[:email]&.strip&.downcase
|
||||||
|
|
||||||
if email.blank?
|
if email.blank?
|
||||||
render json: { error: "Email is required" }, status: :unprocessable_entity
|
render json: {error: "Email is required"}, status: :unprocessable_entity
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
user = User.find_by(email_address: email)
|
user = User.find_by(email_address: email)
|
||||||
|
|
||||||
if user.nil? || !user.can_authenticate_with_webauthn?
|
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
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -191,10 +191,9 @@ class SessionsController < ApplicationController
|
|||||||
session[:webauthn_challenge] = options.challenge
|
session[:webauthn_challenge] = options.challenge
|
||||||
|
|
||||||
render json: options
|
render json: options
|
||||||
|
|
||||||
rescue => e
|
rescue => e
|
||||||
Rails.logger.error "WebAuthn challenge generation error: #{e.message}"
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -202,21 +201,21 @@ class SessionsController < ApplicationController
|
|||||||
# Get pending user from session
|
# Get pending user from session
|
||||||
user_id = session[:pending_webauthn_user_id]
|
user_id = session[:pending_webauthn_user_id]
|
||||||
unless 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
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
user = User.find_by(id: user_id)
|
user = User.find_by(id: user_id)
|
||||||
unless user
|
unless user
|
||||||
session.delete(:pending_webauthn_user_id)
|
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
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Get the credential and assertion from params
|
# Get the credential and assertion from params
|
||||||
credential_data = params[:credential]
|
credential_data = params[:credential]
|
||||||
if credential_data.blank?
|
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
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -224,7 +223,7 @@ class SessionsController < ApplicationController
|
|||||||
challenge = session.delete(:webauthn_challenge)
|
challenge = session.delete(:webauthn_challenge)
|
||||||
|
|
||||||
if challenge.blank?
|
if challenge.blank?
|
||||||
render json: { error: "Invalid or expired session" }, status: :unprocessable_entity
|
render json: {error: "Invalid or expired session"}, status: :unprocessable_entity
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -237,7 +236,7 @@ class SessionsController < ApplicationController
|
|||||||
stored_credential = user.webauthn_credential_for(external_id)
|
stored_credential = user.webauthn_credential_for(external_id)
|
||||||
|
|
||||||
if stored_credential.nil?
|
if stored_credential.nil?
|
||||||
render json: { error: "Credential not found" }, status: :unprocessable_entity
|
render json: {error: "Credential not found"}, status: :unprocessable_entity
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -276,16 +275,15 @@ class SessionsController < ApplicationController
|
|||||||
redirect_to: after_authentication_url,
|
redirect_to: after_authentication_url,
|
||||||
message: "Signed in successfully with passkey"
|
message: "Signed in successfully with passkey"
|
||||||
}
|
}
|
||||||
|
|
||||||
rescue WebAuthn::Error => e
|
rescue WebAuthn::Error => e
|
||||||
Rails.logger.error "WebAuthn verification error: #{e.message}"
|
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
|
rescue JSON::ParserError => e
|
||||||
Rails.logger.error "WebAuthn JSON parsing error: #{e.message}"
|
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
|
rescue => e
|
||||||
Rails.logger.error "Unexpected WebAuthn verification error: #{e.class} - #{e.message}"
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -301,7 +299,7 @@ class SessionsController < ApplicationController
|
|||||||
return nil unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
|
return nil unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
|
||||||
|
|
||||||
# Only allow HTTPS in production
|
# 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
|
redirect_domain = uri.host.downcase
|
||||||
return nil unless redirect_domain.present?
|
return nil unless redirect_domain.present?
|
||||||
@@ -312,7 +310,6 @@ class SessionsController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
matching_app ? url : nil
|
matching_app ? url : nil
|
||||||
|
|
||||||
rescue URI::InvalidURIError
|
rescue URI::InvalidURIError
|
||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
class UsersController < ApplicationController
|
class UsersController < ApplicationController
|
||||||
allow_unauthenticated_access only: %i[ new create ]
|
allow_unauthenticated_access only: %i[new create]
|
||||||
before_action :ensure_first_run, only: %i[ new create ]
|
before_action :ensure_first_run, only: %i[new create]
|
||||||
|
|
||||||
def new
|
def new
|
||||||
@user = User.new
|
@user = User.new
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ class WebauthnController < ApplicationController
|
|||||||
|
|
||||||
# Rate limit check endpoint to prevent enumeration attacks
|
# Rate limit check endpoint to prevent enumeration attacks
|
||||||
rate_limit to: 10, within: 1.minute, only: [:check], with: -> {
|
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
|
# GET /webauthn/new
|
||||||
@@ -16,7 +16,7 @@ class WebauthnController < ApplicationController
|
|||||||
# Generate registration challenge for creating a new passkey
|
# Generate registration challenge for creating a new passkey
|
||||||
def challenge
|
def challenge
|
||||||
user = Current.session&.user
|
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(
|
registration_options = WebAuthn::Credential.options_for_create(
|
||||||
user: {
|
user: {
|
||||||
@@ -44,7 +44,7 @@ class WebauthnController < ApplicationController
|
|||||||
credential_data, nickname = extract_credential_params
|
credential_data, nickname = extract_credential_params
|
||||||
|
|
||||||
if credential_data.blank? || nickname.blank?
|
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
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ class WebauthnController < ApplicationController
|
|||||||
challenge = session.delete(:webauthn_challenge)
|
challenge = session.delete(:webauthn_challenge)
|
||||||
|
|
||||||
if challenge.blank?
|
if challenge.blank?
|
||||||
render json: { error: "Invalid or expired session" }, status: :unprocessable_entity
|
render json: {error: "Invalid or expired session"}, status: :unprocessable_entity
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -68,10 +68,10 @@ class WebauthnController < ApplicationController
|
|||||||
client_extension_results = response["clientExtensionResults"] || {}
|
client_extension_results = response["clientExtensionResults"] || {}
|
||||||
|
|
||||||
authenticator_type = if response["response"]["authenticatorAttachment"] == "cross-platform"
|
authenticator_type = if response["response"]["authenticatorAttachment"] == "cross-platform"
|
||||||
"cross-platform"
|
"cross-platform"
|
||||||
else
|
else
|
||||||
"platform"
|
"platform"
|
||||||
end
|
end
|
||||||
|
|
||||||
# Determine if this is a backup/synced credential
|
# Determine if this is a backup/synced credential
|
||||||
backup_eligible = client_extension_results["credProps"]&.dig("rk") || false
|
backup_eligible = client_extension_results["credProps"]&.dig("rk") || false
|
||||||
@@ -79,7 +79,7 @@ class WebauthnController < ApplicationController
|
|||||||
|
|
||||||
# Store the credential
|
# Store the credential
|
||||||
user = Current.session&.user
|
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!(
|
@webauthn_credential = user.webauthn_credentials.create!(
|
||||||
external_id: Base64.urlsafe_encode64(webauthn_credential.id),
|
external_id: Base64.urlsafe_encode64(webauthn_credential.id),
|
||||||
@@ -96,13 +96,12 @@ class WebauthnController < ApplicationController
|
|||||||
message: "Passkey '#{nickname}' registered successfully",
|
message: "Passkey '#{nickname}' registered successfully",
|
||||||
credential_id: @webauthn_credential.id
|
credential_id: @webauthn_credential.id
|
||||||
}
|
}
|
||||||
|
|
||||||
rescue WebAuthn::Error => e
|
rescue WebAuthn::Error => e
|
||||||
Rails.logger.error "WebAuthn registration error: #{e.message}"
|
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
|
rescue => e
|
||||||
Rails.logger.error "Unexpected WebAuthn registration error: #{e.class} - #{e.message}"
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -115,7 +114,7 @@ class WebauthnController < ApplicationController
|
|||||||
respond_to do |format|
|
respond_to do |format|
|
||||||
format.html {
|
format.html {
|
||||||
redirect_to profile_path,
|
redirect_to profile_path,
|
||||||
notice: "Passkey '#{nickname}' has been removed"
|
notice: "Passkey '#{nickname}' has been removed"
|
||||||
}
|
}
|
||||||
format.json {
|
format.json {
|
||||||
render json: {
|
render json: {
|
||||||
@@ -133,7 +132,7 @@ class WebauthnController < ApplicationController
|
|||||||
email = params[:email]&.strip&.downcase
|
email = params[:email]&.strip&.downcase
|
||||||
|
|
||||||
if email.blank?
|
if email.blank?
|
||||||
render json: { has_webauthn: false, requires_webauthn: false }
|
render json: {has_webauthn: false, requires_webauthn: false}
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -142,7 +141,7 @@ class WebauthnController < ApplicationController
|
|||||||
# Security: Return identical response for non-existent users
|
# Security: Return identical response for non-existent users
|
||||||
# Combined with rate limiting (10/min), this prevents account enumeration
|
# Combined with rate limiting (10/min), this prevents account enumeration
|
||||||
if user.nil?
|
if user.nil?
|
||||||
render json: { has_webauthn: false, requires_webauthn: false }
|
render json: {has_webauthn: false, requires_webauthn: false}
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -158,37 +157,36 @@ class WebauthnController < ApplicationController
|
|||||||
def extract_credential_params
|
def extract_credential_params
|
||||||
# Use require.permit which is working and reliable
|
# Use require.permit which is working and reliable
|
||||||
# The JavaScript sends params both directly and wrapped in webauthn key
|
# The JavaScript sends params both directly and wrapped in webauthn key
|
||||||
begin
|
|
||||||
# Try direct parameters first
|
# Try direct parameters first
|
||||||
credential_params = params.require(:credential).permit(:id, :rawId, :type, response: {}, clientExtensionResults: {})
|
credential_params = params.require(:credential).permit(:id, :rawId, :type, response: {}, clientExtensionResults: {})
|
||||||
nickname = params.require(:nickname)
|
nickname = params.require(:nickname)
|
||||||
[credential_params, nickname]
|
[credential_params, nickname]
|
||||||
rescue ActionController::ParameterMissing
|
rescue ActionController::ParameterMissing
|
||||||
Rails.logger.error("Using the fallback parameters")
|
Rails.logger.error("Using the fallback parameters")
|
||||||
# Fallback to webauthn-wrapped parameters
|
# Fallback to webauthn-wrapped parameters
|
||||||
webauthn_params = params.require(:webauthn).permit(:nickname, credential: [:id, :rawId, :type, response: {}, clientExtensionResults: {}])
|
webauthn_params = params.require(:webauthn).permit(:nickname, credential: [:id, :rawId, :type, response: {}, clientExtensionResults: {}])
|
||||||
[webauthn_params[:credential], webauthn_params[:nickname]]
|
[webauthn_params[:credential], webauthn_params[:nickname]]
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_webauthn_credential
|
def set_webauthn_credential
|
||||||
user = Current.session&.user
|
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])
|
@webauthn_credential = user.webauthn_credentials.find(params[:id])
|
||||||
rescue ActiveRecord::RecordNotFound
|
rescue ActiveRecord::RecordNotFound
|
||||||
respond_to do |format|
|
respond_to do |format|
|
||||||
format.html { redirect_to profile_path, alert: "Passkey not found" }
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
# Helper method to convert Base64 to Base64URL if needed
|
# Helper method to convert Base64 to Base64URL if needed
|
||||||
def base64_to_base64url(str)
|
def base64_to_base64url(str)
|
||||||
str.gsub('+', '-').gsub('/', '_').gsub(/=+$/, '')
|
str.tr("+", "-").tr("/", "_").gsub(/=+$/, "")
|
||||||
end
|
end
|
||||||
|
|
||||||
# Helper method to convert Base64URL to Base64 if needed
|
# Helper method to convert Base64URL to Base64 if needed
|
||||||
def base64url_to_base64(str)
|
def base64url_to_base64(str)
|
||||||
str.gsub('-', '+').gsub('_', '/') + '=' * (4 - str.length % 4) % 4
|
str.tr("-", "+").tr("_", "/") + "=" * (4 - str.length % 4) % 4
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -6,10 +6,10 @@ module ApplicationHelper
|
|||||||
smtp_port = ENV["SMTP_PORT"]
|
smtp_port = ENV["SMTP_PORT"]
|
||||||
|
|
||||||
smtp_address.present? &&
|
smtp_address.present? &&
|
||||||
smtp_port.present? &&
|
smtp_port.present? &&
|
||||||
smtp_address != "localhost" &&
|
smtp_address != "localhost" &&
|
||||||
!smtp_address.start_with?("127.0.0.1") &&
|
!smtp_address.start_with?("127.0.0.1") &&
|
||||||
!smtp_address.start_with?("localhost")
|
!smtp_address.start_with?("localhost")
|
||||||
end
|
end
|
||||||
|
|
||||||
def email_delivery_method
|
def email_delivery_method
|
||||||
@@ -22,11 +22,11 @@ module ApplicationHelper
|
|||||||
|
|
||||||
def border_class_for(type)
|
def border_class_for(type)
|
||||||
case type.to_s
|
case type.to_s
|
||||||
when 'notice' then 'border-green-200'
|
when "notice" then "border-green-200"
|
||||||
when 'alert', 'error' then 'border-red-200'
|
when "alert", "error" then "border-red-200"
|
||||||
when 'warning' then 'border-yellow-200'
|
when "warning" then "border-yellow-200"
|
||||||
when 'info' then 'border-blue-200'
|
when "info" then "border-blue-200"
|
||||||
else 'border-gray-200'
|
else "border-gray-200"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -25,9 +25,7 @@ module ClaimsHelper
|
|||||||
claims = deep_merge_claims(claims, user.parsed_custom_claims)
|
claims = deep_merge_claims(claims, user.parsed_custom_claims)
|
||||||
|
|
||||||
# Merge app-specific claims (arrays are combined)
|
# Merge app-specific claims (arrays are combined)
|
||||||
claims = deep_merge_claims(claims, application.custom_claims_for_user(user))
|
deep_merge_claims(claims, application.custom_claims_for_user(user))
|
||||||
|
|
||||||
claims
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Get claim sources breakdown for display
|
# Get claim sources breakdown for display
|
||||||
|
|||||||
@@ -29,10 +29,10 @@ class BackchannelLogoutJob < ApplicationJob
|
|||||||
uri = URI.parse(application.backchannel_logout_uri)
|
uri = URI.parse(application.backchannel_logout_uri)
|
||||||
|
|
||||||
begin
|
begin
|
||||||
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https', open_timeout: 5, read_timeout: 5) do |http|
|
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 = Net::HTTP::Post.new(uri.path.presence || "/")
|
||||||
request['Content-Type'] = 'application/x-www-form-urlencoded'
|
request["Content-Type"] = "application/x-www-form-urlencoded"
|
||||||
request.set_form_data({ logout_token: logout_token })
|
request.set_form_data({logout_token: logout_token})
|
||||||
http.request(request)
|
http.request(request)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ class BackchannelLogoutJob < ApplicationJob
|
|||||||
rescue Net::OpenTimeout, Net::ReadTimeout => e
|
rescue Net::OpenTimeout, Net::ReadTimeout => e
|
||||||
Rails.logger.warn "BackchannelLogout: Timeout sending logout to #{application.name} (#{application.backchannel_logout_uri}): #{e.message}"
|
Rails.logger.warn "BackchannelLogout: Timeout sending logout to #{application.name} (#{application.backchannel_logout_uri}): #{e.message}"
|
||||||
raise # Retry on timeout
|
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}"
|
Rails.logger.error "BackchannelLogout: Failed to send logout to #{application.name} (#{application.backchannel_logout_uri}): #{e.class} - #{e.message}"
|
||||||
raise # Retry on error
|
raise # Retry on error
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
class ApplicationMailer < ActionMailer::Base
|
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"
|
layout "mailer"
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -19,16 +19,16 @@ class Application < ApplicationRecord
|
|||||||
has_many :oidc_user_consents, dependent: :destroy
|
has_many :oidc_user_consents, dependent: :destroy
|
||||||
|
|
||||||
validates :name, presence: true
|
validates :name, presence: true
|
||||||
validates :slug, presence: true, uniqueness: { case_sensitive: false },
|
validates :slug, presence: true, uniqueness: {case_sensitive: false},
|
||||||
format: { with: /\A[a-z0-9\-]+\z/, message: "only lowercase letters, numbers, and hyphens" }
|
format: {with: /\A[a-z0-9-]+\z/, message: "only lowercase letters, numbers, and hyphens"}
|
||||||
validates :app_type, presence: true,
|
validates :app_type, presence: true,
|
||||||
inclusion: { in: %w[oidc forward_auth] }
|
inclusion: {in: %w[oidc forward_auth]}
|
||||||
validates :client_id, uniqueness: { allow_nil: true }
|
validates :client_id, uniqueness: {allow_nil: true}
|
||||||
validates :client_secret, presence: true, on: :create, if: -> { oidc? && confidential_client? }
|
validates :client_secret, presence: true, on: :create, if: -> { oidc? && confidential_client? }
|
||||||
validates :domain_pattern, presence: true, uniqueness: { case_sensitive: false }, if: :forward_auth?
|
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 :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: {
|
validates :backchannel_logout_uri, format: {
|
||||||
with: URI::regexp(%w[http https]),
|
with: URI::RFC2396_PARSER.make_regexp(%w[http https]),
|
||||||
allow_nil: true,
|
allow_nil: true,
|
||||||
message: "must be a valid HTTP or HTTPS URL"
|
message: "must be a valid HTTP or HTTPS URL"
|
||||||
}
|
}
|
||||||
@@ -38,9 +38,9 @@ class Application < ApplicationRecord
|
|||||||
validate :icon_validation, if: -> { icon.attached? }
|
validate :icon_validation, if: -> { icon.attached? }
|
||||||
|
|
||||||
# Token TTL validations (for OIDC apps)
|
# 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 :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 :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 :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 :slug, with: ->(slug) { slug.strip.downcase }
|
||||||
normalizes :domain_pattern, with: ->(pattern) {
|
normalizes :domain_pattern, with: ->(pattern) {
|
||||||
@@ -56,11 +56,11 @@ class Application < ApplicationRecord
|
|||||||
|
|
||||||
# Default header configuration for ForwardAuth
|
# Default header configuration for ForwardAuth
|
||||||
DEFAULT_HEADERS = {
|
DEFAULT_HEADERS = {
|
||||||
user: 'X-Remote-User',
|
user: "X-Remote-User",
|
||||||
email: 'X-Remote-Email',
|
email: "X-Remote-Email",
|
||||||
name: 'X-Remote-Name',
|
name: "X-Remote-Name",
|
||||||
groups: 'X-Remote-Groups',
|
groups: "X-Remote-Groups",
|
||||||
admin: 'X-Remote-Admin'
|
admin: "X-Remote-Admin"
|
||||||
}.freeze
|
}.freeze
|
||||||
|
|
||||||
# Scopes
|
# Scopes
|
||||||
@@ -135,8 +135,8 @@ class Application < ApplicationRecord
|
|||||||
def matches_domain?(domain)
|
def matches_domain?(domain)
|
||||||
return false if domain.blank? || !forward_auth?
|
return false if domain.blank? || !forward_auth?
|
||||||
|
|
||||||
pattern = domain_pattern.gsub('.', '\.')
|
pattern = domain_pattern.gsub(".", '\.')
|
||||||
pattern = pattern.gsub('*', '[^.]*')
|
pattern = pattern.gsub("*", "[^.]*")
|
||||||
|
|
||||||
regex = Regexp.new("^#{pattern}$", Regexp::IGNORECASE)
|
regex = Regexp.new("^#{pattern}$", Regexp::IGNORECASE)
|
||||||
regex.match?(domain.downcase)
|
regex.match?(domain.downcase)
|
||||||
@@ -144,18 +144,18 @@ class Application < ApplicationRecord
|
|||||||
|
|
||||||
# Policy determination based on user status (for ForwardAuth)
|
# Policy determination based on user status (for ForwardAuth)
|
||||||
def policy_for_user(user)
|
def policy_for_user(user)
|
||||||
return 'deny' unless active?
|
return "deny" unless active?
|
||||||
return 'deny' unless user.active?
|
return "deny" unless user.active?
|
||||||
|
|
||||||
# If no groups specified, bypass authentication
|
# 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 is in allowed groups, determine auth level
|
||||||
if user_allowed?(user)
|
if user_allowed?(user)
|
||||||
# Require 2FA if user has TOTP configured, otherwise one factor
|
# 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
|
else
|
||||||
'deny'
|
"deny"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -197,7 +197,7 @@ class Application < ApplicationRecord
|
|||||||
def generate_new_client_secret!
|
def generate_new_client_secret!
|
||||||
secret = SecureRandom.urlsafe_base64(48)
|
secret = SecureRandom.urlsafe_base64(48)
|
||||||
self.client_secret = secret
|
self.client_secret = secret
|
||||||
self.save!
|
save!
|
||||||
secret
|
secret
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -242,7 +242,7 @@ class Application < ApplicationRecord
|
|||||||
# (i.e., has valid, non-revoked tokens)
|
# (i.e., has valid, non-revoked tokens)
|
||||||
def user_has_active_session?(user)
|
def user_has_active_session?(user)
|
||||||
oidc_access_tokens.where(user: user).valid.exists? ||
|
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
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
@@ -260,14 +260,14 @@ class Application < ApplicationRecord
|
|||||||
return unless icon.attached?
|
return unless icon.attached?
|
||||||
|
|
||||||
# Check content type
|
# 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)
|
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
|
end
|
||||||
|
|
||||||
# Check file size (2MB limit)
|
# Check file size (2MB limit)
|
||||||
if icon.blob.byte_size > 2.megabytes
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -302,8 +302,8 @@ class Application < ApplicationRecord
|
|||||||
|
|
||||||
begin
|
begin
|
||||||
uri = URI.parse(backchannel_logout_uri)
|
uri = URI.parse(backchannel_logout_uri)
|
||||||
unless uri.scheme == 'https'
|
unless uri.scheme == "https"
|
||||||
errors.add(:backchannel_logout_uri, 'must use HTTPS in production')
|
errors.add(:backchannel_logout_uri, "must use HTTPS in production")
|
||||||
end
|
end
|
||||||
rescue URI::InvalidURIError
|
rescue URI::InvalidURIError
|
||||||
# Let the format validator handle invalid URIs
|
# Let the format validator handle invalid URIs
|
||||||
|
|||||||
@@ -2,5 +2,5 @@ class ApplicationGroup < ApplicationRecord
|
|||||||
belongs_to :application
|
belongs_to :application
|
||||||
belongs_to :group
|
belongs_to :group
|
||||||
|
|
||||||
validates :application_id, uniqueness: { scope: :group_id }
|
validates :application_id, uniqueness: {scope: :group_id}
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ class ApplicationUserClaim < ApplicationRecord
|
|||||||
groups
|
groups
|
||||||
].freeze
|
].freeze
|
||||||
|
|
||||||
validates :user_id, uniqueness: { scope: :application_id }
|
validates :user_id, uniqueness: {scope: :application_id}
|
||||||
validate :no_reserved_claim_names
|
validate :no_reserved_claim_names
|
||||||
|
|
||||||
# Parse custom_claims JSON field
|
# Parse custom_claims JSON field
|
||||||
@@ -25,7 +25,7 @@ class ApplicationUserClaim < ApplicationRecord
|
|||||||
|
|
||||||
reserved_used = parsed_custom_claims.keys.map(&:to_s) & RESERVED_CLAIMS
|
reserved_used = parsed_custom_claims.keys.map(&:to_s) & RESERVED_CLAIMS
|
||||||
if reserved_used.any?
|
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
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class Group < ApplicationRecord
|
|||||||
groups
|
groups
|
||||||
].freeze
|
].freeze
|
||||||
|
|
||||||
validates :name, presence: true, uniqueness: { case_sensitive: false }
|
validates :name, presence: true, uniqueness: {case_sensitive: false}
|
||||||
normalizes :name, with: ->(name) { name.strip.downcase }
|
normalizes :name, with: ->(name) { name.strip.downcase }
|
||||||
validate :no_reserved_claim_names
|
validate :no_reserved_claim_names
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ class Group < ApplicationRecord
|
|||||||
|
|
||||||
reserved_used = parsed_custom_claims.keys.map(&:to_s) & RESERVED_CLAIMS
|
reserved_used = parsed_custom_claims.keys.map(&:to_s) & RESERVED_CLAIMS
|
||||||
if reserved_used.any?
|
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
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ class OidcAccessToken < ApplicationRecord
|
|||||||
|
|
||||||
# Compute HMAC for token lookup
|
# Compute HMAC for token lookup
|
||||||
def self.compute_token_hmac(plaintext_token)
|
def self.compute_token_hmac(plaintext_token)
|
||||||
OpenSSL::HMAC.hexdigest('SHA256', TokenHmac::KEY, plaintext_token)
|
OpenSSL::HMAC.hexdigest("SHA256", TokenHmac::KEY, plaintext_token)
|
||||||
end
|
end
|
||||||
|
|
||||||
def expired?
|
def expired?
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ class OidcAuthorizationCode < ApplicationRecord
|
|||||||
|
|
||||||
validates :code_hmac, presence: true, uniqueness: true
|
validates :code_hmac, presence: true, uniqueness: true
|
||||||
validates :redirect_uri, presence: 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? }
|
validate :validate_code_challenge_format, if: -> { code_challenge.present? }
|
||||||
|
|
||||||
scope :valid, -> { where(used: false).where("expires_at > ?", Time.current) }
|
scope :valid, -> { where(used: false).where("expires_at > ?", Time.current) }
|
||||||
@@ -25,7 +25,7 @@ class OidcAuthorizationCode < ApplicationRecord
|
|||||||
|
|
||||||
# Compute HMAC for code lookup
|
# Compute HMAC for code lookup
|
||||||
def self.compute_code_hmac(plaintext_code)
|
def self.compute_code_hmac(plaintext_code)
|
||||||
OpenSSL::HMAC.hexdigest('SHA256', TokenHmac::KEY, plaintext_code)
|
OpenSSL::HMAC.hexdigest("SHA256", TokenHmac::KEY, plaintext_code)
|
||||||
end
|
end
|
||||||
|
|
||||||
def expired?
|
def expired?
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ class OidcRefreshToken < ApplicationRecord
|
|||||||
|
|
||||||
# Compute HMAC for token lookup
|
# Compute HMAC for token lookup
|
||||||
def self.compute_token_hmac(plaintext_token)
|
def self.compute_token_hmac(plaintext_token)
|
||||||
OpenSSL::HMAC.hexdigest('SHA256', TokenHmac::KEY, plaintext_token)
|
OpenSSL::HMAC.hexdigest("SHA256", TokenHmac::KEY, plaintext_token)
|
||||||
end
|
end
|
||||||
|
|
||||||
def expired?
|
def expired?
|
||||||
|
|||||||
@@ -3,19 +3,19 @@ class OidcUserConsent < ApplicationRecord
|
|||||||
belongs_to :application
|
belongs_to :application
|
||||||
|
|
||||||
validates :user, :application, :scopes_granted, :granted_at, presence: true
|
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_granted_at, on: :create
|
||||||
before_validation :set_sid, on: :create
|
before_validation :set_sid, on: :create
|
||||||
|
|
||||||
# Parse scopes_granted into an array
|
# Parse scopes_granted into an array
|
||||||
def scopes
|
def scopes
|
||||||
scopes_granted.split(' ')
|
scopes_granted.split(" ")
|
||||||
end
|
end
|
||||||
|
|
||||||
# Set scopes from an array
|
# Set scopes from an array
|
||||||
def scopes=(scope_array)
|
def scopes=(scope_array)
|
||||||
self.scopes_granted = Array(scope_array).uniq.join(' ')
|
self.scopes_granted = Array(scope_array).uniq.join(" ")
|
||||||
end
|
end
|
||||||
|
|
||||||
# Check if this consent covers the requested scopes
|
# Check if this consent covers the requested scopes
|
||||||
@@ -31,18 +31,18 @@ class OidcUserConsent < ApplicationRecord
|
|||||||
def formatted_scopes
|
def formatted_scopes
|
||||||
scopes.map do |scope|
|
scopes.map do |scope|
|
||||||
case scope
|
case scope
|
||||||
when 'openid'
|
when "openid"
|
||||||
'Basic authentication'
|
"Basic authentication"
|
||||||
when 'profile'
|
when "profile"
|
||||||
'Profile information'
|
"Profile information"
|
||||||
when 'email'
|
when "email"
|
||||||
'Email address'
|
"Email address"
|
||||||
when 'groups'
|
when "groups"
|
||||||
'Group membership'
|
"Group membership"
|
||||||
else
|
else
|
||||||
scope.humanize
|
scope.humanize
|
||||||
end
|
end
|
||||||
end.join(', ')
|
end.join(", ")
|
||||||
end
|
end
|
||||||
|
|
||||||
# Find consent by SID
|
# Find consent by SID
|
||||||
|
|||||||
@@ -29,16 +29,16 @@ class User < ApplicationRecord
|
|||||||
groups
|
groups
|
||||||
].freeze
|
].freeze
|
||||||
|
|
||||||
validates :email_address, presence: true, uniqueness: { case_sensitive: false },
|
validates :email_address, presence: true, uniqueness: {case_sensitive: false},
|
||||||
format: { with: URI::MailTo::EMAIL_REGEXP }
|
format: {with: URI::MailTo::EMAIL_REGEXP}
|
||||||
validates :username, uniqueness: { case_sensitive: false }, allow_nil: true,
|
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" },
|
format: {with: /\A[a-zA-Z0-9_-]+\z/, message: "can only contain letters, numbers, underscores, and hyphens"},
|
||||||
length: { minimum: 2, maximum: 30 }
|
length: {minimum: 2, maximum: 30}
|
||||||
validates :password, length: { minimum: 8 }, allow_nil: true
|
validates :password, length: {minimum: 8}, allow_nil: true
|
||||||
validate :no_reserved_claim_names
|
validate :no_reserved_claim_names
|
||||||
|
|
||||||
# Enum - automatically creates scopes (User.active, User.disabled, etc.)
|
# 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
|
# Scopes
|
||||||
scope :admins, -> { where(admin: true) }
|
scope :admins, -> { where(admin: true) }
|
||||||
@@ -122,12 +122,7 @@ class User < ApplicationRecord
|
|||||||
cache_key = "backup_code_failed_attempts_#{id}"
|
cache_key = "backup_code_failed_attempts_#{id}"
|
||||||
attempts = Rails.cache.read(cache_key) || 0
|
attempts = Rails.cache.read(cache_key) || 0
|
||||||
|
|
||||||
if attempts >= 5 # Allow max 5 failed attempts per hour
|
attempts >= 5
|
||||||
true
|
|
||||||
else
|
|
||||||
# Don't increment here - increment only on failed attempts
|
|
||||||
false
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Increment failed attempt counter
|
# Increment failed attempt counter
|
||||||
@@ -231,7 +226,7 @@ class User < ApplicationRecord
|
|||||||
|
|
||||||
reserved_used = parsed_custom_claims.keys.map(&:to_s) & RESERVED_CLAIMS
|
reserved_used = parsed_custom_claims.keys.map(&:to_s) & RESERVED_CLAIMS
|
||||||
if reserved_used.any?
|
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
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -2,5 +2,5 @@ class UserGroup < ApplicationRecord
|
|||||||
belongs_to :user
|
belongs_to :user
|
||||||
belongs_to :group
|
belongs_to :group
|
||||||
|
|
||||||
validates :user_id, uniqueness: { scope: :group_id }
|
validates :user_id, uniqueness: {scope: :group_id}
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ class WebauthnCredential < ApplicationRecord
|
|||||||
# Validations
|
# Validations
|
||||||
validates :external_id, presence: true, uniqueness: true
|
validates :external_id, presence: true, uniqueness: true
|
||||||
validates :public_key, presence: 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 :nickname, presence: true
|
||||||
validates :authenticator_type, inclusion: { in: %w[platform cross-platform] }
|
validates :authenticator_type, inclusion: {in: %w[platform cross-platform]}
|
||||||
|
|
||||||
# Scopes for querying
|
# Scopes for querying
|
||||||
scope :active, -> { where(nil) } # All credentials are active (we can add revoked_at later if needed)
|
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
|
days = hours / 24
|
||||||
|
|
||||||
if days > 0
|
if days > 0
|
||||||
"#{days.floor} day#{'s' if days > 1} ago"
|
"#{days.floor} day#{"s" if days > 1} ago"
|
||||||
elsif hours > 0
|
elsif hours > 0
|
||||||
"#{hours.floor} hour#{'s' if hours > 1} ago"
|
"#{hours.floor} hour#{"s" if hours > 1} ago"
|
||||||
elsif minutes > 0
|
elsif minutes > 0
|
||||||
"#{minutes.floor} minute#{'s' if minutes > 1} ago"
|
"#{minutes.floor} minute#{"s" if minutes > 1} ago"
|
||||||
else
|
else
|
||||||
"Just now"
|
"Just now"
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -13,20 +13,20 @@ module ClaimsMerger
|
|||||||
result = base.dup
|
result = base.dup
|
||||||
|
|
||||||
incoming.each do |key, value|
|
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 both values are arrays, combine them (union to avoid duplicates)
|
||||||
if result[key].is_a?(Array) && value.is_a?(Array)
|
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
|
# If both values are hashes, recursively merge them
|
||||||
elsif result[key].is_a?(Hash) && value.is_a?(Hash)
|
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
|
else
|
||||||
# Otherwise, incoming value wins (override)
|
# Otherwise, incoming value wins (override)
|
||||||
result[key] = value
|
value
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
# New key, just add it
|
# New key, just add it
|
||||||
result[key] = value
|
value
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ class OidcJwtService
|
|||||||
# Merge app-specific custom claims (highest priority, arrays are combined)
|
# Merge app-specific custom claims (highest priority, arrays are combined)
|
||||||
payload = deep_merge_claims(payload, application.custom_claims_for_user(user))
|
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
|
end
|
||||||
|
|
||||||
# Generate a backchannel logout token (JWT)
|
# Generate a backchannel logout token (JWT)
|
||||||
@@ -84,12 +84,12 @@ class OidcJwtService
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Important: Do NOT include nonce in logout tokens (spec requirement)
|
# 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
|
end
|
||||||
|
|
||||||
# Decode and verify an ID token
|
# Decode and verify an ID token
|
||||||
def decode_id_token(token)
|
def decode_id_token(token)
|
||||||
JWT.decode(token, public_key, true, { algorithm: "RS256" })
|
JWT.decode(token, public_key, true, {algorithm: "RS256"})
|
||||||
end
|
end
|
||||||
|
|
||||||
# Get the public key in JWK format for the JWKS endpoint
|
# Get the public key in JWK format for the JWKS endpoint
|
||||||
|
|||||||
@@ -2,6 +2,4 @@
|
|||||||
require "rubygems"
|
require "rubygems"
|
||||||
require "bundler/setup"
|
require "bundler/setup"
|
||||||
|
|
||||||
ARGV.unshift("--ensure-latest")
|
|
||||||
|
|
||||||
load Gem.bin_path("brakeman", "brakeman")
|
load Gem.bin_path("brakeman", "brakeman")
|
||||||
|
|||||||
@@ -24,16 +24,16 @@ module Clinch
|
|||||||
# config.time_zone = "Central Time (US & Canada)"
|
# config.time_zone = "Central Time (US & Canada)"
|
||||||
# config.eager_load_paths << Rails.root.join("extras")
|
# 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.delivery_method = :smtp
|
||||||
config.action_mailer.smtp_settings = {
|
config.action_mailer.smtp_settings = {
|
||||||
address: ENV.fetch('SMTP_ADDRESS', 'localhost'),
|
address: ENV.fetch("SMTP_ADDRESS", "localhost"),
|
||||||
port: ENV.fetch('SMTP_PORT', 587),
|
port: ENV.fetch("SMTP_PORT", 587),
|
||||||
domain: ENV.fetch('SMTP_DOMAIN', 'localhost'),
|
domain: ENV.fetch("SMTP_DOMAIN", "localhost"),
|
||||||
user_name: ENV.fetch('SMTP_USERNAME', nil),
|
user_name: ENV.fetch("SMTP_USERNAME", nil),
|
||||||
password: ENV.fetch('SMTP_PASSWORD', nil),
|
password: ENV.fetch("SMTP_PASSWORD", nil),
|
||||||
authentication: ENV.fetch('SMTP_AUTHENTICATION', 'plain').to_sym,
|
authentication: ENV.fetch("SMTP_AUTHENTICATION", "plain").to_sym,
|
||||||
enable_starttls_auto: ENV.fetch('SMTP_STARTTLS_AUTO', 'true') == 'true',
|
enable_starttls_auto: ENV.fetch("SMTP_STARTTLS_AUTO", "true") == "true",
|
||||||
openssl_verify_mode: OpenSSL::SSL::VERIFY_PEER
|
openssl_verify_mode: OpenSSL::SSL::VERIFY_PEER
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ Rails.application.configure do
|
|||||||
if Rails.root.join("tmp/caching-dev.txt").exist?
|
if Rails.root.join("tmp/caching-dev.txt").exist?
|
||||||
config.action_controller.perform_caching = true
|
config.action_controller.perform_caching = true
|
||||||
config.action_controller.enable_fragment_cache_logging = 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
|
else
|
||||||
config.action_controller.perform_caching = false
|
config.action_controller.perform_caching = false
|
||||||
end
|
end
|
||||||
@@ -39,10 +39,10 @@ Rails.application.configure do
|
|||||||
config.action_mailer.perform_caching = false
|
config.action_mailer.perform_caching = false
|
||||||
|
|
||||||
# Set localhost to be used by links generated in mailer templates.
|
# 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).
|
# 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.
|
# Print deprecation notices to the Rails logger.
|
||||||
config.active_support.deprecation = :log
|
config.active_support.deprecation = :log
|
||||||
@@ -62,7 +62,6 @@ Rails.application.configure do
|
|||||||
# Use async processor for background jobs in development
|
# Use async processor for background jobs in development
|
||||||
config.active_job.queue_adapter = :async
|
config.active_job.queue_adapter = :async
|
||||||
|
|
||||||
|
|
||||||
# Highlight code that triggered redirect in logs.
|
# Highlight code that triggered redirect in logs.
|
||||||
config.action_dispatch.verbose_redirect_logs = true
|
config.action_dispatch.verbose_redirect_logs = true
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ Rails.application.configure do
|
|||||||
config.action_controller.perform_caching = true
|
config.action_controller.perform_caching = true
|
||||||
|
|
||||||
# Cache assets for far-future expiry since they are all digest stamped.
|
# 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.
|
# Enable serving of images, stylesheets, and JavaScripts from an asset server.
|
||||||
# config.asset_host = "http://assets.example.com"
|
# 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: Rails already sets X-Content-Type-Options: nosniff by default
|
||||||
# Note: Permissions-Policy is configured in config/initializers/permissions_policy.rb
|
# Note: Permissions-Policy is configured in config/initializers/permissions_policy.rb
|
||||||
config.action_dispatch.default_headers.merge!(
|
config.action_dispatch.default_headers.merge!(
|
||||||
'X-Frame-Options' => 'DENY', # Override default SAMEORIGIN to prevent clickjacking
|
"X-Frame-Options" => "DENY", # Override default SAMEORIGIN to prevent clickjacking
|
||||||
'Referrer-Policy' => 'strict-origin-when-cross-origin' # Control referrer information
|
"Referrer-Policy" => "strict-origin-when-cross-origin" # Control referrer information
|
||||||
)
|
)
|
||||||
|
|
||||||
# Skip http-to-https redirect for the default health check endpoint.
|
# Skip http-to-https redirect for the default health check endpoint.
|
||||||
# config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } }
|
# config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } }
|
||||||
|
|
||||||
# Log to STDOUT with the current request id as a default log tag.
|
# Log to STDOUT with the current request id as a default log tag.
|
||||||
config.log_tags = [ :request_id ]
|
config.log_tags = [:request_id]
|
||||||
config.logger = ActiveSupport::TaggedLogging.logger(STDOUT)
|
config.logger = ActiveSupport::TaggedLogging.logger($stdout)
|
||||||
|
|
||||||
# Change to "debug" to log everything (including potentially personally-identifiable information!).
|
# Change to "debug" to log everything (including potentially personally-identifiable information!).
|
||||||
config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info")
|
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.
|
# Set host to be used by links generated in mailer templates.
|
||||||
config.action_mailer.default_url_options = {
|
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.
|
# 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
|
config.active_record.dump_schema_after_migration = false
|
||||||
|
|
||||||
# Only use :id for inspections in production.
|
# Only use :id for inspections in production.
|
||||||
config.active_record.attributes_for_inspect = [ :id ]
|
config.active_record.attributes_for_inspect = [:id]
|
||||||
|
|
||||||
# Helper method to extract domain from CLINCH_HOST (removes protocol if present)
|
# Helper method to extract domain from CLINCH_HOST (removes protocol if present)
|
||||||
def self.extract_domain(host)
|
def self.extract_domain(host)
|
||||||
return host if host.blank?
|
return host if host.blank?
|
||||||
# Remove protocol (http:// or https://) if present
|
# Remove protocol (http:// or https://) if present
|
||||||
host.gsub(/^https?:\/\//, '')
|
host.gsub(/^https?:\/\//, "")
|
||||||
end
|
end
|
||||||
|
|
||||||
# Helper method to ensure URL has https:// protocol
|
# 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.
|
# Enable DNS rebinding protection and other `Host` header attacks.
|
||||||
# Configure allowed hosts based on deployment scenario
|
# Configure allowed hosts based on deployment scenario
|
||||||
allowed_hosts = [
|
allowed_hosts = [
|
||||||
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
|
# 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?
|
if host_domain.present?
|
||||||
begin
|
begin
|
||||||
# Use PublicSuffix to properly extract the domain
|
# Use PublicSuffix to properly extract the domain
|
||||||
@@ -123,20 +123,20 @@ Rails.application.configure do
|
|||||||
rescue PublicSuffix::DomainInvalid
|
rescue PublicSuffix::DomainInvalid
|
||||||
# Fallback to simple domain extraction if PublicSuffix fails
|
# Fallback to simple domain extraction if PublicSuffix fails
|
||||||
Rails.logger.warn "Could not parse domain '#{host_domain}' with PublicSuffix, using fallback"
|
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)}/
|
allowed_hosts << /.*#{Regexp.escape(base_domain)}/
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Allow Docker service names if running in same compose
|
# Allow Docker service names if running in same compose
|
||||||
if ENV['CLINCH_DOCKER_SERVICE_NAME']
|
if ENV["CLINCH_DOCKER_SERVICE_NAME"]
|
||||||
allowed_hosts << ENV['CLINCH_DOCKER_SERVICE_NAME']
|
allowed_hosts << ENV["CLINCH_DOCKER_SERVICE_NAME"]
|
||||||
end
|
end
|
||||||
|
|
||||||
# Allow internal IP access for cross-compose or host networking
|
# 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
|
# Specific host IP
|
||||||
allowed_hosts << '192.168.2.246'
|
allowed_hosts << "192.168.2.246"
|
||||||
|
|
||||||
# Private IP ranges for internal network access
|
# Private IP ranges for internal network access
|
||||||
allowed_hosts += [
|
allowed_hosts += [
|
||||||
@@ -147,14 +147,14 @@ Rails.application.configure do
|
|||||||
end
|
end
|
||||||
|
|
||||||
# Local development fallbacks
|
# Local development fallbacks
|
||||||
if ENV['CLINCH_ALLOW_LOCALHOST'] == 'true'
|
if ENV["CLINCH_ALLOW_LOCALHOST"] == "true"
|
||||||
allowed_hosts += ['localhost', '127.0.0.1', '0.0.0.0']
|
allowed_hosts += ["localhost", "127.0.0.1", "0.0.0.0"]
|
||||||
end
|
end
|
||||||
|
|
||||||
config.hosts = allowed_hosts
|
config.hosts = allowed_hosts
|
||||||
|
|
||||||
# Skip DNS rebinding protection for the default health check endpoint.
|
# 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
|
# Sentry configuration for production
|
||||||
# Only enabled if SENTRY_DSN environment variable is set
|
# Only enabled if SENTRY_DSN environment variable is set
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ Rails.application.configure do
|
|||||||
config.eager_load = ENV["CI"].present?
|
config.eager_load = ENV["CI"].present?
|
||||||
|
|
||||||
# Configure public file server for tests with cache-control for performance.
|
# 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.
|
# Show full error reports.
|
||||||
config.consider_all_requests_local = true
|
config.consider_all_requests_local = true
|
||||||
@@ -37,7 +37,7 @@ Rails.application.configure do
|
|||||||
config.action_mailer.delivery_method = :test
|
config.action_mailer.delivery_method = :test
|
||||||
|
|
||||||
# Set host to be used by links generated in mailer templates.
|
# 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.
|
# Print deprecation notices to the stderr.
|
||||||
config.active_support.deprecation = :stderr
|
config.active_support.deprecation = :stderr
|
||||||
|
|||||||
@@ -8,14 +8,14 @@
|
|||||||
# - ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT
|
# - ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT
|
||||||
|
|
||||||
# Use env vars if set, otherwise derive from SECRET_KEY_BASE (deterministic)
|
# Use env vars if set, otherwise derive from SECRET_KEY_BASE (deterministic)
|
||||||
primary_key = ENV.fetch('ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY') do
|
primary_key = ENV.fetch("ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY") do
|
||||||
Rails.application.key_generator.generate_key('active_record_encryption_primary', 32)
|
Rails.application.key_generator.generate_key("active_record_encryption_primary", 32)
|
||||||
end
|
end
|
||||||
deterministic_key = ENV.fetch('ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY') do
|
deterministic_key = ENV.fetch("ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY") do
|
||||||
Rails.application.key_generator.generate_key('active_record_encryption_deterministic', 32)
|
Rails.application.key_generator.generate_key("active_record_encryption_deterministic", 32)
|
||||||
end
|
end
|
||||||
key_derivation_salt = ENV.fetch('ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT') do
|
key_derivation_salt = ENV.fetch("ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT") do
|
||||||
Rails.application.key_generator.generate_key('active_record_encryption_salt', 32)
|
Rails.application.key_generator.generate_key("active_record_encryption_salt", 32)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Configure Rails 7.1+ ActiveRecord encryption
|
# Configure Rails 7.1+ ActiveRecord encryption
|
||||||
|
|||||||
@@ -56,10 +56,9 @@ Rails.application.configure do
|
|||||||
policy.require_trusted_types_for :none
|
policy.require_trusted_types_for :none
|
||||||
|
|
||||||
# CSP reporting using report_uri (supported method)
|
# CSP reporting using report_uri (supported method)
|
||||||
policy.report_uri "/api/csp-violation-report"
|
policy.report_uri "/api/csp-violation-report"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
||||||
# Start with CSP in report-only mode for testing
|
# Start with CSP in report-only mode for testing
|
||||||
# Set to false after verifying everything works in production
|
# Set to false after verifying everything works in production
|
||||||
config.content_security_policy_report_only = Rails.env.development?
|
config.content_security_policy_report_only = Rails.env.development?
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ Rails.application.config.after_initialize do
|
|||||||
# Configure log rotation
|
# Configure log rotation
|
||||||
csp_logger = Logger.new(
|
csp_logger = Logger.new(
|
||||||
csp_log_path,
|
csp_log_path,
|
||||||
'daily', # Rotate daily
|
"daily", # Rotate daily
|
||||||
30 # Keep 30 old log files
|
30 # Keep 30 old log files
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ Rails.application.config.after_initialize do
|
|||||||
|
|
||||||
# Format: [TIMESTAMP] LEVEL MESSAGE
|
# Format: [TIMESTAMP] LEVEL MESSAGE
|
||||||
csp_logger.formatter = proc do |severity, datetime, progname, msg|
|
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
|
end
|
||||||
|
|
||||||
module CspViolationLocalLogger
|
module CspViolationLocalLogger
|
||||||
@@ -25,9 +25,9 @@ Rails.application.config.after_initialize do
|
|||||||
|
|
||||||
# Skip logging if there's no meaningful violation data
|
# Skip logging if there's no meaningful violation data
|
||||||
return if csp_data.empty? ||
|
return if csp_data.empty? ||
|
||||||
(csp_data[:violated_directive].nil? &&
|
(csp_data[:violated_directive].nil? &&
|
||||||
csp_data[:blocked_uri].nil? &&
|
csp_data[:blocked_uri].nil? &&
|
||||||
csp_data[:document_uri].nil?)
|
csp_data[:document_uri].nil?)
|
||||||
|
|
||||||
# Build a structured log message
|
# Build a structured log message
|
||||||
violated_directive = csp_data[:violated_directive] || "unknown"
|
violated_directive = csp_data[:violated_directive] || "unknown"
|
||||||
@@ -69,7 +69,6 @@ Rails.application.config.after_initialize do
|
|||||||
|
|
||||||
# Also log to main Rails logger for visibility
|
# Also log to main Rails logger for visibility
|
||||||
Rails.logger.info "CSP violation logged to csp_violations.log: #{violated_directive} - #{blocked_uri}"
|
Rails.logger.info "CSP violation logged to csp_violations.log: #{violated_directive} - #{blocked_uri}"
|
||||||
|
|
||||||
rescue => e
|
rescue => e
|
||||||
# Ensure logger errors don't break the CSP reporting flow
|
# Ensure logger errors don't break the CSP reporting flow
|
||||||
Rails.logger.error "Failed to log CSP violation to file: #{e.message}"
|
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")
|
csp_log_path = Rails.root.join("log", "csp_violations.log")
|
||||||
logger = Logger.new(
|
logger = Logger.new(
|
||||||
csp_log_path,
|
csp_log_path,
|
||||||
'daily', # Rotate daily
|
"daily", # Rotate daily
|
||||||
30 # Keep 30 old log files
|
30 # Keep 30 old log files
|
||||||
)
|
)
|
||||||
logger.level = Logger::INFO
|
logger.level = Logger::INFO
|
||||||
logger.formatter = proc do |severity, datetime, progname, msg|
|
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
|
end
|
||||||
logger
|
logger
|
||||||
end
|
end
|
||||||
@@ -120,7 +119,6 @@ Rails.application.config.after_initialize do
|
|||||||
|
|
||||||
# Test write to ensure permissions are correct
|
# Test write to ensure permissions are correct
|
||||||
csp_logger.info "CSP Logger initialized at #{Time.current}"
|
csp_logger.info "CSP Logger initialized at #{Time.current}"
|
||||||
|
|
||||||
rescue => e
|
rescue => e
|
||||||
Rails.logger.error "Failed to initialize CSP local logger: #{e.message}"
|
Rails.logger.error "Failed to initialize CSP local logger: #{e.message}"
|
||||||
Rails.logger.error "CSP violations will only be sent to Sentry (if configured)"
|
Rails.logger.error "CSP violations will only be sent to Sentry (if configured)"
|
||||||
|
|||||||
@@ -3,12 +3,12 @@
|
|||||||
|
|
||||||
Rails.application.config.permissions_policy do |f|
|
Rails.application.config.permissions_policy do |f|
|
||||||
# Disable sensitive browser features for security
|
# Disable sensitive browser features for security
|
||||||
f.camera :none
|
f.camera :none
|
||||||
f.gyroscope :none
|
f.gyroscope :none
|
||||||
f.microphone :none
|
f.microphone :none
|
||||||
f.payment :none
|
f.payment :none
|
||||||
f.usb :none
|
f.usb :none
|
||||||
f.magnetometer :none
|
f.magnetometer :none
|
||||||
|
|
||||||
# You can enable specific features as needed:
|
# You can enable specific features as needed:
|
||||||
# f.fullscreen :self
|
# f.fullscreen :self
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ Rails.application.configure do
|
|||||||
app_environment: Rails.env,
|
app_environment: Rails.env,
|
||||||
# Add CSP policy status
|
# Add CSP policy status
|
||||||
csp_enabled: defined?(Rails.application.config.content_security_policy) &&
|
csp_enabled: defined?(Rails.application.config.content_security_policy) &&
|
||||||
Rails.application.config.content_security_policy.present?
|
Rails.application.config.content_security_policy.present?
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -120,13 +120,13 @@ Rails.application.configure do
|
|||||||
if breadcrumb[:data]
|
if breadcrumb[:data]
|
||||||
breadcrumb[:data].reject! { |key, value|
|
breadcrumb[:data].reject! { |key, value|
|
||||||
key.to_s.match?(/password|secret|token|key|authorization/i) ||
|
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
|
end
|
||||||
|
|
||||||
# Mark CSP-related events
|
# Mark CSP-related events
|
||||||
if breadcrumb[:message]&.include?("CSP Violation") ||
|
if breadcrumb[:message]&.include?("CSP Violation") ||
|
||||||
breadcrumb[:category]&.include?("csp")
|
breadcrumb[:category]&.include?("csp")
|
||||||
breadcrumb[:data] ||= {}
|
breadcrumb[:data] ||= {}
|
||||||
breadcrumb[:data][:security_event] = true
|
breadcrumb[:data][:security_event] = true
|
||||||
breadcrumb[:data][:csp_violation] = true
|
breadcrumb[:data][:csp_violation] = true
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ Rails.application.config.after_initialize do
|
|||||||
timestamp: csp_data[:timestamp]
|
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
|
# Log to Rails logger for redundancy
|
||||||
@@ -69,10 +69,10 @@ Rails.application.config.after_initialize do
|
|||||||
parsed.host
|
parsed.host
|
||||||
rescue URI::InvalidURIError
|
rescue URI::InvalidURIError
|
||||||
# Handle cases where URI might be malformed or just a path
|
# 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
|
nil # It's a relative path, no domain
|
||||||
else
|
else
|
||||||
uri.split('/').first # Best effort extraction
|
uri.split("/").first # Best effort extraction
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -3,5 +3,5 @@
|
|||||||
# Derived from SECRET_KEY_BASE - no storage needed, deterministic output
|
# Derived from SECRET_KEY_BASE - no storage needed, deterministic output
|
||||||
# Optional: Set OIDC_TOKEN_PREFIX_HMAC env var to override with explicit key
|
# Optional: Set OIDC_TOKEN_PREFIX_HMAC env var to override with explicit key
|
||||||
module TokenHmac
|
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
|
end
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ threads threads_count, threads_count
|
|||||||
# Specifies the `port` that Puma will listen on to receive requests; default is 3000.
|
# Specifies the `port` that Puma will listen on to receive requests; default is 3000.
|
||||||
port ENV.fetch("PORT", 3000)
|
port ENV.fetch("PORT", 3000)
|
||||||
|
|
||||||
|
|
||||||
# Allow puma to be restarted by `bin/rails restart` command.
|
# Allow puma to be restarted by `bin/rails restart` command.
|
||||||
plugin :tmp_restart
|
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.
|
# 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.
|
# 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
|
# Authentication routes
|
||||||
get "/signup", to: "users#new", as: :signup
|
get "/signup", to: "users#new", as: :signup
|
||||||
@@ -61,21 +61,21 @@ Rails.application.routes.draw do
|
|||||||
end
|
end
|
||||||
|
|
||||||
# TOTP (2FA) routes
|
# TOTP (2FA) routes
|
||||||
get '/totp/new', to: 'totp#new', as: :new_totp
|
get "/totp/new", to: "totp#new", as: :new_totp
|
||||||
post '/totp', to: 'totp#create', as: :totp
|
post "/totp", to: "totp#create", as: :totp
|
||||||
delete '/totp', to: 'totp#destroy'
|
delete "/totp", to: "totp#destroy"
|
||||||
get '/totp/backup_codes', to: 'totp#backup_codes', as: :backup_codes_totp
|
get "/totp/backup_codes", to: "totp#backup_codes", as: :backup_codes_totp
|
||||||
post '/totp/verify_password', to: 'totp#verify_password', as: :verify_password_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
|
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/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
|
post "/totp/complete_setup", to: "totp#complete_setup", as: :complete_totp_setup
|
||||||
|
|
||||||
# WebAuthn (Passkeys) routes
|
# WebAuthn (Passkeys) routes
|
||||||
get '/webauthn/new', to: 'webauthn#new', as: :new_webauthn
|
get "/webauthn/new", to: "webauthn#new", as: :new_webauthn
|
||||||
post '/webauthn/challenge', to: 'webauthn#challenge'
|
post "/webauthn/challenge", to: "webauthn#challenge"
|
||||||
post '/webauthn/create', to: 'webauthn#create'
|
post "/webauthn/create", to: "webauthn#create"
|
||||||
delete '/webauthn/:id', to: 'webauthn#destroy', as: :webauthn_credential
|
delete "/webauthn/:id", to: "webauthn#destroy", as: :webauthn_credential
|
||||||
get '/webauthn/check', to: 'webauthn#check'
|
get "/webauthn/check", to: "webauthn#check"
|
||||||
|
|
||||||
# Admin routes
|
# Admin routes
|
||||||
namespace :admin do
|
namespace :admin do
|
||||||
|
|||||||
@@ -7,6 +7,6 @@ class CreateUserGroups < ActiveRecord::Migration[8.1]
|
|||||||
t.timestamps
|
t.timestamps
|
||||||
end
|
end
|
||||||
|
|
||||||
add_index :user_groups, [ :user_id, :group_id ], unique: true
|
add_index :user_groups, [:user_id, :group_id], unique: true
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -7,6 +7,6 @@ class CreateApplicationGroups < ActiveRecord::Migration[8.1]
|
|||||||
t.timestamps
|
t.timestamps
|
||||||
end
|
end
|
||||||
|
|
||||||
add_index :application_groups, [ :application_id, :group_id ], unique: true
|
add_index :application_groups, [:application_id, :group_id], unique: true
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -13,6 +13,6 @@ class CreateOidcAuthorizationCodes < ActiveRecord::Migration[8.1]
|
|||||||
end
|
end
|
||||||
add_index :oidc_authorization_codes, :code, unique: true
|
add_index :oidc_authorization_codes, :code, unique: true
|
||||||
add_index :oidc_authorization_codes, :expires_at
|
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
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -11,6 +11,6 @@ class CreateOidcAccessTokens < ActiveRecord::Migration[8.1]
|
|||||||
end
|
end
|
||||||
add_index :oidc_access_tokens, :token, unique: true
|
add_index :oidc_access_tokens, :token, unique: true
|
||||||
add_index :oidc_access_tokens, :expires_at
|
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
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
class AddRoleMappingToApplications < ActiveRecord::Migration[8.1]
|
class AddRoleMappingToApplications < ActiveRecord::Migration[8.1]
|
||||||
def change
|
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, :role_prefix, :string
|
||||||
add_column :applications, :managed_permissions, :json, default: {}
|
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|
|
create_table :application_roles do |t|
|
||||||
t.references :application, null: false, foreign_key: true
|
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|
|
create_table :user_role_assignments do |t|
|
||||||
t.references :user, null: false, foreign_key: true
|
t.references :user, null: false, foreign_key: true
|
||||||
t.references :application_role, 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.json :metadata, default: {}
|
||||||
|
|
||||||
t.timestamps
|
t.timestamps
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ class MigrateForwardAuthRulesToApplications < ActiveRecord::Migration[8.1]
|
|||||||
app = application_class.create!(
|
app = application_class.create!(
|
||||||
name: rule.domain_pattern.titleize,
|
name: rule.domain_pattern.titleize,
|
||||||
slug: rule.domain_pattern.parameterize.presence || "forward-auth-#{rule.id}",
|
slug: rule.domain_pattern.parameterize.presence || "forward-auth-#{rule.id}",
|
||||||
app_type: 'forward_auth',
|
app_type: "forward_auth",
|
||||||
domain_pattern: rule.domain_pattern,
|
domain_pattern: rule.domain_pattern,
|
||||||
headers_config: rule.headers_config || {},
|
headers_config: rule.headers_config || {},
|
||||||
active: rule.active
|
active: rule.active
|
||||||
@@ -59,7 +59,7 @@ class MigrateForwardAuthRulesToApplications < ActiveRecord::Migration[8.1]
|
|||||||
|
|
||||||
def down
|
def down
|
||||||
# Remove all forward_auth applications created by this migration
|
# 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
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ class CreateWebauthnCredentials < ActiveRecord::Migration[8.1]
|
|||||||
t.references :user, null: false, foreign_key: true, index: true
|
t.references :user, null: false, foreign_key: true, index: true
|
||||||
|
|
||||||
# WebAuthn specification fields
|
# 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.string :public_key, null: false # public key (base64)
|
||||||
t.integer :sign_count, null: false, default: 0 # signature counter (clone detection)
|
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, :expires_at
|
||||||
add_index :oidc_refresh_tokens, :revoked_at
|
add_index :oidc_refresh_tokens, :revoked_at
|
||||||
add_index :oidc_refresh_tokens, :token_family_id
|
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
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
class CreateApplicationUserClaims < ActiveRecord::Migration[8.1]
|
class CreateApplicationUserClaims < ActiveRecord::Migration[8.1]
|
||||||
def change
|
def change
|
||||||
create_table :application_user_claims do |t|
|
create_table :application_user_claims do |t|
|
||||||
t.references :application, 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.references :user, null: false, foreign_key: {on_delete: :cascade}
|
||||||
t.json :custom_claims, default: {}, null: false
|
t.json :custom_claims, default: {}, null: false
|
||||||
|
|
||||||
t.timestamps
|
t.timestamps
|
||||||
end
|
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
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ class CreateActiveStorageTables < ActiveRecord::Migration[7.0]
|
|||||||
primary_key_type, foreign_key_type = primary_and_foreign_key_types
|
primary_key_type, foreign_key_type = primary_and_foreign_key_types
|
||||||
|
|
||||||
create_table :active_storage_blobs, id: primary_key_type do |t|
|
create_table :active_storage_blobs, id: primary_key_type do |t|
|
||||||
t.string :key, null: false
|
t.string :key, null: false
|
||||||
t.string :filename, null: false
|
t.string :filename, null: false
|
||||||
t.string :content_type
|
t.string :content_type
|
||||||
t.text :metadata
|
t.text :metadata
|
||||||
t.string :service_name, null: false
|
t.string :service_name, null: false
|
||||||
t.bigint :byte_size, null: false
|
t.bigint :byte_size, null: false
|
||||||
t.string :checksum
|
t.string :checksum
|
||||||
|
|
||||||
if connection.supports_datetime_with_precision?
|
if connection.supports_datetime_with_precision?
|
||||||
t.datetime :created_at, precision: 6, null: false
|
t.datetime :created_at, precision: 6, null: false
|
||||||
@@ -19,13 +19,13 @@ class CreateActiveStorageTables < ActiveRecord::Migration[7.0]
|
|||||||
t.datetime :created_at, null: false
|
t.datetime :created_at, null: false
|
||||||
end
|
end
|
||||||
|
|
||||||
t.index [ :key ], unique: true
|
t.index [:key], unique: true
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table :active_storage_attachments, id: primary_key_type do |t|
|
create_table :active_storage_attachments, id: primary_key_type do |t|
|
||||||
t.string :name, null: false
|
t.string :name, null: false
|
||||||
t.references :record, null: false, polymorphic: true, index: false, type: foreign_key_type
|
t.references :record, null: false, polymorphic: true, index: false, type: foreign_key_type
|
||||||
t.references :blob, null: false, type: foreign_key_type
|
t.references :blob, null: false, type: foreign_key_type
|
||||||
|
|
||||||
if connection.supports_datetime_with_precision?
|
if connection.supports_datetime_with_precision?
|
||||||
t.datetime :created_at, precision: 6, null: false
|
t.datetime :created_at, precision: 6, null: false
|
||||||
@@ -33,7 +33,7 @@ class CreateActiveStorageTables < ActiveRecord::Migration[7.0]
|
|||||||
t.datetime :created_at, null: false
|
t.datetime :created_at, null: false
|
||||||
end
|
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
|
t.foreign_key :active_storage_blobs, column: :blob_id
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -41,17 +41,18 @@ class CreateActiveStorageTables < ActiveRecord::Migration[7.0]
|
|||||||
t.belongs_to :blob, null: false, index: false, type: foreign_key_type
|
t.belongs_to :blob, null: false, index: false, type: foreign_key_type
|
||||||
t.string :variation_digest, null: false
|
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
|
t.foreign_key :active_storage_blobs, column: :blob_id
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
def primary_and_foreign_key_types
|
|
||||||
config = Rails.configuration.generators
|
def primary_and_foreign_key_types
|
||||||
setting = config.options[config.orm][:primary_key_type]
|
config = Rails.configuration.generators
|
||||||
primary_key_type = setting || :primary_key
|
setting = config.options[config.orm][:primary_key_type]
|
||||||
foreign_key_type = setting || :bigint
|
primary_key_type = setting || :primary_key
|
||||||
[ primary_key_type, foreign_key_type ]
|
foreign_key_type = setting || :bigint
|
||||||
end
|
[primary_key_type, foreign_key_type]
|
||||||
|
end
|
||||||
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"
|
require "test_helper"
|
||||||
|
|
||||||
class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
|
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
|
end
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ module Api
|
|||||||
|
|
||||||
# Authentication Tests
|
# Authentication Tests
|
||||||
test "should redirect to login when no session cookie" do
|
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_response 302
|
||||||
assert_match %r{/signin}, response.location
|
assert_match %r{/signin}, response.location
|
||||||
@@ -23,7 +23,7 @@ module Api
|
|||||||
test "should redirect when user is inactive" do
|
test "should redirect when user is inactive" do
|
||||||
sign_in_as(@inactive_user)
|
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_response 302
|
||||||
assert_equal "User account is not active", response.headers["x-auth-reason"]
|
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
|
test "should return 200 when user is authenticated" do
|
||||||
sign_in_as(@user)
|
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_response 200
|
||||||
end
|
end
|
||||||
@@ -41,7 +41,7 @@ module Api
|
|||||||
test "should return 200 when matching rule exists" do
|
test "should return 200 when matching rule exists" do
|
||||||
sign_in_as(@user)
|
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_response 200
|
||||||
end
|
end
|
||||||
@@ -49,7 +49,7 @@ module Api
|
|||||||
test "should return 403 when no rule matches (fail-closed security)" do
|
test "should return 403 when no rule matches (fail-closed security)" do
|
||||||
sign_in_as(@user)
|
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_response 403
|
||||||
assert_equal "No authentication rule configured for this domain", response.headers["x-auth-reason"]
|
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
|
test "should return 403 when rule exists but is inactive" do
|
||||||
sign_in_as(@user)
|
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_response 403
|
||||||
assert_equal "No authentication rule configured for this domain", response.headers["x-auth-reason"]
|
assert_equal "No authentication rule configured for this domain", response.headers["x-auth-reason"]
|
||||||
@@ -68,7 +68,7 @@ module Api
|
|||||||
@rule.allowed_groups << @group
|
@rule.allowed_groups << @group
|
||||||
sign_in_as(@user) # User not in 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_response 403
|
||||||
assert_match %r{permission to access this domain}, response.headers["x-auth-reason"]
|
assert_match %r{permission to access this domain}, response.headers["x-auth-reason"]
|
||||||
@@ -79,35 +79,35 @@ module Api
|
|||||||
@user.groups << @group
|
@user.groups << @group
|
||||||
sign_in_as(@user)
|
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_response 200
|
||||||
end
|
end
|
||||||
|
|
||||||
# Domain Pattern Tests
|
# Domain Pattern Tests
|
||||||
test "should match wildcard domains correctly" do
|
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)
|
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
|
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
|
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_response 403 # No rule configured - fail-closed
|
||||||
assert_equal "No authentication rule configured for this domain", response.headers["x-auth-reason"]
|
assert_equal "No authentication rule configured for this domain", response.headers["x-auth-reason"]
|
||||||
end
|
end
|
||||||
|
|
||||||
test "should match exact domains correctly" do
|
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)
|
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
|
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_response 403 # No rule configured - fail-closed
|
||||||
assert_equal "No authentication rule configured for this domain", response.headers["x-auth-reason"]
|
assert_equal "No authentication rule configured for this domain", response.headers["x-auth-reason"]
|
||||||
end
|
end
|
||||||
@@ -116,7 +116,7 @@ module Api
|
|||||||
test "should return default headers when rule has no custom config" do
|
test "should return default headers when rule has no custom config" do
|
||||||
sign_in_as(@user)
|
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_response 200
|
||||||
assert_equal @user.email_address, response.headers["x-remote-user"]
|
assert_equal @user.email_address, response.headers["x-remote-user"]
|
||||||
@@ -126,7 +126,7 @@ module Api
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "should return custom headers when configured" do
|
test "should return custom headers when configured" do
|
||||||
custom_rule = Application.create!(
|
Application.create!(
|
||||||
name: "Custom App",
|
name: "Custom App",
|
||||||
slug: "custom-app",
|
slug: "custom-app",
|
||||||
app_type: "forward_auth",
|
app_type: "forward_auth",
|
||||||
@@ -140,7 +140,7 @@ module Api
|
|||||||
)
|
)
|
||||||
sign_in_as(@user)
|
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_response 200
|
||||||
assert_equal @user.email_address, response.headers["x-webauth-user"]
|
assert_equal @user.email_address, response.headers["x-webauth-user"]
|
||||||
@@ -151,17 +151,17 @@ module Api
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "should return no headers when all headers disabled" do
|
test "should return no headers when all headers disabled" do
|
||||||
no_headers_rule = Application.create!(
|
Application.create!(
|
||||||
name: "No Headers App",
|
name: "No Headers App",
|
||||||
slug: "no-headers-app",
|
slug: "no-headers-app",
|
||||||
app_type: "forward_auth",
|
app_type: "forward_auth",
|
||||||
domain_pattern: "noheaders.example.com",
|
domain_pattern: "noheaders.example.com",
|
||||||
active: true,
|
active: true,
|
||||||
headers_config: { user: "", email: "", name: "", groups: "", admin: "" }
|
headers_config: {user: "", email: "", name: "", groups: "", admin: ""}
|
||||||
)
|
)
|
||||||
sign_in_as(@user)
|
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
|
assert_response 200
|
||||||
# Check that auth-specific headers are not present (exclude Rails security headers)
|
# Check that auth-specific headers are not present (exclude Rails security headers)
|
||||||
@@ -173,7 +173,7 @@ module Api
|
|||||||
@user.groups << @group
|
@user.groups << @group
|
||||||
sign_in_as(@user)
|
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_response 200
|
||||||
groups_header = response.headers["x-remote-groups"]
|
groups_header = response.headers["x-remote-groups"]
|
||||||
@@ -186,7 +186,7 @@ module Api
|
|||||||
@user.groups.clear # Remove fixture groups
|
@user.groups.clear # Remove fixture groups
|
||||||
sign_in_as(@user)
|
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_response 200
|
||||||
assert_nil response.headers["x-remote-groups"]
|
assert_nil response.headers["x-remote-groups"]
|
||||||
@@ -195,7 +195,7 @@ module Api
|
|||||||
test "should include admin header correctly" do
|
test "should include admin header correctly" do
|
||||||
sign_in_as(@admin_user) # Assuming users(:two) is admin
|
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_response 200
|
||||||
assert_equal "true", response.headers["x-remote-admin"]
|
assert_equal "true", response.headers["x-remote-admin"]
|
||||||
@@ -207,7 +207,7 @@ module Api
|
|||||||
@user.groups << group2
|
@user.groups << group2
|
||||||
sign_in_as(@user)
|
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_response 200
|
||||||
groups_header = response.headers["x-remote-groups"]
|
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
|
test "should fall back to Host header when X-Forwarded-Host is missing" do
|
||||||
sign_in_as(@user)
|
sign_in_as(@user)
|
||||||
|
|
||||||
get "/api/verify", headers: { "Host" => "test.example.com" }
|
get "/api/verify", headers: {"Host" => "test.example.com"}
|
||||||
|
|
||||||
assert_response 200
|
assert_response 200
|
||||||
end
|
end
|
||||||
@@ -239,7 +239,7 @@ module Api
|
|||||||
long_domain = "a" * 250 + ".example.com"
|
long_domain = "a" * 250 + ".example.com"
|
||||||
sign_in_as(@user)
|
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_response 403 # No rule configured - fail-closed
|
||||||
assert_equal "No authentication rule configured for this domain", response.headers["x-auth-reason"]
|
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
|
test "should handle case insensitive domain matching" do
|
||||||
sign_in_as(@user)
|
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_response 200
|
||||||
end
|
end
|
||||||
@@ -262,7 +262,7 @@ module Api
|
|||||||
get "/api/verify", headers: {
|
get "/api/verify", headers: {
|
||||||
"X-Forwarded-Host" => "test.example.com",
|
"X-Forwarded-Host" => "test.example.com",
|
||||||
"X-Forwarded-Uri" => "/admin"
|
"X-Forwarded-Uri" => "/admin"
|
||||||
}, params: { rd: evil_url }
|
}, params: {rd: evil_url}
|
||||||
|
|
||||||
assert_response 302
|
assert_response 302
|
||||||
assert_match %r{/signin}, response.location
|
assert_match %r{/signin}, response.location
|
||||||
@@ -292,8 +292,8 @@ module Api
|
|||||||
# This should be allowed (domain has ForwardAuthRule)
|
# This should be allowed (domain has ForwardAuthRule)
|
||||||
allowed_url = "https://test.example.com/dashboard"
|
allowed_url = "https://test.example.com/dashboard"
|
||||||
|
|
||||||
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" },
|
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"},
|
||||||
params: { rd: allowed_url }
|
params: {rd: allowed_url}
|
||||||
|
|
||||||
assert_response 302
|
assert_response 302
|
||||||
assert_match allowed_url, response.location
|
assert_match allowed_url, response.location
|
||||||
@@ -305,8 +305,8 @@ module Api
|
|||||||
# This should be rejected (no ForwardAuthRule for evil-site.com)
|
# This should be rejected (no ForwardAuthRule for evil-site.com)
|
||||||
evil_url = "https://evil-site.com/steal-credentials"
|
evil_url = "https://evil-site.com/steal-credentials"
|
||||||
|
|
||||||
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" },
|
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"},
|
||||||
params: { rd: evil_url }
|
params: {rd: evil_url}
|
||||||
|
|
||||||
assert_response 302
|
assert_response 302
|
||||||
# Should redirect to login page or default URL, NOT to evil_url
|
# 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)
|
# This should be rejected (HTTP not HTTPS)
|
||||||
http_url = "http://test.example.com/dashboard"
|
http_url = "http://test.example.com/dashboard"
|
||||||
|
|
||||||
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" },
|
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"},
|
||||||
params: { rd: http_url }
|
params: {rd: http_url}
|
||||||
|
|
||||||
assert_response 302
|
assert_response 302
|
||||||
# Should redirect to login page or default URL, NOT to HTTP URL
|
# Should redirect to login page or default URL, NOT to HTTP URL
|
||||||
@@ -340,8 +340,8 @@ module Api
|
|||||||
]
|
]
|
||||||
|
|
||||||
dangerous_schemes.each do |dangerous_url|
|
dangerous_schemes.each do |dangerous_url|
|
||||||
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" },
|
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"},
|
||||||
params: { rd: dangerous_url }
|
params: {rd: dangerous_url}
|
||||||
|
|
||||||
assert_response 302, "Should reject dangerous URL: #{dangerous_url}"
|
assert_response 302, "Should reject dangerous URL: #{dangerous_url}"
|
||||||
# Should redirect to login page or default URL, NOT to dangerous URL
|
# Should redirect to login page or default URL, NOT to dangerous URL
|
||||||
@@ -355,7 +355,7 @@ module Api
|
|||||||
sign_in_as(@user)
|
sign_in_as(@user)
|
||||||
|
|
||||||
# Authenticated GET requests should return 200
|
# 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
|
assert_response 200
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -461,11 +461,11 @@ module Api
|
|||||||
sign_in_as(@user)
|
sign_in_as(@user)
|
||||||
|
|
||||||
# First request
|
# 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
|
assert_response 200
|
||||||
|
|
||||||
# Second request with same session
|
# 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
|
assert_response 200
|
||||||
|
|
||||||
# Should maintain user identity across requests
|
# Should maintain user identity across requests
|
||||||
@@ -481,8 +481,8 @@ module Api
|
|||||||
|
|
||||||
5.times do |i|
|
5.times do |i|
|
||||||
threads << Thread.new do
|
threads << Thread.new do
|
||||||
get "/api/verify", headers: { "X-Forwarded-Host" => "app#{i}.example.com" }
|
get "/api/verify", headers: {"X-Forwarded-Host" => "app#{i}.example.com"}
|
||||||
results << { status: response.status }
|
results << {status: response.status}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -524,7 +524,7 @@ module Api
|
|||||||
request_count = 10
|
request_count = 10
|
||||||
|
|
||||||
request_count.times do |i|
|
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
|
assert_response 403 # No rules configured for these domains
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -10,10 +10,14 @@ class AuthenticationTest < ActiveSupport::TestCase
|
|||||||
return nil if host.blank? || host.match?(/^(localhost|127\.0\.0\.1|::1)$/)
|
return nil if host.blank? || host.match?(/^(localhost|127\.0\.0\.1|::1)$/)
|
||||||
|
|
||||||
# Strip port number for domain parsing
|
# 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
|
# 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
|
# Use Public Suffix List for accurate domain parsing
|
||||||
domain = PublicSuffix.parse(host_without_port)
|
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)
|
user = User.create!(email_address: "xss_test@example.com", password: "password123", name: xss_payload)
|
||||||
|
|
||||||
# Sign in
|
# 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
|
assert_response :redirect
|
||||||
|
|
||||||
# Get a page that displays user name
|
# Get a page that displays user name
|
||||||
@@ -59,7 +59,7 @@ class InputValidationTest < ActionDispatch::IntegrationTest
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Sign in
|
# 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
|
assert_response :redirect
|
||||||
|
|
||||||
# Try to tamper with OAuth authorization parameters
|
# Try to tamper with OAuth authorization parameters
|
||||||
@@ -112,7 +112,7 @@ class InputValidationTest < ActionDispatch::IntegrationTest
|
|||||||
test "JSON input validation prevents malicious payloads" do
|
test "JSON input validation prevents malicious payloads" do
|
||||||
# Try to send malformed JSON
|
# Try to send malformed JSON
|
||||||
post "/oauth/token", params: '{"grant_type":"authorization_code",}'.to_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
|
# Should handle malformed JSON gracefully
|
||||||
assert_includes [400, 422], response.status
|
assert_includes [400, 422], response.status
|
||||||
@@ -124,9 +124,9 @@ class InputValidationTest < ActionDispatch::IntegrationTest
|
|||||||
grant_type: "authorization_code",
|
grant_type: "authorization_code",
|
||||||
code: "test_code",
|
code: "test_code",
|
||||||
redirect_uri: "http://localhost:4000/callback",
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
nested: { __proto__: "tampered", constructor: { prototype: "tampered" } }
|
nested: {__proto__: "tampered", constructor: {prototype: "tampered"}}
|
||||||
}.to_json,
|
}.to_json,
|
||||||
headers: { "CONTENT_TYPE" => "application/json" }
|
headers: {"CONTENT_TYPE" => "application/json"}
|
||||||
|
|
||||||
# Should sanitize or reject prototype pollution attempts
|
# Should sanitize or reject prototype pollution attempts
|
||||||
# The request should be handled (either accept or reject, not crash)
|
# 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|
|
malicious_paths.each do |malicious_path|
|
||||||
# Try to access files with path traversal
|
# 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
|
# Should prevent access to files outside public directory
|
||||||
assert_response :redirect, "Should reject path traversal attempt"
|
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
|
test "should destroy existing sessions when accepting invitation" do
|
||||||
# Create an existing session for the user
|
# Create an existing session for the user
|
||||||
existing_session = @user.sessions.create!
|
@user.sessions.create!
|
||||||
|
|
||||||
put invitation_path(@token), params: {
|
put invitation_path(@token), params: {
|
||||||
password: "newpassword123",
|
password: "newpassword123",
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
test "prevents authorization code reuse - sequential attempts" do
|
test "prevents authorization code reuse - sequential attempts" do
|
||||||
# Create consent
|
# Create consent
|
||||||
consent = OidcUserConsent.create!(
|
OidcUserConsent.create!(
|
||||||
user: @user,
|
user: @user,
|
||||||
application: @application,
|
application: @application,
|
||||||
scopes_granted: "openid profile",
|
scopes_granted: "openid profile",
|
||||||
@@ -81,7 +81,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
test "revokes existing tokens when authorization code is reused" do
|
test "revokes existing tokens when authorization code is reused" do
|
||||||
# Create consent
|
# Create consent
|
||||||
consent = OidcUserConsent.create!(
|
OidcUserConsent.create!(
|
||||||
user: @user,
|
user: @user,
|
||||||
application: @application,
|
application: @application,
|
||||||
scopes_granted: "openid profile",
|
scopes_granted: "openid profile",
|
||||||
@@ -135,7 +135,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
test "rejects already used authorization code" do
|
test "rejects already used authorization code" do
|
||||||
# Create consent
|
# Create consent
|
||||||
consent = OidcUserConsent.create!(
|
OidcUserConsent.create!(
|
||||||
user: @user,
|
user: @user,
|
||||||
application: @application,
|
application: @application,
|
||||||
scopes_granted: "openid profile",
|
scopes_granted: "openid profile",
|
||||||
@@ -171,7 +171,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
test "rejects expired authorization code" do
|
test "rejects expired authorization code" do
|
||||||
# Create consent
|
# Create consent
|
||||||
consent = OidcUserConsent.create!(
|
OidcUserConsent.create!(
|
||||||
user: @user,
|
user: @user,
|
||||||
application: @application,
|
application: @application,
|
||||||
scopes_granted: "openid profile",
|
scopes_granted: "openid profile",
|
||||||
@@ -206,7 +206,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
test "rejects authorization code with mismatched redirect_uri" do
|
test "rejects authorization code with mismatched redirect_uri" do
|
||||||
# Create consent
|
# Create consent
|
||||||
consent = OidcUserConsent.create!(
|
OidcUserConsent.create!(
|
||||||
user: @user,
|
user: @user,
|
||||||
application: @application,
|
application: @application,
|
||||||
scopes_granted: "openid profile",
|
scopes_granted: "openid profile",
|
||||||
@@ -256,7 +256,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
test "rejects authorization code for different application" do
|
test "rejects authorization code for different application" do
|
||||||
# Create consent for the first application
|
# Create consent for the first application
|
||||||
consent = OidcUserConsent.create!(
|
OidcUserConsent.create!(
|
||||||
user: @user,
|
user: @user,
|
||||||
application: @application,
|
application: @application,
|
||||||
scopes_granted: "openid profile",
|
scopes_granted: "openid profile",
|
||||||
@@ -308,7 +308,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
test "rejects invalid client_id in Basic auth" do
|
test "rejects invalid client_id in Basic auth" do
|
||||||
# Create consent
|
# Create consent
|
||||||
consent = OidcUserConsent.create!(
|
OidcUserConsent.create!(
|
||||||
user: @user,
|
user: @user,
|
||||||
application: @application,
|
application: @application,
|
||||||
scopes_granted: "openid profile",
|
scopes_granted: "openid profile",
|
||||||
@@ -341,7 +341,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
test "rejects invalid client_secret in Basic auth" do
|
test "rejects invalid client_secret in Basic auth" do
|
||||||
# Create consent
|
# Create consent
|
||||||
consent = OidcUserConsent.create!(
|
OidcUserConsent.create!(
|
||||||
user: @user,
|
user: @user,
|
||||||
application: @application,
|
application: @application,
|
||||||
scopes_granted: "openid profile",
|
scopes_granted: "openid profile",
|
||||||
@@ -374,7 +374,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
test "accepts client credentials in POST body" do
|
test "accepts client credentials in POST body" do
|
||||||
# Create consent
|
# Create consent
|
||||||
consent = OidcUserConsent.create!(
|
OidcUserConsent.create!(
|
||||||
user: @user,
|
user: @user,
|
||||||
application: @application,
|
application: @application,
|
||||||
scopes_granted: "openid profile",
|
scopes_granted: "openid profile",
|
||||||
@@ -408,7 +408,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
test "rejects request with no client authentication" do
|
test "rejects request with no client authentication" do
|
||||||
# Create consent
|
# Create consent
|
||||||
consent = OidcUserConsent.create!(
|
OidcUserConsent.create!(
|
||||||
user: @user,
|
user: @user,
|
||||||
application: @application,
|
application: @application,
|
||||||
scopes_granted: "openid profile",
|
scopes_granted: "openid profile",
|
||||||
@@ -474,7 +474,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
test "client authentication uses constant-time comparison" do
|
test "client authentication uses constant-time comparison" do
|
||||||
# Create consent
|
# Create consent
|
||||||
consent = OidcUserConsent.create!(
|
OidcUserConsent.create!(
|
||||||
user: @user,
|
user: @user,
|
||||||
application: @application,
|
application: @application,
|
||||||
scopes_granted: "openid profile",
|
scopes_granted: "openid profile",
|
||||||
@@ -546,7 +546,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Sign in first
|
# 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
|
# Test authorization with state parameter
|
||||||
get "/oauth/authorize", params: {
|
get "/oauth/authorize", params: {
|
||||||
@@ -573,7 +573,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Sign in first
|
# 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
|
# Test authorization without state parameter
|
||||||
get "/oauth/authorize", params: {
|
get "/oauth/authorize", params: {
|
||||||
@@ -593,7 +593,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
test "nonce parameter is included in ID token" do
|
test "nonce parameter is included in ID token" do
|
||||||
# Create consent
|
# Create consent
|
||||||
consent = OidcUserConsent.create!(
|
OidcUserConsent.create!(
|
||||||
user: @user,
|
user: @user,
|
||||||
application: @application,
|
application: @application,
|
||||||
scopes_granted: "openid profile",
|
scopes_granted: "openid profile",
|
||||||
@@ -637,7 +637,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
test "access tokens are not exposed in referer header" do
|
test "access tokens are not exposed in referer header" do
|
||||||
# Create consent and authorization code
|
# Create consent and authorization code
|
||||||
consent = OidcUserConsent.create!(
|
OidcUserConsent.create!(
|
||||||
user: @user,
|
user: @user,
|
||||||
application: @application,
|
application: @application,
|
||||||
scopes_granted: "openid profile",
|
scopes_granted: "openid profile",
|
||||||
@@ -664,7 +664,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
assert_response :success
|
assert_response :success
|
||||||
response_body = JSON.parse(@response.body)
|
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)
|
# Verify token is not in response headers (especially Referer)
|
||||||
assert_nil response.headers["Referer"], "Access token should not leak in Referer header"
|
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
|
test "PKCE code_verifier is required when code_challenge was provided" do
|
||||||
# Create consent
|
# Create consent
|
||||||
consent = OidcUserConsent.create!(
|
OidcUserConsent.create!(
|
||||||
user: @user,
|
user: @user,
|
||||||
application: @application,
|
application: @application,
|
||||||
scopes_granted: "openid profile",
|
scopes_granted: "openid profile",
|
||||||
@@ -716,7 +716,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
test "PKCE with S256 method validates correctly" do
|
test "PKCE with S256 method validates correctly" do
|
||||||
# Create consent
|
# Create consent
|
||||||
consent = OidcUserConsent.create!(
|
OidcUserConsent.create!(
|
||||||
user: @user,
|
user: @user,
|
||||||
application: @application,
|
application: @application,
|
||||||
scopes_granted: "openid profile",
|
scopes_granted: "openid profile",
|
||||||
@@ -755,7 +755,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
test "PKCE rejects invalid code_verifier" do
|
test "PKCE rejects invalid code_verifier" do
|
||||||
# Create consent
|
# Create consent
|
||||||
consent = OidcUserConsent.create!(
|
OidcUserConsent.create!(
|
||||||
user: @user,
|
user: @user,
|
||||||
application: @application,
|
application: @application,
|
||||||
scopes_granted: "openid profile",
|
scopes_granted: "openid profile",
|
||||||
@@ -798,7 +798,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
test "refresh token rotation is enforced" do
|
test "refresh token rotation is enforced" do
|
||||||
# Create consent for the refresh token endpoint
|
# Create consent for the refresh token endpoint
|
||||||
consent = OidcUserConsent.create!(
|
OidcUserConsent.create!(
|
||||||
user: @user,
|
user: @user,
|
||||||
application: @application,
|
application: @application,
|
||||||
scopes_granted: "openid profile",
|
scopes_granted: "openid profile",
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "authorization endpoint accepts PKCE parameters (S256)" do
|
test "authorization endpoint accepts PKCE parameters (S256)" do
|
||||||
code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
|
||||||
code_challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
|
code_challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
|
||||||
|
|
||||||
auth_params = {
|
auth_params = {
|
||||||
@@ -56,7 +55,7 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
# Should show consent page (user is already authenticated)
|
# Should show consent page (user is already authenticated)
|
||||||
assert_response :success
|
assert_response :success
|
||||||
assert_match /consent/, @response.body.downcase
|
assert_match(/consent/, @response.body.downcase)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "authorization endpoint accepts PKCE parameters (plain)" do
|
test "authorization endpoint accepts PKCE parameters (plain)" do
|
||||||
@@ -77,7 +76,7 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
# Should show consent page (user is already authenticated)
|
# Should show consent page (user is already authenticated)
|
||||||
assert_response :success
|
assert_response :success
|
||||||
assert_match /consent/, @response.body.downcase
|
assert_match(/consent/, @response.body.downcase)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "authorization endpoint rejects invalid code_challenge_method" do
|
test "authorization endpoint rejects invalid code_challenge_method" do
|
||||||
@@ -478,7 +477,7 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
|
|||||||
assert_response :bad_request
|
assert_response :bad_request
|
||||||
error = JSON.parse(@response.body)
|
error = JSON.parse(@response.body)
|
||||||
assert_equal "invalid_request", error["error"]
|
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
|
# Cleanup
|
||||||
OidcRefreshToken.where(application: public_app).delete_all
|
OidcRefreshToken.where(application: public_app).delete_all
|
||||||
@@ -525,7 +524,7 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
|
|||||||
assert_response :bad_request
|
assert_response :bad_request
|
||||||
error = JSON.parse(@response.body)
|
error = JSON.parse(@response.body)
|
||||||
assert_equal "invalid_request", error["error"]
|
assert_equal "invalid_request", error["error"]
|
||||||
assert_match /PKCE is required/, error["error_description"]
|
assert_match(/PKCE is required/, error["error_description"])
|
||||||
end
|
end
|
||||||
|
|
||||||
# ====================
|
# ====================
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ class PasswordsControllerTest < ActionDispatch::IntegrationTest
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "create" do
|
test "create" do
|
||||||
post passwords_path, params: { email_address: @user.email_address }
|
post passwords_path, params: {email_address: @user.email_address}
|
||||||
assert_enqueued_email_with PasswordsMailer, :reset, args: [ @user ]
|
assert_enqueued_email_with PasswordsMailer, :reset, args: [@user]
|
||||||
assert_redirected_to signin_path
|
assert_redirected_to signin_path
|
||||||
|
|
||||||
follow_redirect!
|
follow_redirect!
|
||||||
@@ -18,7 +18,7 @@ class PasswordsControllerTest < ActionDispatch::IntegrationTest
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "create for an unknown user redirects but sends no mail" do
|
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_enqueued_emails 0
|
||||||
assert_redirected_to signin_path
|
assert_redirected_to signin_path
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ class PasswordsControllerTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
test "update" do
|
test "update" do
|
||||||
assert_changes -> { @user.reload.password_digest } 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
|
assert_redirected_to signin_path
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ class PasswordsControllerTest < ActionDispatch::IntegrationTest
|
|||||||
test "update with non matching passwords" do
|
test "update with non matching passwords" do
|
||||||
token = @user.password_reset_token
|
token = @user.password_reset_token
|
||||||
assert_no_changes -> { @user.reload.password_digest } do
|
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)
|
assert_redirected_to edit_password_path(token)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -61,7 +61,8 @@ class PasswordsControllerTest < ActionDispatch::IntegrationTest
|
|||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
def assert_notice(text)
|
|
||||||
assert_select "div", /#{text}/
|
def assert_notice(text)
|
||||||
end
|
assert_select "div", /#{text}/
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -9,14 +9,14 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "create with valid credentials" do
|
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_redirected_to root_path
|
||||||
assert cookies[:session_id]
|
assert cookies[:session_id]
|
||||||
end
|
end
|
||||||
|
|
||||||
test "create with invalid credentials" do
|
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_redirected_to signin_path
|
||||||
assert_nil cookies[:session_id]
|
assert_nil cookies[:session_id]
|
||||||
|
|||||||
@@ -14,11 +14,11 @@ class TotpSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
valid_code = totp.now
|
valid_code = totp.now
|
||||||
|
|
||||||
# Set up pending TOTP session
|
# 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
|
assert_redirected_to totp_verification_path
|
||||||
|
|
||||||
# First use of the code should succeed
|
# 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_response :redirect
|
||||||
assert_redirected_to root_path
|
assert_redirected_to root_path
|
||||||
|
|
||||||
@@ -50,12 +50,12 @@ class TotpSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
original_codes = user.reload.backup_codes
|
original_codes = user.reload.backup_codes
|
||||||
|
|
||||||
# Set up pending TOTP session
|
# 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
|
assert_redirected_to totp_verification_path
|
||||||
|
|
||||||
# Use a backup code
|
# Use a backup code
|
||||||
backup_code = backup_codes.first
|
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
|
# Should successfully sign in
|
||||||
assert_response :redirect
|
assert_response :redirect
|
||||||
@@ -70,11 +70,11 @@ class TotpSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
assert_response :redirect
|
assert_response :redirect
|
||||||
|
|
||||||
# Sign in again
|
# 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
|
assert_redirected_to totp_verification_path
|
||||||
|
|
||||||
# Try the same backup code
|
# 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
|
# Should fail - backup code already used
|
||||||
assert_response :redirect
|
assert_response :redirect
|
||||||
@@ -91,13 +91,13 @@ class TotpSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
# Generate backup codes
|
# Generate backup codes
|
||||||
user.totp_secret = ROTP::Base32.random
|
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!
|
user.save!
|
||||||
|
|
||||||
# Check that stored codes are BCrypt hashes (start with $2a$)
|
# Check that stored codes are BCrypt hashes (start with $2a$)
|
||||||
# backup_codes is already an Array (JSON column), no need to parse
|
# backup_codes is already an Array (JSON column), no need to parse
|
||||||
user.backup_codes.each do |code|
|
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
|
end
|
||||||
|
|
||||||
user.destroy
|
user.destroy
|
||||||
@@ -116,7 +116,7 @@ class TotpSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
user.save!
|
user.save!
|
||||||
|
|
||||||
# Set up pending TOTP session
|
# 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
|
assert_redirected_to totp_verification_path
|
||||||
|
|
||||||
# Generate a TOTP code for a time far in the future (outside valid window)
|
# 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
|
future_code = totp.at(Time.now.to_i + 300) # 5 minutes in the future
|
||||||
|
|
||||||
# Try to use the future code
|
# 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
|
# Should fail - code is outside valid time window
|
||||||
assert_response :redirect
|
assert_response :redirect
|
||||||
@@ -145,16 +145,16 @@ class TotpSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
# Verify the TOTP secret exists (sanity check)
|
# Verify the TOTP secret exists (sanity check)
|
||||||
assert user.totp_secret.present?
|
assert user.totp_secret.present?
|
||||||
totp_secret = user.totp_secret
|
user.totp_secret
|
||||||
|
|
||||||
# Sign in with TOTP
|
# 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
|
assert_redirected_to totp_verification_path
|
||||||
|
|
||||||
# Complete TOTP verification
|
# Complete TOTP verification
|
||||||
totp = ROTP::TOTP.new(user.totp_secret)
|
totp = ROTP::TOTP.new(user.totp_secret)
|
||||||
valid_code = totp.now
|
valid_code = totp.now
|
||||||
post totp_verification_path, params: { code: valid_code }
|
post totp_verification_path, params: {code: valid_code}
|
||||||
assert_response :redirect
|
assert_response :redirect
|
||||||
|
|
||||||
# The TOTP secret should never be exposed in the response body or headers
|
# 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)
|
user.update!(totp_required: true, totp_secret: nil)
|
||||||
|
|
||||||
# Sign in
|
# 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
|
# Should redirect to TOTP setup, not verification
|
||||||
assert_response :redirect
|
assert_response :redirect
|
||||||
@@ -232,7 +232,7 @@ class TotpSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
user.save!
|
user.save!
|
||||||
|
|
||||||
# Set up pending TOTP session
|
# 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
|
assert_redirected_to totp_verification_path
|
||||||
|
|
||||||
# Try invalid formats
|
# Try invalid formats
|
||||||
@@ -245,7 +245,7 @@ class TotpSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
]
|
]
|
||||||
|
|
||||||
invalid_codes.each do |invalid_code|
|
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_response :redirect
|
||||||
assert_redirected_to totp_verification_path
|
assert_redirected_to totp_verification_path
|
||||||
end
|
end
|
||||||
@@ -266,11 +266,11 @@ class TotpSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
user.save!
|
user.save!
|
||||||
|
|
||||||
# Sign in
|
# 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
|
assert_redirected_to totp_verification_path
|
||||||
|
|
||||||
# Use backup code instead of TOTP
|
# 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
|
# Should successfully sign in
|
||||||
assert_response :redirect
|
assert_response :redirect
|
||||||
|
|||||||
@@ -20,27 +20,27 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
|
|||||||
# Basic Authentication Flow Tests
|
# Basic Authentication Flow Tests
|
||||||
test "complete authentication flow: unauthenticated to authenticated" do
|
test "complete authentication flow: unauthenticated to authenticated" do
|
||||||
# Step 1: Unauthenticated request should redirect
|
# 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_response 302
|
||||||
assert_match %r{/signin}, response.location
|
assert_match %r{/signin}, response.location
|
||||||
assert_equal "No session cookie", response.headers["x-auth-reason"]
|
assert_equal "No session cookie", response.headers["x-auth-reason"]
|
||||||
|
|
||||||
# Step 2: Sign in
|
# 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
|
assert_response 302
|
||||||
# Signin now redirects back with fa_token parameter
|
# Signin now redirects back with fa_token parameter
|
||||||
assert_match(/\?fa_token=/, response.location)
|
assert_match(/\?fa_token=/, response.location)
|
||||||
assert cookies[:session_id]
|
assert cookies[:session_id]
|
||||||
|
|
||||||
# Step 3: Authenticated request should succeed
|
# 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_response 200
|
||||||
assert_equal @user.email_address, response.headers["x-remote-user"]
|
assert_equal @user.email_address, response.headers["x-remote-user"]
|
||||||
end
|
end
|
||||||
|
|
||||||
test "session expiration handling" do
|
test "session expiration handling" do
|
||||||
# Sign in
|
# 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)
|
# Manually expire the session (get the most recent session for this user)
|
||||||
session = Session.where(user: @user).order(created_at: :desc).first
|
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)
|
session.update!(expires_at: 1.hour.ago)
|
||||||
|
|
||||||
# Request should fail and redirect to login
|
# 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_response 302
|
||||||
assert_equal "Session expired", response.headers["x-auth-reason"]
|
assert_equal "Session expired", response.headers["x-auth-reason"]
|
||||||
end
|
end
|
||||||
@@ -56,24 +56,24 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
|
|||||||
# Domain and Rule Integration Tests
|
# Domain and Rule Integration Tests
|
||||||
test "different domain patterns with same session" do
|
test "different domain patterns with same session" do
|
||||||
# Create test rules
|
# Create test rules
|
||||||
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)
|
||||||
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
|
# 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
|
# 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_response 200
|
||||||
assert_equal @user.email_address, response.headers["x-remote-user"]
|
assert_equal @user.email_address, response.headers["x-remote-user"]
|
||||||
|
|
||||||
# Test exact domain
|
# 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_response 200
|
||||||
assert_equal @user.email_address, response.headers["x-remote-user"]
|
assert_equal @user.email_address, response.headers["x-remote-user"]
|
||||||
|
|
||||||
# Test non-matching domain (should use defaults)
|
# 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_response 200
|
||||||
assert_equal @user.email_address, response.headers["x-remote-user"]
|
assert_equal @user.email_address, response.headers["x-remote-user"]
|
||||||
end
|
end
|
||||||
@@ -84,10 +84,10 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
|
|||||||
restricted_rule.allowed_groups << @group
|
restricted_rule.allowed_groups << @group
|
||||||
|
|
||||||
# Sign in user without 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
|
# 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_response 403
|
||||||
assert_match %r{permission to access this domain}, response.headers["x-auth-reason"]
|
assert_match %r{permission to access this domain}, response.headers["x-auth-reason"]
|
||||||
|
|
||||||
@@ -95,7 +95,7 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
|
|||||||
@user.groups << @group
|
@user.groups << @group
|
||||||
|
|
||||||
# Should now be allowed
|
# 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_response 200
|
||||||
assert_equal @user.email_address, response.headers["x-remote-user"]
|
assert_equal @user.email_address, response.headers["x-remote-user"]
|
||||||
end
|
end
|
||||||
@@ -103,18 +103,18 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
|
|||||||
# Header Configuration Integration Tests
|
# Header Configuration Integration Tests
|
||||||
test "different header configurations with same user" do
|
test "different header configurations with same user" do
|
||||||
# Create applications with different configs
|
# 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)
|
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: "Custom App", slug: "custom-app", app_type: "forward_auth",
|
name: "Custom App", slug: "custom-app", app_type: "forward_auth",
|
||||||
domain_pattern: "custom.example.com",
|
domain_pattern: "custom.example.com",
|
||||||
active: true,
|
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",
|
name: "No Headers App", slug: "no-headers-app", app_type: "forward_auth",
|
||||||
domain_pattern: "noheaders.example.com",
|
domain_pattern: "noheaders.example.com",
|
||||||
active: true,
|
active: true,
|
||||||
headers_config: { user: "", email: "", name: "", groups: "", admin: "" }
|
headers_config: {user: "", email: "", name: "", groups: "", admin: ""}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add user to groups
|
# Add user to groups
|
||||||
@@ -122,10 +122,10 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
|
|||||||
@user.groups << @group2
|
@user.groups << @group2
|
||||||
|
|
||||||
# Sign in
|
# 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
|
# 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
|
assert_response 200
|
||||||
# Rails normalizes header keys to lowercase
|
# Rails normalizes header keys to lowercase
|
||||||
assert_equal @user.email_address, response.headers["x-remote-user"]
|
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"]
|
assert_equal "Group Two,Group One", response.headers["x-remote-groups"]
|
||||||
|
|
||||||
# Test custom headers
|
# 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
|
assert_response 200
|
||||||
# Custom headers are also normalized to lowercase
|
# Custom headers are also normalized to lowercase
|
||||||
assert_equal @user.email_address, response.headers["x-webauth-user"]
|
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"]
|
assert_equal "Group Two,Group One", response.headers["x-webauth-roles"]
|
||||||
|
|
||||||
# Test no headers
|
# 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
|
assert_response 200
|
||||||
# Check that no auth-related headers are present (excluding security headers)
|
# 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) }
|
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: {
|
get "/api/verify", headers: {
|
||||||
"X-Forwarded-Host" => "app.example.com",
|
"X-Forwarded-Host" => "app.example.com",
|
||||||
"X-Forwarded-Uri" => "/admin"
|
"X-Forwarded-Uri" => "/admin"
|
||||||
}, params: { rd: "https://app.example.com/admin" }
|
}, params: {rd: "https://app.example.com/admin"}
|
||||||
|
|
||||||
assert_response 302
|
assert_response 302
|
||||||
location = response.location
|
location = response.location
|
||||||
@@ -194,16 +194,16 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
|
|||||||
admin_user = users(:two)
|
admin_user = users(:two)
|
||||||
|
|
||||||
# Create restricted rule
|
# Create restricted rule
|
||||||
admin_rule = Application.create!(
|
Application.create!(
|
||||||
name: "Admin App", slug: "admin-app", app_type: "forward_auth",
|
name: "Admin App", slug: "admin-app", app_type: "forward_auth",
|
||||||
domain_pattern: "admin.example.com",
|
domain_pattern: "admin.example.com",
|
||||||
active: true,
|
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
|
# Test regular user
|
||||||
post "/signin", params: { email_address: regular_user.email_address, password: "password" }
|
post "/signin", params: {email_address: regular_user.email_address, password: "password"}
|
||||||
get "/api/verify", headers: { "X-Forwarded-Host" => "admin.example.com" }
|
get "/api/verify", headers: {"X-Forwarded-Host" => "admin.example.com"}
|
||||||
assert_response 200
|
assert_response 200
|
||||||
assert_equal regular_user.email_address, response.headers["x-admin-user"]
|
assert_equal regular_user.email_address, response.headers["x-admin-user"]
|
||||||
|
|
||||||
@@ -211,8 +211,8 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
|
|||||||
delete "/session"
|
delete "/session"
|
||||||
|
|
||||||
# Test admin user
|
# Test admin user
|
||||||
post "/signin", params: { email_address: admin_user.email_address, password: "password" }
|
post "/signin", params: {email_address: admin_user.email_address, password: "password"}
|
||||||
get "/api/verify", headers: { "X-Forwarded-Host" => "admin.example.com" }
|
get "/api/verify", headers: {"X-Forwarded-Host" => "admin.example.com"}
|
||||||
assert_response 200
|
assert_response 200
|
||||||
assert_equal admin_user.email_address, response.headers["x-admin-user"]
|
assert_equal admin_user.email_address, response.headers["x-admin-user"]
|
||||||
assert_equal "true", response.headers["x-admin-flag"]
|
assert_equal "true", response.headers["x-admin-flag"]
|
||||||
@@ -221,10 +221,10 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
|
|||||||
# Security Integration Tests
|
# Security Integration Tests
|
||||||
test "session hijacking prevention" do
|
test "session hijacking prevention" do
|
||||||
# User A signs in
|
# 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
|
# 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_response 200
|
||||||
assert_equal @user.email_address, response.headers["x-remote-user"]
|
assert_equal @user.email_address, response.headers["x-remote-user"]
|
||||||
user_a_session_id = Session.where(user: @user).last.id
|
user_a_session_id = Session.where(user: @user).last.id
|
||||||
@@ -233,10 +233,10 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
|
|||||||
reset!
|
reset!
|
||||||
|
|
||||||
# User B signs in (creates a new session)
|
# 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
|
# 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_response 200
|
||||||
assert_equal @admin_user.email_address, response.headers["x-remote-user"]
|
assert_equal @admin_user.email_address, response.headers["x-remote-user"]
|
||||||
user_b_session_id = Session.where(user: @admin_user).last.id
|
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_a_session_id), "User A's session should still exist"
|
||||||
assert Session.exists?(user_b_session_id), "User B's session should still exist"
|
assert Session.exists?(user_b_session_id), "User B's session should still exist"
|
||||||
end
|
end
|
||||||
|
|
||||||
end
|
end
|
||||||
@@ -94,7 +94,7 @@ class InvitationFlowTest < ActionDispatch::IntegrationTest
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "expired invitation token flow" do
|
test "expired invitation token flow" do
|
||||||
user = User.create!(
|
User.create!(
|
||||||
email_address: "expired@example.com",
|
email_address: "expired@example.com",
|
||||||
password: "temppassword",
|
password: "temppassword",
|
||||||
status: :pending_invitation
|
status: :pending_invitation
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ class SessionSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
user = User.create!(email_address: "session_test@example.com", password: "password123")
|
user = User.create!(email_address: "session_test@example.com", password: "password123")
|
||||||
|
|
||||||
# Sign in
|
# 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
|
assert_response :redirect
|
||||||
follow_redirect!
|
follow_redirect!
|
||||||
assert_response :success
|
assert_response :success
|
||||||
@@ -75,7 +75,7 @@ class SessionSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
user = User.create!(email_address: "session_fixation_test@example.com", password: "password123")
|
user = User.create!(email_address: "session_fixation_test@example.com", password: "password123")
|
||||||
|
|
||||||
# Sign in creates a new session
|
# 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
|
assert_response :redirect
|
||||||
|
|
||||||
# User should be authenticated after sign in
|
# 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")
|
user = User.create!(email_address: "concurrent_session_test@example.com", password: "password123")
|
||||||
|
|
||||||
# Create multiple sessions from different devices
|
# Create multiple sessions from different devices
|
||||||
session1 = user.sessions.create!(
|
user.sessions.create!(
|
||||||
ip_address: "192.168.1.1",
|
ip_address: "192.168.1.1",
|
||||||
user_agent: "Mozilla/5.0 (Windows)",
|
user_agent: "Mozilla/5.0 (Windows)",
|
||||||
device_name: "Windows PC",
|
device_name: "Windows PC",
|
||||||
last_activity_at: Time.current
|
last_activity_at: Time.current
|
||||||
)
|
)
|
||||||
|
|
||||||
session2 = user.sessions.create!(
|
user.sessions.create!(
|
||||||
ip_address: "192.168.1.2",
|
ip_address: "192.168.1.2",
|
||||||
user_agent: "Mozilla/5.0 (iPhone)",
|
user_agent: "Mozilla/5.0 (iPhone)",
|
||||||
device_name: "iPhone",
|
device_name: "iPhone",
|
||||||
last_activity_at: Time.current
|
last_activity_at: Time.current
|
||||||
)
|
)
|
||||||
|
|
||||||
session3 = user.sessions.create!(
|
user.sessions.create!(
|
||||||
ip_address: "192.168.1.3",
|
ip_address: "192.168.1.3",
|
||||||
user_agent: "Mozilla/5.0 (Macintosh)",
|
user_agent: "Mozilla/5.0 (Macintosh)",
|
||||||
device_name: "MacBook",
|
device_name: "MacBook",
|
||||||
@@ -157,14 +157,14 @@ class SessionSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
user = User.create!(email_address: "logout_test@example.com", password: "password123")
|
user = User.create!(email_address: "logout_test@example.com", password: "password123")
|
||||||
|
|
||||||
# Create multiple sessions
|
# Create multiple sessions
|
||||||
session1 = user.sessions.create!(
|
user.sessions.create!(
|
||||||
ip_address: "192.168.1.1",
|
ip_address: "192.168.1.1",
|
||||||
user_agent: "Mozilla/5.0 (Windows)",
|
user_agent: "Mozilla/5.0 (Windows)",
|
||||||
device_name: "Windows PC",
|
device_name: "Windows PC",
|
||||||
last_activity_at: Time.current
|
last_activity_at: Time.current
|
||||||
)
|
)
|
||||||
|
|
||||||
session2 = user.sessions.create!(
|
user.sessions.create!(
|
||||||
ip_address: "192.168.1.2",
|
ip_address: "192.168.1.2",
|
||||||
user_agent: "Mozilla/5.0 (iPhone)",
|
user_agent: "Mozilla/5.0 (iPhone)",
|
||||||
device_name: "iPhone",
|
device_name: "iPhone",
|
||||||
@@ -172,7 +172,7 @@ class SessionSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Sign in (creates a new session via the sign-in flow)
|
# 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
|
assert_response :redirect
|
||||||
|
|
||||||
# Should have 3 sessions now
|
# Should have 3 sessions now
|
||||||
@@ -204,7 +204,7 @@ class SessionSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Create consent with backchannel logout enabled
|
# Create consent with backchannel logout enabled
|
||||||
consent = OidcUserConsent.create!(
|
OidcUserConsent.create!(
|
||||||
user: user,
|
user: user,
|
||||||
application: application,
|
application: application,
|
||||||
scopes_granted: "openid profile",
|
scopes_granted: "openid profile",
|
||||||
@@ -212,7 +212,7 @@ class SessionSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Sign in
|
# 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
|
assert_response :redirect
|
||||||
|
|
||||||
# Sign out
|
# Sign out
|
||||||
@@ -237,8 +237,8 @@ class SessionSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
user = User.create!(email_address: "hijacking_test@example.com", password: "password123")
|
user = User.create!(email_address: "hijacking_test@example.com", password: "password123")
|
||||||
|
|
||||||
# Sign in
|
# Sign in
|
||||||
post signin_path, params: { email_address: "hijacking_test@example.com", password: "password123" },
|
post signin_path, params: {email_address: "hijacking_test@example.com", password: "password123"},
|
||||||
headers: { "HTTP_USER_AGENT" => "TestBrowser/1.0" }
|
headers: {"HTTP_USER_AGENT" => "TestBrowser/1.0"}
|
||||||
assert_response :redirect
|
assert_response :redirect
|
||||||
|
|
||||||
# Check that session includes IP and user agent
|
# Check that session includes IP and user agent
|
||||||
@@ -295,7 +295,7 @@ class SessionSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
# Test forward auth endpoint with valid session
|
# Test forward auth endpoint with valid session
|
||||||
get api_verify_path(rd: "https://test.example.com/protected"),
|
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
|
# Should accept the request and redirect back
|
||||||
assert_response :redirect
|
assert_response :redirect
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ class WebauthnCredentialEnumerationTest < ActionDispatch::IntegrationTest
|
|||||||
user2 = User.create!(email_address: "user2@example.com", password: "password123")
|
user2 = User.create!(email_address: "user2@example.com", password: "password123")
|
||||||
|
|
||||||
# Create a credential for user1
|
# Create a credential for user1
|
||||||
credential1 = user1.webauthn_credentials.create!(
|
user1.webauthn_credentials.create!(
|
||||||
external_id: Base64.urlsafe_encode64("user1_credential"),
|
external_id: Base64.urlsafe_encode64("user1_credential"),
|
||||||
public_key: Base64.urlsafe_encode64("public_key_1"),
|
public_key: Base64.urlsafe_encode64("public_key_1"),
|
||||||
sign_count: 0,
|
sign_count: 0,
|
||||||
@@ -28,7 +28,7 @@ class WebauthnCredentialEnumerationTest < ActionDispatch::IntegrationTest
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Sign in as user1
|
# 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
|
assert_response :redirect
|
||||||
follow_redirect!
|
follow_redirect!
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ class WebauthnCredentialEnumerationTest < ActionDispatch::IntegrationTest
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Sign in
|
# 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
|
assert_response :redirect
|
||||||
follow_redirect!
|
follow_redirect!
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ class ApplicationJobTest < ActiveJob::TestCase
|
|||||||
end
|
end
|
||||||
|
|
||||||
assert_enqueued_jobs 1 do
|
assert_enqueued_jobs 1 do
|
||||||
test_job.perform_later("arg1", "arg2", { "key" => "value" })
|
test_job.perform_later("arg1", "arg2", {"key" => "value"})
|
||||||
end
|
end
|
||||||
|
|
||||||
# ActiveJob serializes all hash keys as strings
|
# ActiveJob serializes all hash keys as strings
|
||||||
@@ -77,7 +77,7 @@ class ApplicationJobTest < ActiveJob::TestCase
|
|||||||
args = enqueued_jobs.last[:args]
|
args = enqueued_jobs.last[:args]
|
||||||
if args.is_a?(Array) && args.first.is_a?(Hash)
|
if args.is_a?(Array) && args.first.is_a?(Hash)
|
||||||
# GlobalID serialization format
|
# 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
|
else
|
||||||
# Direct object serialization
|
# Direct object serialization
|
||||||
assert_equal user.id, args.first.id
|
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)
|
# Should not include sensitive data in headers (except Subject which legitimately mentions password)
|
||||||
email.header.fields.each do |field|
|
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)
|
# Check for actual tokens (not just the word "token" which is common in emails)
|
||||||
refute_includes field.value.to_s.downcase, "password"
|
refute_includes field.value.to_s.downcase, "password"
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ class ApplicationUserClaimTest < ActiveSupport::TestCase
|
|||||||
claim = ApplicationUserClaim.new(
|
claim = ApplicationUserClaim.new(
|
||||||
user: @user,
|
user: @user,
|
||||||
application: @application,
|
application: @application,
|
||||||
custom_claims: { "role": "admin" }
|
custom_claims: {role: "admin"}
|
||||||
)
|
)
|
||||||
assert claim.valid?
|
assert claim.valid?
|
||||||
assert claim.save
|
assert claim.save
|
||||||
@@ -20,13 +20,13 @@ class ApplicationUserClaimTest < ActiveSupport::TestCase
|
|||||||
ApplicationUserClaim.create!(
|
ApplicationUserClaim.create!(
|
||||||
user: @user,
|
user: @user,
|
||||||
application: @application,
|
application: @application,
|
||||||
custom_claims: { "role": "admin" }
|
custom_claims: {role: "admin"}
|
||||||
)
|
)
|
||||||
|
|
||||||
duplicate = ApplicationUserClaim.new(
|
duplicate = ApplicationUserClaim.new(
|
||||||
user: @user,
|
user: @user,
|
||||||
application: @application,
|
application: @application,
|
||||||
custom_claims: { "role": "user" }
|
custom_claims: {role: "user"}
|
||||||
)
|
)
|
||||||
|
|
||||||
assert_not duplicate.valid?
|
assert_not duplicate.valid?
|
||||||
@@ -37,7 +37,7 @@ class ApplicationUserClaimTest < ActiveSupport::TestCase
|
|||||||
claim = ApplicationUserClaim.new(
|
claim = ApplicationUserClaim.new(
|
||||||
user: @user,
|
user: @user,
|
||||||
application: @application,
|
application: @application,
|
||||||
custom_claims: { "role": "admin", "level": 5 }
|
custom_claims: {role: "admin", level: 5}
|
||||||
)
|
)
|
||||||
|
|
||||||
parsed = claim.parsed_custom_claims
|
parsed = claim.parsed_custom_claims
|
||||||
@@ -59,7 +59,7 @@ class ApplicationUserClaimTest < ActiveSupport::TestCase
|
|||||||
claim = ApplicationUserClaim.new(
|
claim = ApplicationUserClaim.new(
|
||||||
user: @user,
|
user: @user,
|
||||||
application: @application,
|
application: @application,
|
||||||
custom_claims: { "groups": ["admin"], "role": "user" }
|
custom_claims: {groups: ["admin"], role: "user"}
|
||||||
)
|
)
|
||||||
|
|
||||||
assert_not claim.valid?
|
assert_not claim.valid?
|
||||||
@@ -70,7 +70,7 @@ class ApplicationUserClaimTest < ActiveSupport::TestCase
|
|||||||
claim = ApplicationUserClaim.new(
|
claim = ApplicationUserClaim.new(
|
||||||
user: @user,
|
user: @user,
|
||||||
application: @application,
|
application: @application,
|
||||||
custom_claims: { "kavita_groups": ["admin"], "role": "user" }
|
custom_claims: {kavita_groups: ["admin"], role: "user"}
|
||||||
)
|
)
|
||||||
|
|
||||||
assert claim.valid?
|
assert claim.valid?
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ class OidcAccessTokenTest < ActiveSupport::TestCase
|
|||||||
assert_nil new_token.plaintext_token
|
assert_nil new_token.plaintext_token
|
||||||
assert new_token.save
|
assert new_token.save
|
||||||
assert_not_nil new_token.plaintext_token
|
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
|
end
|
||||||
|
|
||||||
test "should set expiry before validation on create" do
|
test "should set expiry before validation on create" do
|
||||||
@@ -144,7 +144,7 @@ class OidcAccessTokenTest < ActiveSupport::TestCase
|
|||||||
|
|
||||||
# All tokens should match the expected pattern
|
# All tokens should match the expected pattern
|
||||||
tokens.each do |token|
|
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
|
# 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 >= 43, "Token should be at least 43 characters"
|
||||||
assert token.length <= 64, "Token should not exceed 64 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,
|
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
|
end
|
||||||
|
|
||||||
test "should have appropriate expiry times" do
|
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
|
# Authorization codes expire in 10 minutes, access tokens in 1 hour
|
||||||
assert access_token.expires_at > auth_code.expires_at,
|
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
|
end
|
||||||
|
|
||||||
test "revoked tokens should not appear in valid scope" do
|
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_nil new_code.code_hmac
|
||||||
assert new_code.save
|
assert new_code.save
|
||||||
assert_not_nil new_code.code_hmac
|
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
|
end
|
||||||
|
|
||||||
test "should set expiry before validation on create" do
|
test "should set expiry before validation on create" do
|
||||||
@@ -186,7 +186,7 @@ class OidcAuthorizationCodeTest < ActiveSupport::TestCase
|
|||||||
|
|
||||||
# All codes should be SHA256 hex digests
|
# All codes should be SHA256 hex digests
|
||||||
codes.each do |code|
|
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
|
assert_equal 64, code.length # SHA256 hex digest
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -218,7 +218,7 @@ class OidcUserConsentTest < ActiveSupport::TestCase
|
|||||||
|
|
||||||
# Application requests more than granted
|
# Application requests more than granted
|
||||||
assert_not @consent.covers_scopes?(["openid", "profile", "groups"]),
|
assert_not @consent.covers_scopes?(["openid", "profile", "groups"]),
|
||||||
"Should not cover scopes not granted"
|
"Should not cover scopes not granted"
|
||||||
|
|
||||||
# Application requests subset
|
# Application requests subset
|
||||||
assert @consent.covers_scopes?(["email"]), "Should cover subset of granted scopes"
|
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"
|
assert_not authenticated_user.authenticate("WrongPassword"), "Should not authenticate with wrong password"
|
||||||
|
|
||||||
# Test password changes invalidate old sessions
|
# Test password changes invalidate old sessions
|
||||||
old_password_digest = @user.password_digest
|
@user.password_digest
|
||||||
@user.password = "NewPassword123!"
|
@user.password = "NewPassword123!"
|
||||||
@user.save!
|
@user.save!
|
||||||
|
|
||||||
@@ -102,7 +102,7 @@ class UserPasswordManagementTest < ActiveSupport::TestCase
|
|||||||
assert new_user.password_digest.length > 50, "Password digest should be substantial"
|
assert new_user.password_digest.length > 50, "Password digest should be substantial"
|
||||||
|
|
||||||
# Test digest format (bcrypt hashes start with $2a$)
|
# 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
|
# Test authentication against digest
|
||||||
authenticated_user = User.find(new_user.id)
|
authenticated_user = User.find(new_user.id)
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ class UserTest < ActiveSupport::TestCase
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "does not find user with invalid invitation token" do
|
test "does not find user with invalid invitation token" do
|
||||||
user = User.create!(
|
User.create!(
|
||||||
email_address: "test@example.com",
|
email_address: "test@example.com",
|
||||||
password: "password123",
|
password: "password123",
|
||||||
status: :pending_invitation
|
status: :pending_invitation
|
||||||
@@ -222,7 +222,7 @@ class UserTest < ActiveSupport::TestCase
|
|||||||
# Should store 10 BCrypt hashes
|
# Should store 10 BCrypt hashes
|
||||||
assert_equal 10, stored_hashes.length
|
assert_equal 10, stored_hashes.length
|
||||||
stored_hashes.each do |hash|
|
stored_hashes.each do |hash|
|
||||||
assert hash.start_with?('$2a$'), "Should be BCrypt hash"
|
assert hash.start_with?("$2a$"), "Should be BCrypt hash"
|
||||||
end
|
end
|
||||||
|
|
||||||
# Verify each plain code matches its corresponding hash
|
# Verify each plain code matches its corresponding hash
|
||||||
@@ -298,7 +298,7 @@ class UserTest < ActiveSupport::TestCase
|
|||||||
# Make 5 failed attempts to trigger rate limit
|
# Make 5 failed attempts to trigger rate limit
|
||||||
5.times do |i|
|
5.times do |i|
|
||||||
result = user.verify_backup_code("INVALID123")
|
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
|
end
|
||||||
|
|
||||||
# Check that the cache is tracking attempts
|
# Check that the cache is tracking attempts
|
||||||
|
|||||||
@@ -61,18 +61,18 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
|
|||||||
|
|
||||||
assert_not_nil token, "Should generate token"
|
assert_not_nil token, "Should generate token"
|
||||||
assert token.length > 100, "Token should be substantial"
|
assert token.length > 100, "Token should be substantial"
|
||||||
assert token.include?('.')
|
assert token.include?(".")
|
||||||
|
|
||||||
# Decode without verification for testing the payload
|
# Decode without verification for testing the payload
|
||||||
decoded = JWT.decode(token, nil, false).first
|
decoded = JWT.decode(token, nil, false).first
|
||||||
assert_equal @application.client_id, decoded['aud'], "Should have correct audience"
|
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.id.to_s, decoded["sub"], "Should have correct subject"
|
||||||
assert_equal @user.email_address, decoded['email'], "Should have correct email"
|
assert_equal @user.email_address, decoded["email"], "Should have correct email"
|
||||||
assert_equal true, decoded['email_verified'], "Should have email verified"
|
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["preferred_username"], "Should have preferred username"
|
||||||
assert_equal @user.email_address, decoded['name'], "Should have name"
|
assert_equal @user.email_address, decoded["name"], "Should have name"
|
||||||
assert_equal @service.issuer_url, decoded['iss'], "Should have correct issuer"
|
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_in_delta Time.current.to_i + 3600, decoded["exp"], 5, "Should have correct expiration"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "should handle nonce in id token" do
|
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)
|
token = @service.generate_id_token(@user, @application, nonce: nonce)
|
||||||
|
|
||||||
decoded = JWT.decode(token, nil, false).first
|
decoded = JWT.decode(token, nil, false).first
|
||||||
assert_equal nonce, decoded['nonce'], "Should preserve nonce in token"
|
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_in_delta Time.current.to_i + 3600, decoded["exp"], 5, "Should have correct expiration with nonce"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "should include groups in token when user has groups" do
|
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)
|
token = @service.generate_id_token(@user, @application)
|
||||||
|
|
||||||
decoded = JWT.decode(token, nil, false).first
|
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
|
end
|
||||||
|
|
||||||
test "admin claim should not be included in token" do
|
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)
|
token = @service.generate_id_token(@user, @application)
|
||||||
|
|
||||||
decoded = JWT.decode(token, nil, false).first
|
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
|
end
|
||||||
|
|
||||||
test "should handle missing roles gracefully" do
|
test "should handle missing roles gracefully" do
|
||||||
token = @service.generate_id_token(@user, @application)
|
token = @service.generate_id_token(@user, @application)
|
||||||
|
|
||||||
decoded = JWT.decode(token, nil, false).first
|
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
|
end
|
||||||
|
|
||||||
test "should load RSA private key from environment with escaped newlines" do
|
test "should load RSA private key from environment with escaped newlines" do
|
||||||
@@ -168,7 +168,7 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
|
|||||||
OidcJwtService.send(:private_key)
|
OidcJwtService.send(:private_key)
|
||||||
end
|
end
|
||||||
|
|
||||||
assert_match /Invalid OIDC private key format/, error.message
|
assert_match(/Invalid OIDC private key format/, error.message)
|
||||||
ensure
|
ensure
|
||||||
# Restore original value and clear cached key
|
# Restore original value and clear cached key
|
||||||
ENV["OIDC_PRIVATE_KEY"] = original_value
|
ENV["OIDC_PRIVATE_KEY"] = original_value
|
||||||
@@ -193,7 +193,7 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
|
|||||||
OidcJwtService.send(:private_key)
|
OidcJwtService.send(:private_key)
|
||||||
end
|
end
|
||||||
|
|
||||||
assert_match /OIDC private key not configured/, error.message
|
assert_match(/OIDC private key not configured/, error.message)
|
||||||
ensure
|
ensure
|
||||||
# Restore original environment and clear cached key
|
# Restore original environment and clear cached key
|
||||||
ENV["OIDC_PRIVATE_KEY"] = original_value if original_value
|
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"
|
assert_not_nil decoded_array, "Should decode valid token"
|
||||||
decoded = decoded_array.first # JWT.decode returns an array
|
decoded = decoded_array.first # JWT.decode returns an array
|
||||||
assert_equal @user.id.to_s, decoded['sub'], "Should decode subject correctly"
|
assert_equal @user.id.to_s, decoded["sub"], "Should decode subject correctly"
|
||||||
assert_equal @application.client_id, decoded['aud'], "Should decode audience 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 decoded["exp"] > Time.current.to_i, "Token should not be expired"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "should reject invalid id tokens" do
|
test "should reject invalid id tokens" do
|
||||||
@@ -252,9 +252,9 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
|
|||||||
|
|
||||||
decoded = JWT.decode(token, nil, false).first
|
decoded = JWT.decode(token, nil, false).first
|
||||||
# ID tokens always include email_verified
|
# ID tokens always include email_verified
|
||||||
assert_includes decoded.keys, 'email_verified'
|
assert_includes decoded.keys, "email_verified"
|
||||||
assert_equal @user.id.to_s, decoded['sub'], "Should decode subject correctly"
|
assert_equal @user.id.to_s, decoded["sub"], "Should decode subject correctly"
|
||||||
assert_equal @application.client_id, decoded['aud'], "Should decode audience correctly"
|
assert_equal @application.client_id, decoded["aud"], "Should decode audience correctly"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "should validate JWT configuration" do
|
test "should validate JWT configuration" do
|
||||||
@@ -275,7 +275,7 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
|
|||||||
ApplicationUserClaim.create!(
|
ApplicationUserClaim.create!(
|
||||||
user: user,
|
user: user,
|
||||||
application: app,
|
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)
|
token = @service.generate_id_token(user, app)
|
||||||
@@ -292,17 +292,17 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
|
|||||||
|
|
||||||
# Add user to group with claims
|
# Add user to group with claims
|
||||||
group = groups(:admin_group)
|
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
|
user.groups << group
|
||||||
|
|
||||||
# Add user custom claims
|
# 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)
|
# Add app-specific claims (should override both)
|
||||||
ApplicationUserClaim.create!(
|
ApplicationUserClaim.create!(
|
||||||
user: user,
|
user: user,
|
||||||
application: app,
|
application: app,
|
||||||
custom_claims: { "role": "admin", "app_specific": true }
|
custom_claims: {role: "admin", app_specific: true}
|
||||||
)
|
)
|
||||||
|
|
||||||
token = @service.generate_id_token(user, app)
|
token = @service.generate_id_token(user, app)
|
||||||
@@ -324,11 +324,11 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
|
|||||||
|
|
||||||
# Group has roles: ["user"]
|
# Group has roles: ["user"]
|
||||||
group = groups(:admin_group)
|
group = groups(:admin_group)
|
||||||
group.update!(custom_claims: { "roles" => ["user"], "permissions" => ["read"] })
|
group.update!(custom_claims: {"roles" => ["user"], "permissions" => ["read"]})
|
||||||
user.groups << group
|
user.groups << group
|
||||||
|
|
||||||
# User adds roles: ["admin"]
|
# 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)
|
token = @service.generate_id_token(user, app)
|
||||||
decoded = JWT.decode(token, nil, false).first
|
decoded = JWT.decode(token, nil, false).first
|
||||||
@@ -349,16 +349,16 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
|
|||||||
|
|
||||||
# First group has roles: ["user"]
|
# First group has roles: ["user"]
|
||||||
group1 = groups(:admin_group)
|
group1 = groups(:admin_group)
|
||||||
group1.update!(custom_claims: { "roles" => ["user"] })
|
group1.update!(custom_claims: {"roles" => ["user"]})
|
||||||
user.groups << group1
|
user.groups << group1
|
||||||
|
|
||||||
# Second group has roles: ["moderator"]
|
# Second group has roles: ["moderator"]
|
||||||
group2 = Group.create!(name: "moderators", description: "Moderators group")
|
group2 = Group.create!(name: "moderators", description: "Moderators group")
|
||||||
group2.update!(custom_claims: { "roles" => ["moderator"] })
|
group2.update!(custom_claims: {"roles" => ["moderator"]})
|
||||||
user.groups << group2
|
user.groups << group2
|
||||||
|
|
||||||
# User adds roles: ["admin"]
|
# User adds roles: ["admin"]
|
||||||
user.update!(custom_claims: { "roles" => ["admin"] })
|
user.update!(custom_claims: {"roles" => ["admin"]})
|
||||||
|
|
||||||
token = @service.generate_id_token(user, app)
|
token = @service.generate_id_token(user, app)
|
||||||
decoded = JWT.decode(token, nil, false).first
|
decoded = JWT.decode(token, nil, false).first
|
||||||
@@ -376,11 +376,11 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
|
|||||||
|
|
||||||
# Group has roles: ["user", "reader"]
|
# Group has roles: ["user", "reader"]
|
||||||
group = groups(:admin_group)
|
group = groups(:admin_group)
|
||||||
group.update!(custom_claims: { "roles" => ["user", "reader"] })
|
group.update!(custom_claims: {"roles" => ["user", "reader"]})
|
||||||
user.groups << group
|
user.groups << group
|
||||||
|
|
||||||
# User also has "user" role (duplicate)
|
# 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)
|
token = @service.generate_id_token(user, app)
|
||||||
decoded = JWT.decode(token, nil, false).first
|
decoded = JWT.decode(token, nil, false).first
|
||||||
@@ -398,11 +398,11 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
|
|||||||
|
|
||||||
# Group has roles array and max_items scalar
|
# Group has roles array and max_items scalar
|
||||||
group = groups(:admin_group)
|
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.groups << group
|
||||||
|
|
||||||
# User overrides max_items and theme, adds to roles
|
# 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)
|
token = @service.generate_id_token(user, app)
|
||||||
decoded = JWT.decode(token, nil, false).first
|
decoded = JWT.decode(token, nil, false).first
|
||||||
@@ -425,7 +425,7 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
|
|||||||
group.update!(custom_claims: {
|
group.update!(custom_claims: {
|
||||||
"config" => {
|
"config" => {
|
||||||
"theme" => "light",
|
"theme" => "light",
|
||||||
"notifications" => { "email" => true }
|
"notifications" => {"email" => true}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
user.groups << group
|
user.groups << group
|
||||||
@@ -434,7 +434,7 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
|
|||||||
user.update!(custom_claims: {
|
user.update!(custom_claims: {
|
||||||
"config" => {
|
"config" => {
|
||||||
"language" => "en",
|
"language" => "en",
|
||||||
"notifications" => { "sms" => true }
|
"notifications" => {"sms" => true}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -454,17 +454,17 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
|
|||||||
|
|
||||||
# Group has roles: ["user"]
|
# Group has roles: ["user"]
|
||||||
group = groups(:admin_group)
|
group = groups(:admin_group)
|
||||||
group.update!(custom_claims: { "roles" => ["user"] })
|
group.update!(custom_claims: {"roles" => ["user"]})
|
||||||
user.groups << group
|
user.groups << group
|
||||||
|
|
||||||
# User has roles: ["moderator"]
|
# User has roles: ["moderator"]
|
||||||
user.update!(custom_claims: { "roles" => ["moderator"] })
|
user.update!(custom_claims: {"roles" => ["moderator"]})
|
||||||
|
|
||||||
# App-specific has roles: ["app_admin"]
|
# App-specific has roles: ["app_admin"]
|
||||||
ApplicationUserClaim.create!(
|
ApplicationUserClaim.create!(
|
||||||
user: user,
|
user: user,
|
||||||
application: app,
|
application: app,
|
||||||
custom_claims: { "roles" => ["app_admin"] }
|
custom_claims: {"roles" => ["app_admin"]}
|
||||||
)
|
)
|
||||||
|
|
||||||
token = @service.generate_id_token(user, app)
|
token = @service.generate_id_token(user, app)
|
||||||
|
|||||||
@@ -13,13 +13,13 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
|
|||||||
# End-to-End Authentication Flow Tests
|
# End-to-End Authentication Flow Tests
|
||||||
test "complete forward auth flow with default headers" do
|
test "complete forward auth flow with default headers" do
|
||||||
# Create an application with default headers
|
# 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
|
# Step 1: Unauthenticated request to protected resource
|
||||||
get "/api/verify", headers: {
|
get "/api/verify", headers: {
|
||||||
"X-Forwarded-Host" => "app.example.com",
|
"X-Forwarded-Host" => "app.example.com",
|
||||||
"X-Forwarded-Uri" => "/dashboard"
|
"X-Forwarded-Uri" => "/dashboard"
|
||||||
}, params: { rd: "https://app.example.com/dashboard" }
|
}, params: {rd: "https://app.example.com/dashboard"}
|
||||||
|
|
||||||
assert_response 302
|
assert_response 302
|
||||||
location = response.location
|
location = response.location
|
||||||
@@ -30,13 +30,13 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
|
|||||||
assert_equal "https://app.example.com/dashboard", session[:return_to_after_authenticating]
|
assert_equal "https://app.example.com/dashboard", session[:return_to_after_authenticating]
|
||||||
|
|
||||||
# Step 3: Sign in
|
# 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_response 302
|
||||||
assert_redirected_to "https://app.example.com/dashboard"
|
assert_redirected_to "https://app.example.com/dashboard"
|
||||||
|
|
||||||
# Step 4: Authenticated request to protected resource
|
# 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_response 200
|
||||||
assert_equal @user.email_address, response.headers["x-remote-user"]
|
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
|
test "multiple domain access with single session" do
|
||||||
# Create applications for different domains
|
# 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)
|
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: "Grafana", slug: "grafana-system-test", app_type: "forward_auth",
|
name: "Grafana", slug: "grafana-system-test", app_type: "forward_auth",
|
||||||
domain_pattern: "grafana.example.com",
|
domain_pattern: "grafana.example.com",
|
||||||
active: true,
|
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",
|
name: "Metube", slug: "metube-system-test", app_type: "forward_auth",
|
||||||
domain_pattern: "metube.example.com",
|
domain_pattern: "metube.example.com",
|
||||||
active: true,
|
active: true,
|
||||||
headers_config: { user: "", email: "", name: "", groups: "", admin: "" }
|
headers_config: {user: "", email: "", name: "", groups: "", admin: ""}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Sign in once
|
# 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_response 302
|
||||||
assert_redirected_to "/"
|
assert_redirected_to "/"
|
||||||
|
|
||||||
# Test access to different applications
|
# Test access to different applications
|
||||||
# App with default headers
|
# 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 200
|
||||||
assert response.headers.key?("x-remote-user")
|
assert response.headers.key?("x-remote-user")
|
||||||
|
|
||||||
# Grafana with custom headers
|
# 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 200
|
||||||
assert response.headers.key?("x-webauth-user")
|
assert response.headers.key?("x-webauth-user")
|
||||||
|
|
||||||
# Metube with no headers
|
# 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
|
assert_response 200
|
||||||
auth_headers = response.headers.select { |k, v| k.match?(/^x-remote-|^x-webauth-|^x-admin-/i) }
|
auth_headers = response.headers.select { |k, v| k.match?(/^x-remote-|^x-webauth-|^x-admin-/i) }
|
||||||
assert_empty auth_headers
|
assert_empty auth_headers
|
||||||
@@ -98,11 +98,11 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
|
|||||||
@user.groups << @group
|
@user.groups << @group
|
||||||
|
|
||||||
# Sign in
|
# 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_response 302
|
||||||
|
|
||||||
# Should have access (in allowed group)
|
# 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_response 200
|
||||||
assert_equal @group.name, response.headers["x-remote-groups"]
|
assert_equal @group.name, response.headers["x-remote-groups"]
|
||||||
|
|
||||||
@@ -110,7 +110,7 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
|
|||||||
@user.groups << @group2
|
@user.groups << @group2
|
||||||
|
|
||||||
# Should show multiple groups
|
# 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
|
assert_response 200
|
||||||
groups_header = response.headers["x-remote-groups"]
|
groups_header = response.headers["x-remote-groups"]
|
||||||
assert_includes groups_header, @group.name
|
assert_includes groups_header, @group.name
|
||||||
@@ -120,13 +120,13 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
|
|||||||
@user.groups.clear
|
@user.groups.clear
|
||||||
|
|
||||||
# Should be denied
|
# 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
|
assert_response 403
|
||||||
end
|
end
|
||||||
|
|
||||||
test "bypass mode when no groups assigned to rule" do
|
test "bypass mode when no groups assigned to rule" do
|
||||||
# Create bypass application (no groups)
|
# Create bypass application (no groups)
|
||||||
bypass_rule = Application.create!(
|
Application.create!(
|
||||||
name: "Public", slug: "public-system-test", app_type: "forward_auth",
|
name: "Public", slug: "public-system-test", app_type: "forward_auth",
|
||||||
domain_pattern: "public.example.com",
|
domain_pattern: "public.example.com",
|
||||||
active: true
|
active: true
|
||||||
@@ -136,11 +136,11 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
|
|||||||
@user.groups.clear
|
@user.groups.clear
|
||||||
|
|
||||||
# Sign in
|
# 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_response 302
|
||||||
|
|
||||||
# Should have access (bypass mode)
|
# 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_response 200
|
||||||
assert_equal @user.email_address, response.headers["x-remote-user"]
|
assert_equal @user.email_address, response.headers["x-remote-user"]
|
||||||
end
|
end
|
||||||
@@ -148,12 +148,12 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
|
|||||||
# Security System Tests
|
# Security System Tests
|
||||||
test "session security and isolation" do
|
test "session security and isolation" do
|
||||||
# User A signs in
|
# 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_a_session = cookies[:session_id]
|
||||||
|
|
||||||
# User B signs in
|
# User B signs in
|
||||||
delete "/session"
|
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_b_session = cookies[:session_id]
|
||||||
|
|
||||||
# User A should still be able to access resources
|
# User A should still be able to access resources
|
||||||
@@ -178,11 +178,11 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
|
|||||||
|
|
||||||
test "session expiration and cleanup" do
|
test "session expiration and cleanup" do
|
||||||
# Sign in
|
# 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]
|
session_id = cookies[:session_id]
|
||||||
|
|
||||||
# Should work initially
|
# 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
|
assert_response 200
|
||||||
|
|
||||||
# Manually expire session
|
# Manually expire session
|
||||||
@@ -190,7 +190,7 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
|
|||||||
session.update!(expires_at: 1.hour.ago)
|
session.update!(expires_at: 1.hour.ago)
|
||||||
|
|
||||||
# Should redirect to login
|
# 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_response 302
|
||||||
assert_equal "Session expired", response.headers["x-auth-reason"]
|
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
|
test "concurrent access with rate limiting considerations" do
|
||||||
# Sign in
|
# 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]
|
session_cookie = cookies[:session_id]
|
||||||
|
|
||||||
# Simulate multiple concurrent requests from different IPs
|
# Simulate multiple concurrent requests from different IPs
|
||||||
@@ -244,23 +244,23 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
|
|||||||
apps = [
|
apps = [
|
||||||
{
|
{
|
||||||
domain: "dashboard.example.com",
|
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]
|
groups: [@group]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
domain: "api.example.com",
|
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: []
|
groups: []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
domain: "logs.example.com",
|
domain: "logs.example.com",
|
||||||
headers_config: { user: "", email: "", name: "", groups: "", admin: "" },
|
headers_config: {user: "", email: "", name: "", groups: "", admin: ""},
|
||||||
groups: []
|
groups: []
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
# Create applications for each app
|
# Create applications for each app
|
||||||
rules = apps.map.with_index do |app, idx|
|
apps.map.with_index do |app, idx|
|
||||||
rule = Application.create!(
|
rule = Application.create!(
|
||||||
name: "Multi App #{idx}", slug: "multi-app-#{idx}", app_type: "forward_auth",
|
name: "Multi App #{idx}", slug: "multi-app-#{idx}", app_type: "forward_auth",
|
||||||
domain_pattern: app[:domain],
|
domain_pattern: app[:domain],
|
||||||
@@ -275,19 +275,19 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
|
|||||||
@user.groups << @group
|
@user.groups << @group
|
||||||
|
|
||||||
# Sign in once
|
# 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_response 302
|
||||||
|
|
||||||
# Test access to each application
|
# Test access to each application
|
||||||
apps.each do |app|
|
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]}"
|
assert_response 200, "Failed for #{app[:domain]}"
|
||||||
|
|
||||||
# Verify headers are correct
|
# Verify headers are correct
|
||||||
if app[:headers_config][:user].present?
|
if app[:headers_config][:user].present?
|
||||||
assert_equal app[:headers_config][:user],
|
assert_equal app[:headers_config][:user],
|
||||||
response.headers.keys.find { |k| k.include?("USER") },
|
response.headers.keys.find { |k| k.include?("USER") },
|
||||||
"Wrong user header for #{app[:domain]}"
|
"Wrong user header for #{app[:domain]}"
|
||||||
assert_equal @user.email_address, response.headers[app[:headers_config][:user]]
|
assert_equal @user.email_address, response.headers[app[:headers_config][:user]]
|
||||||
else
|
else
|
||||||
# Should have no auth headers
|
# Should have no auth headers
|
||||||
@@ -300,24 +300,24 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
|
|||||||
test "domain pattern edge cases" do
|
test "domain pattern edge cases" do
|
||||||
# Test various domain patterns
|
# Test various domain patterns
|
||||||
patterns = [
|
patterns = [
|
||||||
{ pattern: "*.example.com", domains: ["app.example.com", "api.example.com", "sub.app.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: "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.dev.example.com", "api.staging.example.com"]}
|
||||||
]
|
]
|
||||||
|
|
||||||
patterns.each_with_index do |pattern_config, idx|
|
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",
|
name: "Pattern Test #{idx}", slug: "pattern-test-#{idx}", app_type: "forward_auth",
|
||||||
domain_pattern: pattern_config[:pattern],
|
domain_pattern: pattern_config[:pattern],
|
||||||
active: true
|
active: true
|
||||||
)
|
)
|
||||||
|
|
||||||
# Sign in
|
# 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
|
# Test each domain
|
||||||
pattern_config[:domains].each do |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_response 200, "Failed for pattern #{pattern_config[:pattern]} with domain #{domain}"
|
||||||
assert_equal @user.email_address, response.headers["x-remote-user"]
|
assert_equal @user.email_address, response.headers["x-remote-user"]
|
||||||
end
|
end
|
||||||
@@ -330,10 +330,10 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
|
|||||||
# Performance System Tests
|
# Performance System Tests
|
||||||
test "system performance under load" do
|
test "system performance under load" do
|
||||||
# Create test application
|
# 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
|
# 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]
|
session_cookie = cookies[:session_id]
|
||||||
|
|
||||||
# Performance test
|
# Performance test
|
||||||
@@ -374,7 +374,7 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
|
|||||||
# Error Recovery System Tests
|
# Error Recovery System Tests
|
||||||
test "graceful degradation with database issues" do
|
test "graceful degradation with database issues" do
|
||||||
# Sign in first
|
# 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
|
assert_response 302
|
||||||
|
|
||||||
# Simulate database connection issue by mocking
|
# Simulate database connection issue by mocking
|
||||||
@@ -387,7 +387,7 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
|
|||||||
|
|
||||||
begin
|
begin
|
||||||
# Request should handle the error gracefully
|
# 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
|
# Should return 302 (redirect to login) rather than 500 error
|
||||||
assert_response 302, "Should gracefully handle database issues"
|
assert_response 302, "Should gracefully handle database issues"
|
||||||
@@ -398,7 +398,7 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
|
|||||||
end
|
end
|
||||||
|
|
||||||
# Normal operation should still work
|
# 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
|
assert_response 200
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -78,7 +78,7 @@ class WebauthnSecurityTest < ActionDispatch::SystemTestCase
|
|||||||
user = User.create!(email_address: "webauthn_handle_auth_test@example.com", password: "password123")
|
user = User.create!(email_address: "webauthn_handle_auth_test@example.com", password: "password123")
|
||||||
|
|
||||||
user_handle = SecureRandom.uuid
|
user_handle = SecureRandom.uuid
|
||||||
credential = user.webauthn_credentials.create!(
|
user.webauthn_credentials.create!(
|
||||||
external_id: Base64.urlsafe_encode64("fake_credential_id"),
|
external_id: Base64.urlsafe_encode64("fake_credential_id"),
|
||||||
public_key: Base64.urlsafe_encode64("fake_public_key"),
|
public_key: Base64.urlsafe_encode64("fake_public_key"),
|
||||||
sign_count: 0,
|
sign_count: 0,
|
||||||
@@ -99,7 +99,7 @@ class WebauthnSecurityTest < ActionDispatch::SystemTestCase
|
|||||||
|
|
||||||
test "WebAuthn request validates origin" do
|
test "WebAuthn request validates origin" do
|
||||||
user = User.create!(email_address: "webauthn_origin_test@example.com", password: "password123")
|
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"),
|
external_id: Base64.urlsafe_encode64("fake_credential_id"),
|
||||||
public_key: Base64.urlsafe_encode64("fake_public_key"),
|
public_key: Base64.urlsafe_encode64("fake_public_key"),
|
||||||
sign_count: 0,
|
sign_count: 0,
|
||||||
@@ -107,14 +107,14 @@ class WebauthnSecurityTest < ActionDispatch::SystemTestCase
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Test WebAuthn challenge from valid origin
|
# Test WebAuthn challenge from valid origin
|
||||||
post webauthn_challenge_path, params: { email: "webauthn_origin_test@example.com" },
|
post webauthn_challenge_path, params: {email: "webauthn_origin_test@example.com"},
|
||||||
headers: { "HTTP_ORIGIN": "http://localhost:3000" }
|
headers: {HTTP_ORIGIN: "http://localhost:3000"}
|
||||||
|
|
||||||
# Should succeed for valid origin
|
# Should succeed for valid origin
|
||||||
|
|
||||||
# Test WebAuthn challenge from invalid origin
|
# Test WebAuthn challenge from invalid origin
|
||||||
post webauthn_challenge_path, params: { email: "webauthn_origin_test@example.com" },
|
post webauthn_challenge_path, params: {email: "webauthn_origin_test@example.com"},
|
||||||
headers: { "HTTP_ORIGIN": "http://evil.com" }
|
headers: {HTTP_ORIGIN: "http://evil.com"}
|
||||||
|
|
||||||
# Should reject invalid origin
|
# 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 = User.create!(email_address: "webauthn_verify_origin_test@example.com", password: "password123")
|
||||||
user.update!(webauthn_id: SecureRandom.uuid)
|
user.update!(webauthn_id: SecureRandom.uuid)
|
||||||
|
|
||||||
credential = user.webauthn_credentials.create!(
|
user.webauthn_credentials.create!(
|
||||||
external_id: Base64.urlsafe_encode64("fake_credential_id"),
|
external_id: Base64.urlsafe_encode64("fake_credential_id"),
|
||||||
public_key: Base64.urlsafe_encode64("fake_public_key"),
|
public_key: Base64.urlsafe_encode64("fake_public_key"),
|
||||||
sign_count: 0,
|
sign_count: 0,
|
||||||
@@ -133,10 +133,10 @@ class WebauthnSecurityTest < ActionDispatch::SystemTestCase
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Sign in with WebAuthn
|
# 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
|
assert_response :success
|
||||||
|
|
||||||
challenge = JSON.parse(@response.body)["challenge"]
|
JSON.parse(@response.body)["challenge"]
|
||||||
|
|
||||||
# Simulate WebAuthn verification with wrong origin
|
# Simulate WebAuthn verification with wrong origin
|
||||||
# This should fail
|
# This should fail
|
||||||
@@ -155,7 +155,7 @@ class WebauthnSecurityTest < ActionDispatch::SystemTestCase
|
|||||||
# Standard attestation formats: none, packed, tpm, android-key, android-safetynet, fido-u2f, etc.
|
# Standard attestation formats: none, packed, tpm, android-key, android-safetynet, fido-u2f, etc.
|
||||||
|
|
||||||
# Test with 'none' attestation (most common for privacy)
|
# Test with 'none' attestation (most common for privacy)
|
||||||
attestation_object = {
|
{
|
||||||
fmt: "none",
|
fmt: "none",
|
||||||
attStmt: {},
|
attStmt: {},
|
||||||
authData: Base64.strict_encode64("fake_auth_data")
|
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")
|
user = User.create!(email_address: "webauthn_invalid_attestation_test@example.com", password: "password123")
|
||||||
|
|
||||||
# Try to register with invalid attestation format
|
# Try to register with invalid attestation format
|
||||||
invalid_attestation = {
|
{
|
||||||
fmt: "invalid_format",
|
fmt: "invalid_format",
|
||||||
attStmt: {},
|
attStmt: {},
|
||||||
authData: Base64.strict_encode64("fake_auth_data")
|
authData: Base64.strict_encode64("fake_auth_data")
|
||||||
@@ -263,7 +263,7 @@ class WebauthnSecurityTest < ActionDispatch::SystemTestCase
|
|||||||
|
|
||||||
test "WebAuthn requires user presence for authentication" do
|
test "WebAuthn requires user presence for authentication" do
|
||||||
user = User.create!(email_address: "webauthn_presence_test@example.com", password: "password123")
|
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"),
|
external_id: Base64.urlsafe_encode64("fake_credential_id"),
|
||||||
public_key: Base64.urlsafe_encode64("fake_public_key"),
|
public_key: Base64.urlsafe_encode64("fake_public_key"),
|
||||||
sign_count: 0,
|
sign_count: 0,
|
||||||
@@ -291,7 +291,7 @@ class WebauthnSecurityTest < ActionDispatch::SystemTestCase
|
|||||||
nickname: "USB Key"
|
nickname: "USB Key"
|
||||||
)
|
)
|
||||||
|
|
||||||
credential2 = user.webauthn_credentials.create!(
|
user.webauthn_credentials.create!(
|
||||||
external_id: Base64.urlsafe_encode64("credential_2"),
|
external_id: Base64.urlsafe_encode64("credential_2"),
|
||||||
public_key: Base64.urlsafe_encode64("public_key_2"),
|
public_key: Base64.urlsafe_encode64("public_key_2"),
|
||||||
sign_count: 0,
|
sign_count: 0,
|
||||||
@@ -317,7 +317,7 @@ class WebauthnSecurityTest < ActionDispatch::SystemTestCase
|
|||||||
user.update!(webauthn_enabled: true)
|
user.update!(webauthn_enabled: true)
|
||||||
|
|
||||||
# Sign in with password should still work
|
# 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
|
# If WebAuthn is enabled, should offer WebAuthn as an option
|
||||||
# Implementation should handle password + WebAuthn or passwordless flow
|
# 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 = User.create!(email_address: "webauthn_passwordless_test@example.com", password: "password123")
|
||||||
user.update!(webauthn_enabled: true)
|
user.update!(webauthn_enabled: true)
|
||||||
|
|
||||||
credential = user.webauthn_credentials.create!(
|
user.webauthn_credentials.create!(
|
||||||
external_id: Base64.urlsafe_encode64("passwordless_credential"),
|
external_id: Base64.urlsafe_encode64("passwordless_credential"),
|
||||||
public_key: Base64.urlsafe_encode64("public_key"),
|
public_key: Base64.urlsafe_encode64("public_key"),
|
||||||
sign_count: 0,
|
sign_count: 0,
|
||||||
|
|||||||
Reference in New Issue
Block a user