Add files to support brakeman and standardrb. Fix some SRB warnings
This commit is contained in:
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
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*
|
||||||
Reference in New Issue
Block a user