Compare commits
37 Commits
2025.03
...
9b81aee490
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b81aee490 | ||
|
|
265518ab25 | ||
|
|
adb789bbea | ||
|
|
93a0edb0a2 | ||
|
|
7d3af2bcec | ||
|
|
c03034c49f | ||
|
|
9234904e47 | ||
|
|
e36a9a781a | ||
|
|
d036e25fef | ||
|
|
fcdd2b6de7 | ||
|
|
3939ea773f | ||
|
|
4b4afe277e | ||
|
|
364e6e21dd | ||
|
|
9d352ab8ec | ||
|
|
d1d4ac745f | ||
|
|
3db466f5a2 | ||
|
|
7c6ae7ab7e | ||
|
|
ed7ceedef5 | ||
|
|
40815d3576 | ||
|
|
a17c08c890 | ||
|
|
4f31fadc6c | ||
|
|
29c0981a59 | ||
|
|
9d402fcd92 | ||
|
|
9530c8284f | ||
|
|
bb5aa2e6d6 | ||
|
|
cc7beba9de | ||
|
|
00eca6d8b2 | ||
|
|
32235f9647 | ||
|
|
71d59e7367 | ||
|
|
99c3ac905f | ||
|
|
0761c424c1 | ||
|
|
2a32d75895 | ||
|
|
4c1df53fd5 | ||
|
|
acab15ce30 | ||
|
|
0361bfe470 | ||
|
|
5b9d15584a | ||
|
|
898fd69a5d |
18
.env.example
18
.env.example
@@ -1,5 +1,21 @@
|
||||
# Rails Configuration
|
||||
SECRET_KEY_BASE=generate-with-bin-rails-secret
|
||||
# SECRET_KEY_BASE is used for:
|
||||
# - Session cookie encryption
|
||||
# - Signed token verification
|
||||
# - ActiveRecord encryption (currently: TOTP secrets)
|
||||
# - OIDC token prefix HMAC derivation
|
||||
#
|
||||
# CRITICAL: Do NOT change SECRET_KEY_BASE after deployment. Changing it will:
|
||||
# - Invalidate all user sessions (users must re-login)
|
||||
# - Break encrypted data (users must re-setup 2FA)
|
||||
# - Invalidate all OIDC access/refresh tokens (clients must re-authenticate)
|
||||
#
|
||||
# Optional: Override encryption keys with env vars for key rotation:
|
||||
# - ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY
|
||||
# - ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY
|
||||
# - ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT
|
||||
# - OIDC_TOKEN_PREFIX_HMAC
|
||||
SECRET_KEY_BASE=generate-with-bin/rails/secret
|
||||
RAILS_ENV=development
|
||||
|
||||
# Database
|
||||
|
||||
18
.github/workflows/ci.yml
vendored
18
.github/workflows/ci.yml
vendored
@@ -19,7 +19,9 @@ jobs:
|
||||
bundler-cache: true
|
||||
|
||||
- name: Scan for common Rails security vulnerabilities using static analysis
|
||||
run: bin/brakeman --no-pager
|
||||
run: bin/brakeman --no-pager --no-exit-on-warn
|
||||
# Note: 2 weak warnings exist and are documented as acceptable
|
||||
# See docs/beta-checklist.md for details
|
||||
|
||||
- name: Scan for known security vulnerabilities in gems used
|
||||
run: bin/bundler-audit
|
||||
@@ -41,8 +43,6 @@ jobs:
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
RUBOCOP_CACHE_ROOT: tmp/rubocop
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
@@ -52,18 +52,8 @@ jobs:
|
||||
with:
|
||||
bundler-cache: true
|
||||
|
||||
- name: Prepare RuboCop cache
|
||||
uses: actions/cache@v4
|
||||
env:
|
||||
DEPENDENCIES_HASH: ${{ hashFiles('.ruby-version', '**/.rubocop.yml', '**/.rubocop_todo.yml', 'Gemfile.lock') }}
|
||||
with:
|
||||
path: ${{ env.RUBOCOP_CACHE_ROOT }}
|
||||
key: rubocop-${{ runner.os }}-${{ env.DEPENDENCIES_HASH }}-${{ github.ref_name == github.event.repository.default_branch && github.run_id || 'default' }}
|
||||
restore-keys: |
|
||||
rubocop-${{ runner.os }}-${{ env.DEPENDENCIES_HASH }}-
|
||||
|
||||
- name: Lint code for consistent style
|
||||
run: bin/rubocop -f github
|
||||
run: bin/standardrb
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
7
.standard.yml
Normal file
7
.standard.yml
Normal file
@@ -0,0 +1,7 @@
|
||||
ignore:
|
||||
- 'test_*.rb' # Ignore test files in root directory
|
||||
- 'tmp/**/*'
|
||||
- 'vendor/**/*'
|
||||
- 'node_modules/**/*'
|
||||
- 'config/initializers/csp_local_logger.rb' # Complex CSP logger with intentional block structure
|
||||
- 'config/initializers/sentry_subscriber.rb' # Sentry subscriber with module structure
|
||||
12
Gemfile
12
Gemfile
@@ -42,11 +42,12 @@ gem "sentry-ruby", "~> 6.2"
|
||||
gem "sentry-rails", "~> 6.2"
|
||||
|
||||
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
|
||||
gem "tzinfo-data", platforms: %i[ windows jruby ]
|
||||
gem "tzinfo-data", platforms: %i[windows jruby]
|
||||
|
||||
# Use the database-backed adapters for Rails.cache and Action Cable
|
||||
gem "solid_cache"
|
||||
gem "solid_cable"
|
||||
gem "solid_queue", "~> 1.2"
|
||||
|
||||
# Reduces boot times through caching; required in config/boot.rb
|
||||
gem "bootsnap", require: false
|
||||
@@ -62,7 +63,7 @@ gem "image_processing", "~> 1.2"
|
||||
|
||||
group :development, :test do
|
||||
# See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
|
||||
gem "debug", platforms: %i[ mri windows ], require: "debug/prelude"
|
||||
gem "debug", platforms: %i[mri windows], require: "debug/prelude"
|
||||
|
||||
# Audits gems for known security defects (use config/bundler-audit.yml to ignore issues)
|
||||
gem "bundler-audit", require: false
|
||||
@@ -70,8 +71,8 @@ group :development, :test do
|
||||
# Static analysis for security vulnerabilities [https://brakemanscanner.org/]
|
||||
gem "brakeman", require: false
|
||||
|
||||
# Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/]
|
||||
gem "rubocop-rails-omakase", require: false
|
||||
# Standard Ruby style guide, linter, and formatter [https://github.com/standardrb/standard]
|
||||
gem "standard", require: false
|
||||
end
|
||||
|
||||
group :development do
|
||||
@@ -86,4 +87,7 @@ group :test do
|
||||
# Use system testing [https://guides.rubyonrails.org/testing.html#system-testing]
|
||||
gem "capybara"
|
||||
gem "selenium-webdriver"
|
||||
|
||||
# Code coverage analysis
|
||||
gem "simplecov", require: false
|
||||
end
|
||||
|
||||
48
Gemfile.lock
48
Gemfile.lock
@@ -116,11 +116,14 @@ GEM
|
||||
debug (1.11.0)
|
||||
irb (~> 1.10)
|
||||
reline (>= 0.3.8)
|
||||
docile (1.4.1)
|
||||
dotenv (3.1.8)
|
||||
drb (2.2.3)
|
||||
ed25519 (1.4.0)
|
||||
erb (6.0.0)
|
||||
erubi (1.13.1)
|
||||
et-orbi (1.4.0)
|
||||
tzinfo
|
||||
ffi (1.17.2-aarch64-linux-gnu)
|
||||
ffi (1.17.2-aarch64-linux-musl)
|
||||
ffi (1.17.2-arm-linux-gnu)
|
||||
@@ -128,6 +131,9 @@ GEM
|
||||
ffi (1.17.2-arm64-darwin)
|
||||
ffi (1.17.2-x86_64-linux-gnu)
|
||||
ffi (1.17.2-x86_64-linux-musl)
|
||||
fugit (1.12.1)
|
||||
et-orbi (~> 1.4)
|
||||
raabro (~> 1.4)
|
||||
globalid (1.3.0)
|
||||
activesupport (>= 6.1)
|
||||
i18n (1.14.7)
|
||||
@@ -237,6 +243,7 @@ GEM
|
||||
public_suffix (7.0.0)
|
||||
puma (7.1.0)
|
||||
nio4r (~> 2.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.8.1)
|
||||
rack (3.2.4)
|
||||
rack-session (2.1.1)
|
||||
@@ -309,16 +316,6 @@ GEM
|
||||
lint_roller (~> 1.1)
|
||||
rubocop (>= 1.75.0, < 2.0)
|
||||
rubocop-ast (>= 1.47.1, < 2.0)
|
||||
rubocop-rails (2.34.2)
|
||||
activesupport (>= 4.2.0)
|
||||
lint_roller (~> 1.1)
|
||||
rack (>= 1.1)
|
||||
rubocop (>= 1.75.0, < 2.0)
|
||||
rubocop-ast (>= 1.44.0, < 2.0)
|
||||
rubocop-rails-omakase (1.1.0)
|
||||
rubocop (>= 1.72)
|
||||
rubocop-performance (>= 1.24)
|
||||
rubocop-rails (>= 2.30)
|
||||
ruby-progressbar (1.13.0)
|
||||
ruby-vips (2.2.5)
|
||||
ffi (~> 1.12)
|
||||
@@ -339,6 +336,12 @@ GEM
|
||||
sentry-ruby (6.2.0)
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
simplecov (0.22.0)
|
||||
docile (~> 1.1)
|
||||
simplecov-html (~> 0.11)
|
||||
simplecov_json_formatter (~> 0.1)
|
||||
simplecov-html (0.13.2)
|
||||
simplecov_json_formatter (0.1.4)
|
||||
solid_cable (3.0.12)
|
||||
actioncable (>= 7.2)
|
||||
activejob (>= 7.2)
|
||||
@@ -348,6 +351,13 @@ GEM
|
||||
activejob (>= 7.2)
|
||||
activerecord (>= 7.2)
|
||||
railties (>= 7.2)
|
||||
solid_queue (1.2.4)
|
||||
activejob (>= 7.1)
|
||||
activerecord (>= 7.1)
|
||||
concurrent-ruby (>= 1.3.1)
|
||||
fugit (~> 1.11)
|
||||
railties (>= 7.1)
|
||||
thor (>= 1.3.1)
|
||||
sqlite3 (2.8.1-aarch64-linux-gnu)
|
||||
sqlite3 (2.8.1-aarch64-linux-musl)
|
||||
sqlite3 (2.8.1-arm-linux-gnu)
|
||||
@@ -362,6 +372,18 @@ GEM
|
||||
net-sftp (>= 2.1.2)
|
||||
net-ssh (>= 2.8.0)
|
||||
ostruct
|
||||
standard (1.52.0)
|
||||
language_server-protocol (~> 3.17.0.2)
|
||||
lint_roller (~> 1.0)
|
||||
rubocop (~> 1.81.7)
|
||||
standard-custom (~> 1.0.0)
|
||||
standard-performance (~> 1.8)
|
||||
standard-custom (1.0.2)
|
||||
lint_roller (~> 1.0)
|
||||
rubocop (~> 1.50)
|
||||
standard-performance (1.9.0)
|
||||
lint_roller (~> 1.1)
|
||||
rubocop-performance (~> 1.26.0)
|
||||
stimulus-rails (1.3.4)
|
||||
railties (>= 6.0.0)
|
||||
stringio (3.1.8)
|
||||
@@ -392,7 +414,7 @@ GEM
|
||||
concurrent-ruby (~> 1.0)
|
||||
unicode-display_width (3.2.0)
|
||||
unicode-emoji (~> 4.1)
|
||||
unicode-emoji (4.1.0)
|
||||
unicode-emoji (4.2.0)
|
||||
uri (1.1.1)
|
||||
useragent (0.16.11)
|
||||
web-console (4.2.1)
|
||||
@@ -447,13 +469,15 @@ DEPENDENCIES
|
||||
rails (~> 8.1.1)
|
||||
rotp (~> 6.3)
|
||||
rqrcode (~> 3.1)
|
||||
rubocop-rails-omakase
|
||||
selenium-webdriver
|
||||
sentry-rails (~> 6.2)
|
||||
sentry-ruby (~> 6.2)
|
||||
simplecov
|
||||
solid_cable
|
||||
solid_cache
|
||||
solid_queue (~> 1.2)
|
||||
sqlite3 (>= 2.1)
|
||||
standard
|
||||
stimulus-rails
|
||||
tailwindcss-rails
|
||||
thruster
|
||||
|
||||
317
README.md
317
README.md
@@ -1,34 +1,15 @@
|
||||
# Clinch
|
||||
|
||||
> [!NOTE]
|
||||
> This software is experiemental. If you'd like to try it out, find bugs, security flaws and improvements, please do.
|
||||
> This software is experimental. If you'd like to try it out, find bugs, security flaws and improvements, please do.
|
||||
|
||||
**A lightweight, self-hosted identity & SSO / IpD portal**
|
||||
|
||||
Clinch gives you one place to manage users and lets any web app authenticate against it without maintaining its own user table.
|
||||
|
||||
I've completed all planned features:
|
||||
|
||||
* Create Admin user on first login
|
||||
* TOTP ( QR Code ) 2FA, with backup codes ( encrypted at rest )
|
||||
* Passkey generation and login, with detection of Passkey during login
|
||||
* Forward Auth configured and working
|
||||
* OIDC provider with auto discovery, refresh tokens, and token revocation
|
||||
* Configurable token expiry per application (access, refresh, ID tokens)
|
||||
* Backchannel Logout
|
||||
* Per-application logout / revoke
|
||||
* Invite users by email, assign to groups
|
||||
* Self managed password reset by email
|
||||
* Use Groups to assign Applications ( Family group can access Kavita, Developers can access Gitea )
|
||||
* Configurable Group, User & App+User custom claims for OIDC token
|
||||
* Display all Applications available to the user on their Dashboard
|
||||
* Display all logged in sessions and OIDC logged in sessions
|
||||
|
||||
What remains now is ensure test coverage,
|
||||
Clinch gives you one place to manage users and lets any web app authenticate against it without managing its own users.
|
||||
|
||||
## Why Clinch?
|
||||
|
||||
Do you host your own web apps? MeTube, Kavita, Audiobookshelf, Gitea? Rather than managing all those separate user accounts, set everyone up on Clinch and let it do the authentication and user management.
|
||||
Do you host your own web apps? MeTube, Kavita, Audiobookshelf, Gitea, Grafana, Proxmox? Rather than managing all those separate user accounts, set everyone up on Clinch and let it do the authentication and user management.
|
||||
|
||||
Clinch sits in a sweet spot between two excellent open-source identity solutions:
|
||||
|
||||
@@ -86,6 +67,9 @@ Clinch sits in a sweet spot between two excellent open-source identity solutions
|
||||
|
||||
### SSO Protocols
|
||||
|
||||
Apps that speak OIDC use the OIDC flow.
|
||||
Apps that only need "who is it?", or you want available from the internet behind authentication (MeTube, Jellyfin) use ForwardAuth.
|
||||
|
||||
#### OpenID Connect (OIDC)
|
||||
Standard OAuth2/OIDC provider with endpoints:
|
||||
- `/.well-known/openid-configuration` - Discovery endpoint
|
||||
@@ -98,18 +82,45 @@ Features:
|
||||
- **Refresh tokens** - Long-lived tokens (30 days default) with automatic rotation and revocation
|
||||
- **Token family tracking** - Advanced security detects token replay attacks and revokes compromised token families
|
||||
- **Configurable token expiry** - Set access token (5min-24hr), refresh token (1-90 days), and ID token TTL per application
|
||||
- **Token security** - BCrypt-hashed tokens, automatic cleanup of expired tokens
|
||||
- **Token security** - All tokens HMAC-SHA256 hashed (suitable for 256-bit random data), automatic cleanup of expired tokens
|
||||
- **Pairwise subject identifiers** - Each user gets a unique, stable `sub` claim per application for enhanced privacy
|
||||
|
||||
Client apps (Audiobookshelf, Kavita, Grafana, etc.) redirect to Clinch for login and receive ID tokens, access tokens, and refresh tokens.
|
||||
**ID Token Claims** (JWT with RS256 signature):
|
||||
|
||||
| Claim | Description | Notes |
|
||||
|-------|-------------|-------|
|
||||
| Standard Claims | | |
|
||||
| `iss` | Issuer (Clinch URL) | From `CLINCH_HOST` |
|
||||
| `sub` | Subject (user identifier) | Pairwise SID - unique per app |
|
||||
| `aud` | Audience | OAuth client_id |
|
||||
| `exp` | Expiration timestamp | Configurable TTL |
|
||||
| `iat` | Issued-at timestamp | Token creation time |
|
||||
| `email` | User email | |
|
||||
| `email_verified` | Email verification | Always `true` |
|
||||
| `preferred_username` | Username/email | Fallback to email |
|
||||
| `name` | Display name | User's name or email |
|
||||
| `nonce` | Random value | From auth request (prevents replay) |
|
||||
| **Security Claims** | | |
|
||||
| `at_hash` | Access token hash | SHA-256 hash of access_token (OIDC Core §3.1.3.6) |
|
||||
| `auth_time` | Authentication time | Unix timestamp of when user logged in (OIDC Core §2) |
|
||||
| `acr` | Auth context class | `"1"` = password, `"2"` = 2FA/passkey (OIDC Core §2) |
|
||||
| `azp` | Authorized party | OAuth client_id (OIDC Core §2) |
|
||||
| Custom Claims | | |
|
||||
| `groups` | User's groups | Array of group names |
|
||||
| *custom* | Arbitrary key-values | From groups, users, or app-specific config |
|
||||
|
||||
**Authentication Context Class Reference (`acr`):**
|
||||
- `"1"` - Something you know (password only)
|
||||
- `"2"` - Two-factor or phishing-resistant (TOTP, backup codes, WebAuthn/passkey)
|
||||
|
||||
Client apps (Audiobookshelf, Kavita, Proxmox, Grafana, etc.) redirect to Clinch for login and receive ID tokens, access tokens, and refresh tokens.
|
||||
|
||||
#### Trusted-Header SSO (ForwardAuth)
|
||||
Works with reverse proxies (Caddy, Traefik, Nginx):
|
||||
1. Proxy sends every request to `/api/verify`
|
||||
2. **200 OK** → Proxy injects headers (`Remote-User`, `Remote-Groups`, `Remote-Email`) and forwards to app
|
||||
3. **401/403** → Proxy redirects to Clinch login; after login, user returns to original URL
|
||||
|
||||
Apps that speak OIDC use the OIDC flow; apps that only need "who is it?" headers use ForwardAuth.
|
||||
2. Response handling:
|
||||
- **200 OK** → Proxy injects headers (`Remote-User`, `Remote-Groups`, `Remote-Email`) and forwards to app
|
||||
- **Any other status** → Proxy returns that response directly to client (typically 302 redirect to login page)
|
||||
|
||||
**Note:** ForwardAuth requires applications to run on the same domain as Clinch (e.g., `app.yourdomain.com` with Clinch at `auth.yourdomain.com`) for secure session cookie sharing. Take a look at Authentik if you need multi domain support.
|
||||
|
||||
@@ -117,7 +128,6 @@ Apps that speak OIDC use the OIDC flow; apps that only need "who is it?" headers
|
||||
Send emails for:
|
||||
- Invitation links (one-time token, 7-day expiry)
|
||||
- Password reset links (one-time token, 1-hour expiry)
|
||||
- 2FA backup codes
|
||||
|
||||
### Session Management
|
||||
- **Device tracking** - See all active sessions with device names and IPs
|
||||
@@ -217,9 +227,9 @@ Configure different claims for different applications on a per-user basis:
|
||||
- Many-to-many with Groups (allowlist)
|
||||
|
||||
**OIDC Tokens**
|
||||
- Authorization codes (10-minute expiry, one-time use, PKCE support)
|
||||
- Access tokens (opaque, BCrypt-hashed, configurable expiry 5min-24hr, revocable)
|
||||
- Refresh tokens (opaque, BCrypt-hashed, configurable expiry 1-90 days, single-use with rotation)
|
||||
- Authorization codes (opaque, HMAC-SHA256 hashed, 10-minute expiry, one-time use, PKCE support)
|
||||
- Access tokens (opaque, HMAC-SHA256 hashed, configurable expiry 5min-24hr, revocable)
|
||||
- Refresh tokens (opaque, HMAC-SHA256 hashed, configurable expiry 1-90 days, single-use with rotation)
|
||||
- ID tokens (JWT, signed with RS256, configurable expiry 5min-24hr)
|
||||
|
||||
---
|
||||
@@ -334,24 +344,237 @@ OIDC_PRIVATE_KEY=<contents-of-private-key.pem>
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
## Rails Console
|
||||
|
||||
### In Progress
|
||||
- OIDC provider implementation
|
||||
- ForwardAuth endpoint
|
||||
- Admin UI for user/group/app management
|
||||
- First-run wizard
|
||||
One advantage of being a Rails application is direct access to the Rails console for administrative tasks. This is particularly useful for debugging, emergency access, or bulk operations.
|
||||
|
||||
### Planned Features
|
||||
- **Audit logging** - Track all authentication events
|
||||
- **WebAuthn/Passkeys** - Hardware key support
|
||||
### Starting the Console
|
||||
|
||||
#### Maybe
|
||||
- **SAML support** - SAML 2.0 identity provider
|
||||
- **Policy engine** - Rule-based access control
|
||||
- Example: `IF user.email =~ "*@gmail.com" AND app.slug == "kavita" THEN DENY`
|
||||
- Stored as JSON, evaluated after auth but before consent
|
||||
- **LDAP sync** - Import users from LDAP/Active Directory
|
||||
```bash
|
||||
# Docker / Docker Compose
|
||||
docker exec -it clinch bin/rails console
|
||||
# or
|
||||
docker compose exec -it clinch bin/rails console
|
||||
|
||||
# Local development
|
||||
bin/rails console
|
||||
```
|
||||
|
||||
### Finding Users
|
||||
|
||||
```ruby
|
||||
# Find by email
|
||||
user = User.find_by(email_address: 'alice@example.com')
|
||||
|
||||
# Find by username
|
||||
user = User.find_by(username: 'alice')
|
||||
|
||||
# List all users
|
||||
User.all.pluck(:id, :email_address, :status)
|
||||
|
||||
# Find admins
|
||||
User.admins.pluck(:email_address)
|
||||
|
||||
# Find users in a specific status
|
||||
User.active.count
|
||||
User.disabled.pluck(:email_address)
|
||||
User.pending_invitation.pluck(:email_address)
|
||||
```
|
||||
|
||||
### Creating Users
|
||||
|
||||
```ruby
|
||||
# Create a regular user
|
||||
User.create!(
|
||||
email_address: 'newuser@example.com',
|
||||
password: 'secure-password-here',
|
||||
status: :active
|
||||
)
|
||||
|
||||
# Create an admin user
|
||||
User.create!(
|
||||
email_address: 'admin@example.com',
|
||||
password: 'secure-password-here',
|
||||
status: :active,
|
||||
admin: true
|
||||
)
|
||||
```
|
||||
|
||||
### Managing Passwords
|
||||
|
||||
```ruby
|
||||
user = User.find_by(email_address: 'alice@example.com')
|
||||
user.password = 'new-secure-password'
|
||||
user.save!
|
||||
```
|
||||
|
||||
### Two-Factor Authentication (TOTP)
|
||||
|
||||
```ruby
|
||||
user = User.find_by(email_address: 'alice@example.com')
|
||||
|
||||
# Check if TOTP is enabled
|
||||
user.totp_enabled?
|
||||
|
||||
# Get current TOTP code (useful for testing/debugging)
|
||||
puts user.console_totp
|
||||
|
||||
# Enable TOTP (generates secret and backup codes)
|
||||
backup_codes = user.enable_totp!
|
||||
puts backup_codes # Display backup codes to give to user
|
||||
|
||||
# Disable TOTP
|
||||
user.disable_totp!
|
||||
|
||||
# Force user to set up TOTP on next login
|
||||
user.update!(totp_required: true)
|
||||
```
|
||||
|
||||
### Managing User Status
|
||||
|
||||
```ruby
|
||||
user = User.find_by(email_address: 'alice@example.com')
|
||||
|
||||
# Disable a user (prevents login)
|
||||
user.disabled!
|
||||
|
||||
# Re-enable a user
|
||||
user.active!
|
||||
|
||||
# Check current status
|
||||
user.status # => "active", "disabled", or "pending_invitation"
|
||||
|
||||
# Grant admin privileges
|
||||
user.update!(admin: true)
|
||||
|
||||
# Revoke admin privileges
|
||||
user.update!(admin: false)
|
||||
```
|
||||
|
||||
### Managing Groups
|
||||
|
||||
```ruby
|
||||
user = User.find_by(email_address: 'alice@example.com')
|
||||
|
||||
# View user's groups
|
||||
user.groups.pluck(:name)
|
||||
|
||||
# Add user to a group
|
||||
family = Group.find_by(name: 'family')
|
||||
user.groups << family
|
||||
|
||||
# Remove user from a group
|
||||
user.groups.delete(family)
|
||||
|
||||
# Create a new group
|
||||
Group.create!(name: 'developers', description: 'Development team')
|
||||
```
|
||||
|
||||
### Managing Sessions
|
||||
|
||||
```ruby
|
||||
user = User.find_by(email_address: 'alice@example.com')
|
||||
|
||||
# View active sessions
|
||||
user.sessions.pluck(:id, :device_name, :client_ip, :created_at)
|
||||
|
||||
# Revoke all sessions (force logout everywhere)
|
||||
user.sessions.destroy_all
|
||||
|
||||
# Revoke a specific session
|
||||
user.sessions.find(123).destroy
|
||||
```
|
||||
|
||||
### Managing Applications
|
||||
|
||||
```ruby
|
||||
# List all OIDC applications
|
||||
Application.oidc.pluck(:name, :client_id)
|
||||
|
||||
# Find an application
|
||||
app = Application.find_by(slug: 'kavita')
|
||||
|
||||
# Regenerate client secret
|
||||
new_secret = app.generate_new_client_secret!
|
||||
puts new_secret # Display once - not stored in plain text
|
||||
|
||||
# Check which users can access an app
|
||||
app.allowed_groups.flat_map(&:users).uniq.pluck(:email_address)
|
||||
|
||||
# Revoke all tokens for an application
|
||||
app.oidc_access_tokens.destroy_all
|
||||
app.oidc_refresh_tokens.destroy_all
|
||||
```
|
||||
|
||||
### Revoking OIDC Consents
|
||||
|
||||
```ruby
|
||||
user = User.find_by(email_address: 'alice@example.com')
|
||||
app = Application.find_by(slug: 'kavita')
|
||||
|
||||
# Revoke consent for a specific app
|
||||
user.revoke_consent!(app)
|
||||
|
||||
# Revoke all OIDC consents
|
||||
user.revoke_all_consents!
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing & Security
|
||||
|
||||
### Running Tests
|
||||
|
||||
Clinch has comprehensive test coverage with 341 tests covering integration, models, controllers, services, and system tests.
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
bin/rails test
|
||||
|
||||
# Run specific test types
|
||||
bin/rails test:integration
|
||||
bin/rails test:models
|
||||
bin/rails test:controllers
|
||||
bin/rails test:system
|
||||
|
||||
# Run with code coverage report
|
||||
COVERAGE=1 bin/rails test
|
||||
# View coverage report at coverage/index.html
|
||||
```
|
||||
|
||||
### Security Scanning
|
||||
|
||||
Clinch uses multiple automated security tools to ensure code quality and security:
|
||||
|
||||
```bash
|
||||
# Run all security checks
|
||||
bin/rake security
|
||||
|
||||
# Individual security scans
|
||||
bin/brakeman --no-pager # Static security analysis
|
||||
bin/bundler-audit check --update # Dependency vulnerability scan
|
||||
bin/importmap audit # JavaScript dependency scan
|
||||
```
|
||||
|
||||
**CI/CD Integration:**
|
||||
All security scans run automatically on every pull request and push to main via GitHub Actions.
|
||||
|
||||
**Security Tools:**
|
||||
- **Brakeman** - Static analysis for Rails security vulnerabilities
|
||||
- **bundler-audit** - Checks gems for known CVEs
|
||||
- **SimpleCov** - Code coverage tracking
|
||||
- **RuboCop** - Code style and quality enforcement
|
||||
|
||||
**Current Status:**
|
||||
- ✅ All security scans passing
|
||||
- ✅ 341 tests, 1349 assertions, 0 failures
|
||||
- ✅ No known dependency vulnerabilities
|
||||
- ✅ Phases 1-4 security hardening complete (18+ vulnerabilities fixed)
|
||||
- 🟡 3 outstanding security issues (all MEDIUM/LOW priority)
|
||||
|
||||
**Security Documentation:**
|
||||
- [docs/security-todo.md](docs/security-todo.md) - Detailed vulnerability tracking and remediation history
|
||||
- [docs/beta-checklist.md](docs/beta-checklist.md) - Beta release readiness criteria
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -7,10 +7,11 @@ module ApplicationCable
|
||||
end
|
||||
|
||||
private
|
||||
def set_current_user
|
||||
if session = Session.find_by(id: cookies.signed[:session_id])
|
||||
self.current_user = session.user
|
||||
end
|
||||
|
||||
def set_current_user
|
||||
if (session = Session.find_by(id: cookies.signed[:session_id]))
|
||||
self.current_user = session.user
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -29,9 +29,9 @@ class ActiveSessionsController < ApplicationController
|
||||
# Revoke all tokens for this user-application pair
|
||||
now = Time.current
|
||||
revoked_access_tokens = OidcAccessToken.where(application: application, user: @user, revoked_at: nil)
|
||||
.update_all(revoked_at: now)
|
||||
.update_all(revoked_at: now)
|
||||
revoked_refresh_tokens = OidcRefreshToken.where(application: application, user: @user, revoked_at: nil)
|
||||
.update_all(revoked_at: now)
|
||||
.update_all(revoked_at: now)
|
||||
|
||||
Rails.logger.info "ActiveSessionsController: Revoked #{revoked_access_tokens} access tokens and #{revoked_refresh_tokens} refresh tokens for #{application.name}"
|
||||
|
||||
@@ -64,9 +64,9 @@ class ActiveSessionsController < ApplicationController
|
||||
# Revoke all tokens for this user-application pair
|
||||
now = Time.current
|
||||
revoked_access_tokens = OidcAccessToken.where(application: application, user: @user, revoked_at: nil)
|
||||
.update_all(revoked_at: now)
|
||||
.update_all(revoked_at: now)
|
||||
revoked_refresh_tokens = OidcRefreshToken.where(application: application, user: @user, revoked_at: nil)
|
||||
.update_all(revoked_at: now)
|
||||
.update_all(revoked_at: now)
|
||||
|
||||
Rails.logger.info "ActiveSessionsController: Logged out from #{application.name} - revoked #{revoked_access_tokens} access tokens and #{revoked_refresh_tokens} refresh tokens"
|
||||
|
||||
@@ -98,4 +98,4 @@ class ActiveSessionsController < ApplicationController
|
||||
redirect_to active_sessions_path, alert: "No applications to revoke."
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -26,18 +26,17 @@ module Admin
|
||||
@application.allowed_groups = Group.where(id: group_ids)
|
||||
end
|
||||
|
||||
# Get the plain text client secret to show one time
|
||||
# Get the plain text client secret to show one time (confidential clients only)
|
||||
client_secret = nil
|
||||
if @application.oidc?
|
||||
if @application.oidc? && @application.confidential_client?
|
||||
client_secret = @application.generate_new_client_secret!
|
||||
end
|
||||
|
||||
if @application.oidc? && client_secret
|
||||
flash[:notice] = "Application created successfully."
|
||||
flash[:notice] = "Application created successfully."
|
||||
if @application.oidc?
|
||||
flash[:client_id] = @application.client_id
|
||||
flash[:client_secret] = client_secret
|
||||
else
|
||||
flash[:notice] = "Application created successfully."
|
||||
flash[:client_secret] = client_secret if client_secret
|
||||
flash[:public_client] = true if @application.public_client?
|
||||
end
|
||||
|
||||
redirect_to admin_application_path(@application)
|
||||
@@ -74,15 +73,20 @@ module Admin
|
||||
|
||||
def regenerate_credentials
|
||||
if @application.oidc?
|
||||
# Generate new client ID and secret
|
||||
# Generate new client ID (always)
|
||||
new_client_id = SecureRandom.urlsafe_base64(32)
|
||||
client_secret = @application.generate_new_client_secret!
|
||||
|
||||
@application.update!(client_id: new_client_id)
|
||||
|
||||
flash[:notice] = "Credentials regenerated successfully."
|
||||
flash[:client_id] = @application.client_id
|
||||
flash[:client_secret] = client_secret
|
||||
|
||||
# Generate new client secret only for confidential clients
|
||||
if @application.confidential_client?
|
||||
client_secret = @application.generate_new_client_secret!
|
||||
flash[:client_secret] = client_secret
|
||||
else
|
||||
flash[:public_client] = true
|
||||
end
|
||||
|
||||
redirect_to admin_application_path(@application)
|
||||
else
|
||||
@@ -97,15 +101,24 @@ module Admin
|
||||
end
|
||||
|
||||
def application_params
|
||||
params.require(:application).permit(
|
||||
permitted = params.require(:application).permit(
|
||||
:name, :slug, :app_type, :active, :redirect_uris, :description, :metadata,
|
||||
:domain_pattern, :landing_url, :access_token_ttl, :refresh_token_ttl, :id_token_ttl,
|
||||
:icon, :backchannel_logout_uri,
|
||||
headers_config: {}
|
||||
).tap do |whitelisted|
|
||||
# Remove client_secret from params if present (shouldn't be updated via form)
|
||||
whitelisted.delete(:client_secret)
|
||||
:icon, :backchannel_logout_uri, :is_public_client, :require_pkce
|
||||
)
|
||||
|
||||
# Handle headers_config - it comes as a JSON string from the text area
|
||||
if params[:application][:headers_config].present?
|
||||
begin
|
||||
permitted[:headers_config] = JSON.parse(params[:application][:headers_config])
|
||||
rescue JSON::ParserError
|
||||
permitted[:headers_config] = {}
|
||||
end
|
||||
end
|
||||
|
||||
# Remove client_secret from params if present (shouldn't be updated via form)
|
||||
permitted.delete(:client_secret)
|
||||
permitted
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -8,7 +8,7 @@ module Api
|
||||
def violation_report
|
||||
# Parse CSP violation report
|
||||
report_data = JSON.parse(request.body.read)
|
||||
csp_report = report_data['csp-report']
|
||||
csp_report = report_data["csp-report"]
|
||||
|
||||
# Validate that we have a proper CSP report
|
||||
unless csp_report.is_a?(Hash) && csp_report.present?
|
||||
@@ -19,28 +19,28 @@ module Api
|
||||
|
||||
# Log the violation for security monitoring
|
||||
Rails.logger.warn "CSP Violation Report:"
|
||||
Rails.logger.warn " Blocked URI: #{csp_report['blocked-uri']}"
|
||||
Rails.logger.warn " Document URI: #{csp_report['document-uri']}"
|
||||
Rails.logger.warn " Referrer: #{csp_report['referrer']}"
|
||||
Rails.logger.warn " Violated Directive: #{csp_report['violated-directive']}"
|
||||
Rails.logger.warn " Original Policy: #{csp_report['original-policy']}"
|
||||
Rails.logger.warn " Blocked URI: #{csp_report["blocked-uri"]}"
|
||||
Rails.logger.warn " Document URI: #{csp_report["document-uri"]}"
|
||||
Rails.logger.warn " Referrer: #{csp_report["referrer"]}"
|
||||
Rails.logger.warn " Violated Directive: #{csp_report["violated-directive"]}"
|
||||
Rails.logger.warn " Original Policy: #{csp_report["original-policy"]}"
|
||||
Rails.logger.warn " User Agent: #{request.user_agent}"
|
||||
Rails.logger.warn " IP Address: #{request.remote_ip}"
|
||||
|
||||
# Emit structured event for CSP violation
|
||||
# This allows multiple subscribers to process the event (Sentry, local logging, etc.)
|
||||
Rails.event.notify("csp.violation", {
|
||||
blocked_uri: csp_report['blocked-uri'],
|
||||
document_uri: csp_report['document-uri'],
|
||||
referrer: csp_report['referrer'],
|
||||
violated_directive: csp_report['violated-directive'],
|
||||
original_policy: csp_report['original-policy'],
|
||||
disposition: csp_report['disposition'],
|
||||
effective_directive: csp_report['effective-directive'],
|
||||
source_file: csp_report['source-file'],
|
||||
line_number: csp_report['line-number'],
|
||||
column_number: csp_report['column-number'],
|
||||
status_code: csp_report['status-code'],
|
||||
blocked_uri: csp_report["blocked-uri"],
|
||||
document_uri: csp_report["document-uri"],
|
||||
referrer: csp_report["referrer"],
|
||||
violated_directive: csp_report["violated-directive"],
|
||||
original_policy: csp_report["original-policy"],
|
||||
disposition: csp_report["disposition"],
|
||||
effective_directive: csp_report["effective-directive"],
|
||||
source_file: csp_report["source-file"],
|
||||
line_number: csp_report["line-number"],
|
||||
column_number: csp_report["column-number"],
|
||||
status_code: csp_report["status-code"],
|
||||
user_agent: request.user_agent,
|
||||
ip_address: request.remote_ip,
|
||||
current_user_id: Current.user&.id,
|
||||
@@ -54,4 +54,4 @@ module Api
|
||||
head :bad_request
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -49,14 +49,20 @@ module Api
|
||||
forwarded_host = request.headers["X-Forwarded-Host"] || request.headers["Host"]
|
||||
|
||||
if forwarded_host.present?
|
||||
# Load active forward auth applications with their associations for better performance
|
||||
# Load all forward auth applications (including inactive ones) for security checks
|
||||
# Preload groups to avoid N+1 queries in user_allowed? checks
|
||||
apps = Application.forward_auth.includes(:allowed_groups).active
|
||||
apps = Application.forward_auth.includes(:allowed_groups)
|
||||
|
||||
# Find matching forward auth application for this domain
|
||||
app = apps.find { |a| a.matches_domain?(forwarded_host) }
|
||||
|
||||
if app
|
||||
# Check if application is active
|
||||
unless app.active?
|
||||
Rails.logger.info "ForwardAuth: Access denied to #{forwarded_host} - application is inactive"
|
||||
return render_forbidden("No authentication rule configured for this domain")
|
||||
end
|
||||
|
||||
# Check if user is allowed by this application
|
||||
unless app.user_allowed?(user)
|
||||
Rails.logger.info "ForwardAuth: User #{user.email_address} denied access to #{forwarded_host} by app #{app.domain_pattern}"
|
||||
@@ -65,8 +71,9 @@ module Api
|
||||
|
||||
Rails.logger.info "ForwardAuth: User #{user.email_address} granted access to #{forwarded_host} by app #{app.domain_pattern} (policy: #{app.policy_for_user(user)})"
|
||||
else
|
||||
# No application found - allow access with default headers (original behavior)
|
||||
Rails.logger.info "ForwardAuth: No application found for domain: #{forwarded_host}, allowing with default headers"
|
||||
# No application found - DENY by default (fail-closed security)
|
||||
Rails.logger.info "ForwardAuth: Access denied to #{forwarded_host} - no authentication rule configured"
|
||||
return render_forbidden("No authentication rule configured for this domain")
|
||||
end
|
||||
else
|
||||
Rails.logger.info "ForwardAuth: User #{user.email_address} authenticated (no domain specified)"
|
||||
@@ -74,22 +81,26 @@ module Api
|
||||
|
||||
# User is authenticated and authorized
|
||||
# Return 200 with user information headers using app-specific configuration
|
||||
headers = app ? app.headers_for_user(user) : Application::DEFAULT_HEADERS.map { |key, header_name|
|
||||
case key
|
||||
when :user, :email, :name
|
||||
[header_name, user.email_address]
|
||||
when :groups
|
||||
user.groups.any? ? [header_name, user.groups.pluck(:name).join(",")] : nil
|
||||
when :admin
|
||||
[header_name, user.admin? ? "true" : "false"]
|
||||
end
|
||||
}.compact.to_h
|
||||
headers = if app
|
||||
app.headers_for_user(user)
|
||||
else
|
||||
Application::DEFAULT_HEADERS.map { |key, header_name|
|
||||
case key
|
||||
when :user, :email, :name
|
||||
[header_name, user.email_address]
|
||||
when :groups
|
||||
user.groups.any? ? [header_name, user.groups.pluck(:name).join(",")] : nil
|
||||
when :admin
|
||||
[header_name, user.admin? ? "true" : "false"]
|
||||
end
|
||||
}.compact.to_h
|
||||
end
|
||||
|
||||
headers.each { |key, value| response.headers[key] = value }
|
||||
|
||||
# Log what headers we're sending (helpful for debugging)
|
||||
if headers.any?
|
||||
Rails.logger.debug "ForwardAuth: Headers sent: #{headers.keys.join(', ')}"
|
||||
Rails.logger.debug "ForwardAuth: Headers sent: #{headers.keys.join(", ")}"
|
||||
else
|
||||
Rails.logger.debug "ForwardAuth: No headers sent (access only)"
|
||||
end
|
||||
@@ -116,14 +127,13 @@ module Api
|
||||
# Delete the token immediately (one-time use)
|
||||
Rails.cache.delete("forward_auth_token:#{token}")
|
||||
|
||||
session_id
|
||||
session_id
|
||||
end
|
||||
|
||||
def extract_session_id
|
||||
# Extract session ID from cookie
|
||||
# Rails uses signed cookies by default
|
||||
session_id = cookies.signed[:session_id]
|
||||
session_id
|
||||
cookies.signed[:session_id]
|
||||
end
|
||||
|
||||
def extract_app_from_headers
|
||||
@@ -135,6 +145,9 @@ module Api
|
||||
def render_unauthorized(reason = nil)
|
||||
Rails.logger.info "ForwardAuth: Unauthorized - #{reason}"
|
||||
|
||||
# Set auth reason header for debugging (like Authelia)
|
||||
response.headers["X-Auth-Reason"] = reason if reason.present?
|
||||
|
||||
# Get the redirect URL from query params or construct default
|
||||
redirect_url = validate_redirect_url(params[:rd])
|
||||
base_url = determine_base_url(redirect_url)
|
||||
@@ -145,7 +158,7 @@ module Api
|
||||
original_uri = request.headers["X-Forwarded-Uri"] || request.headers["X-Forwarded-Path"] || "/"
|
||||
|
||||
# Debug logging to see what headers we're getting
|
||||
Rails.logger.info "ForwardAuth Headers: Host=#{request.headers['Host']}, X-Forwarded-Host=#{original_host}, X-Forwarded-Uri=#{request.headers['X-Forwarded-Uri']}, X-Forwarded-Path=#{request.headers['X-Forwarded-Path']}"
|
||||
Rails.logger.info "ForwardAuth Headers: Host=#{request.headers["Host"]}, X-Forwarded-Host=#{original_host}, X-Forwarded-Uri=#{request.headers["X-Forwarded-Uri"]}, X-Forwarded-Path=#{request.headers["X-Forwarded-Path"]}"
|
||||
|
||||
original_url = if original_host
|
||||
# Use the forwarded host and URI (original behavior)
|
||||
@@ -176,6 +189,9 @@ module Api
|
||||
def render_forbidden(reason = nil)
|
||||
Rails.logger.info "ForwardAuth: Forbidden - #{reason}"
|
||||
|
||||
# Set auth reason header for debugging (like Authelia)
|
||||
response.headers["X-Auth-Reason"] = reason if reason.present?
|
||||
|
||||
# Return 403 Forbidden
|
||||
head :forbidden
|
||||
end
|
||||
@@ -190,7 +206,7 @@ module Api
|
||||
return nil unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
|
||||
|
||||
# Only allow HTTPS in production
|
||||
return nil unless Rails.env.development? || uri.scheme == 'https'
|
||||
return nil unless Rails.env.development? || uri.scheme == "https"
|
||||
|
||||
redirect_domain = uri.host.downcase
|
||||
return nil unless redirect_domain.present?
|
||||
@@ -201,7 +217,6 @@ module Api
|
||||
end
|
||||
|
||||
matching_app ? url : nil
|
||||
|
||||
rescue URI::InvalidURIError
|
||||
nil
|
||||
end
|
||||
@@ -220,13 +235,13 @@ module Api
|
||||
return redirect_url if redirect_url.present?
|
||||
|
||||
# Try CLINCH_HOST environment variable first
|
||||
if ENV['CLINCH_HOST'].present?
|
||||
host = ENV['CLINCH_HOST']
|
||||
if ENV["CLINCH_HOST"].present?
|
||||
host = ENV["CLINCH_HOST"]
|
||||
# Ensure URL has https:// protocol
|
||||
host.match?(/^https?:\/\//) ? host : "https://#{host}"
|
||||
else
|
||||
# Fallback to the request host
|
||||
request_host = request.host || request.headers['X-Forwarded-Host']
|
||||
request_host = request.host || request.headers["X-Forwarded-Host"]
|
||||
if request_host.present?
|
||||
Rails.logger.warn "ForwardAuth: CLINCH_HOST not set, using request host: #{request_host}"
|
||||
"https://#{request_host}"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
class ApplicationController < ActionController::Base
|
||||
include Authentication
|
||||
|
||||
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
|
||||
allow_browser versions: :modern
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
require 'uri'
|
||||
require 'public_suffix'
|
||||
require 'ipaddr'
|
||||
require "uri"
|
||||
require "public_suffix"
|
||||
require "ipaddr"
|
||||
|
||||
module Authentication
|
||||
extend ActiveSupport::Concern
|
||||
@@ -17,133 +17,137 @@ module Authentication
|
||||
end
|
||||
|
||||
private
|
||||
def authenticated?
|
||||
resume_session
|
||||
|
||||
def authenticated?
|
||||
resume_session
|
||||
end
|
||||
|
||||
def require_authentication
|
||||
resume_session || request_authentication
|
||||
end
|
||||
|
||||
def resume_session
|
||||
Current.session ||= find_session_by_cookie
|
||||
end
|
||||
|
||||
def find_session_by_cookie
|
||||
Session.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id]
|
||||
end
|
||||
|
||||
def request_authentication
|
||||
session[:return_to_after_authenticating] = request.url
|
||||
redirect_to signin_path
|
||||
end
|
||||
|
||||
def after_authentication_url
|
||||
session[:return_to_after_authenticating]
|
||||
session.delete(:return_to_after_authenticating) || root_url
|
||||
end
|
||||
|
||||
def start_new_session_for(user, acr: "1")
|
||||
user.update!(last_sign_in_at: Time.current)
|
||||
user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip, acr: acr).tap do |session|
|
||||
Current.session = session
|
||||
|
||||
# Extract root domain for cross-subdomain cookies (required for forward auth)
|
||||
domain = extract_root_domain(request.host)
|
||||
|
||||
cookie_options = {
|
||||
value: session.id,
|
||||
httponly: true,
|
||||
same_site: :lax,
|
||||
secure: Rails.env.production?
|
||||
}
|
||||
|
||||
# Set domain for cross-subdomain authentication if we can extract it
|
||||
cookie_options[:domain] = domain if domain.present?
|
||||
|
||||
cookies.signed.permanent[:session_id] = cookie_options
|
||||
|
||||
# Create a one-time token for immediate forward auth after authentication
|
||||
# This solves the race condition where browser hasn't processed cookie yet
|
||||
create_forward_auth_token(session)
|
||||
end
|
||||
end
|
||||
|
||||
def terminate_session
|
||||
Current.session.destroy
|
||||
cookies.delete(:session_id)
|
||||
end
|
||||
|
||||
# Extract root domain for cross-subdomain cookies in SSO forward_auth system.
|
||||
#
|
||||
# PURPOSE: Enables a single authentication session to work across multiple subdomains
|
||||
# by setting cookies with the domain parameter (e.g., .example.com allows access from
|
||||
# both app.example.com and api.example.com).
|
||||
#
|
||||
# CRITICAL: Returns nil for IP addresses (IPv4 and IPv6) and localhost - this is intentional!
|
||||
# When accessing services by IP, there are no subdomains to share cookies with,
|
||||
# and setting a domain cookie would break authentication.
|
||||
#
|
||||
# Uses the Public Suffix List (industry standard maintained by Mozilla) to
|
||||
# correctly handle complex domain patterns like co.uk, com.au, appspot.com, etc.
|
||||
#
|
||||
# Examples:
|
||||
# - app.example.com -> .example.com (enables cross-subdomain SSO)
|
||||
# - api.example.co.uk -> .example.co.uk (handles complex TLDs)
|
||||
# - myapp.appspot.com -> .myapp.appspot.com (handles platform domains)
|
||||
# - localhost -> nil (local development, no domain cookie)
|
||||
# - 192.168.1.1 -> nil (IP access, no domain cookie - prevents SSO breakage)
|
||||
#
|
||||
# @param host [String] The request host (may include port)
|
||||
# @return [String, nil] Root domain with leading dot for cookies, or nil for no domain setting
|
||||
def extract_root_domain(host)
|
||||
return nil if host.blank? || host.match?(/^(localhost|127\.0\.0\.1|::1)$/)
|
||||
|
||||
# Strip port number for domain parsing
|
||||
host_without_port = host.split(":").first
|
||||
|
||||
# Check if it's an IP address (IPv4 or IPv6) - if so, don't set domain cookie
|
||||
begin
|
||||
return nil if IPAddr.new(host_without_port)
|
||||
rescue
|
||||
false
|
||||
end
|
||||
|
||||
def require_authentication
|
||||
resume_session || request_authentication
|
||||
end
|
||||
# Use Public Suffix List for accurate domain parsing
|
||||
domain = PublicSuffix.parse(host_without_port)
|
||||
".#{domain.domain}"
|
||||
rescue PublicSuffix::DomainInvalid
|
||||
# Fallback for invalid domains or IPs
|
||||
nil
|
||||
end
|
||||
|
||||
def resume_session
|
||||
Current.session ||= find_session_by_cookie
|
||||
end
|
||||
# Create a one-time token for forward auth to handle the race condition
|
||||
# where the browser hasn't processed the session cookie yet
|
||||
def create_forward_auth_token(session_obj)
|
||||
# Generate a secure random token
|
||||
token = SecureRandom.urlsafe_base64(32)
|
||||
|
||||
def find_session_by_cookie
|
||||
Session.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id]
|
||||
end
|
||||
# Store it with an expiry of 60 seconds
|
||||
Rails.cache.write(
|
||||
"forward_auth_token:#{token}",
|
||||
session_obj.id,
|
||||
expires_in: 60.seconds
|
||||
)
|
||||
|
||||
def request_authentication
|
||||
session[:return_to_after_authenticating] = request.url
|
||||
redirect_to signin_path
|
||||
end
|
||||
# Set the token as a query parameter on the redirect URL
|
||||
# We need to store this in the controller's session
|
||||
controller_session = session
|
||||
if controller_session[:return_to_after_authenticating].present?
|
||||
original_url = controller_session[:return_to_after_authenticating]
|
||||
uri = URI.parse(original_url)
|
||||
|
||||
def after_authentication_url
|
||||
return_url = session[:return_to_after_authenticating]
|
||||
final_url = session.delete(:return_to_after_authenticating) || root_url
|
||||
final_url
|
||||
end
|
||||
# Skip adding fa_token for OAuth URLs (OAuth flow should not have forward auth tokens)
|
||||
unless uri.path&.start_with?("/oauth/")
|
||||
# Add token as query parameter
|
||||
query_params = URI.decode_www_form(uri.query || "").to_h
|
||||
query_params["fa_token"] = token
|
||||
uri.query = URI.encode_www_form(query_params)
|
||||
|
||||
def start_new_session_for(user)
|
||||
user.update!(last_sign_in_at: Time.current)
|
||||
user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session|
|
||||
Current.session = session
|
||||
|
||||
# Extract root domain for cross-subdomain cookies (required for forward auth)
|
||||
domain = extract_root_domain(request.host)
|
||||
|
||||
cookie_options = {
|
||||
value: session.id,
|
||||
httponly: true,
|
||||
same_site: :lax,
|
||||
secure: Rails.env.production?
|
||||
}
|
||||
|
||||
# Set domain for cross-subdomain authentication if we can extract it
|
||||
cookie_options[:domain] = domain if domain.present?
|
||||
|
||||
cookies.signed.permanent[:session_id] = cookie_options
|
||||
|
||||
# Create a one-time token for immediate forward auth after authentication
|
||||
# This solves the race condition where browser hasn't processed cookie yet
|
||||
create_forward_auth_token(session)
|
||||
end
|
||||
end
|
||||
|
||||
def terminate_session
|
||||
Current.session.destroy
|
||||
cookies.delete(:session_id)
|
||||
end
|
||||
|
||||
# Extract root domain for cross-subdomain cookies in SSO forward_auth system.
|
||||
#
|
||||
# PURPOSE: Enables a single authentication session to work across multiple subdomains
|
||||
# by setting cookies with the domain parameter (e.g., .example.com allows access from
|
||||
# both app.example.com and api.example.com).
|
||||
#
|
||||
# CRITICAL: Returns nil for IP addresses (IPv4 and IPv6) and localhost - this is intentional!
|
||||
# When accessing services by IP, there are no subdomains to share cookies with,
|
||||
# and setting a domain cookie would break authentication.
|
||||
#
|
||||
# Uses the Public Suffix List (industry standard maintained by Mozilla) to
|
||||
# correctly handle complex domain patterns like co.uk, com.au, appspot.com, etc.
|
||||
#
|
||||
# Examples:
|
||||
# - app.example.com -> .example.com (enables cross-subdomain SSO)
|
||||
# - api.example.co.uk -> .example.co.uk (handles complex TLDs)
|
||||
# - myapp.appspot.com -> .myapp.appspot.com (handles platform domains)
|
||||
# - localhost -> nil (local development, no domain cookie)
|
||||
# - 192.168.1.1 -> nil (IP access, no domain cookie - prevents SSO breakage)
|
||||
#
|
||||
# @param host [String] The request host (may include port)
|
||||
# @return [String, nil] Root domain with leading dot for cookies, or nil for no domain setting
|
||||
def extract_root_domain(host)
|
||||
return nil if host.blank? || host.match?(/^(localhost|127\.0\.0\.1|::1)$/)
|
||||
|
||||
# Strip port number for domain parsing
|
||||
host_without_port = host.split(':').first
|
||||
|
||||
# Check if it's an IP address (IPv4 or IPv6) - if so, don't set domain cookie
|
||||
return nil if IPAddr.new(host_without_port) rescue false
|
||||
|
||||
# Use Public Suffix List for accurate domain parsing
|
||||
domain = PublicSuffix.parse(host_without_port)
|
||||
".#{domain.domain}"
|
||||
rescue PublicSuffix::DomainInvalid
|
||||
# Fallback for invalid domains or IPs
|
||||
nil
|
||||
end
|
||||
|
||||
# Create a one-time token for forward auth to handle the race condition
|
||||
# where the browser hasn't processed the session cookie yet
|
||||
def create_forward_auth_token(session_obj)
|
||||
# Generate a secure random token
|
||||
token = SecureRandom.urlsafe_base64(32)
|
||||
|
||||
# Store it with an expiry of 60 seconds
|
||||
Rails.cache.write(
|
||||
"forward_auth_token:#{token}",
|
||||
session_obj.id,
|
||||
expires_in: 60.seconds
|
||||
)
|
||||
|
||||
# Set the token as a query parameter on the redirect URL
|
||||
# We need to store this in the controller's session
|
||||
controller_session = session
|
||||
if controller_session[:return_to_after_authenticating].present?
|
||||
original_url = controller_session[:return_to_after_authenticating]
|
||||
uri = URI.parse(original_url)
|
||||
|
||||
# Skip adding fa_token for OAuth URLs (OAuth flow should not have forward auth tokens)
|
||||
unless uri.path&.start_with?("/oauth/")
|
||||
# Add token as query parameter
|
||||
query_params = URI.decode_www_form(uri.query || "").to_h
|
||||
query_params['fa_token'] = token
|
||||
uri.query = URI.encode_www_form(query_params)
|
||||
|
||||
# Update the session with the tokenized URL
|
||||
controller_session[:return_to_after_authenticating] = uri.to_s
|
||||
end
|
||||
# Update the session with the tokenized URL
|
||||
controller_session[:return_to_after_authenticating] = uri.to_s
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
class InvitationsController < ApplicationController
|
||||
include Authentication
|
||||
|
||||
allow_unauthenticated_access
|
||||
before_action :set_user_by_invitation_token, only: %i[ show update ]
|
||||
before_action :set_user_by_invitation_token, only: %i[show update]
|
||||
|
||||
def show
|
||||
# Show the password setup form
|
||||
@@ -35,16 +36,16 @@ class InvitationsController < ApplicationController
|
||||
# Check if user is still pending invitation
|
||||
if @user.nil?
|
||||
redirect_to signin_path, alert: "Invitation link is invalid or has expired."
|
||||
return false
|
||||
false
|
||||
elsif @user.pending_invitation?
|
||||
# User is valid and pending - proceed
|
||||
return true
|
||||
true
|
||||
else
|
||||
redirect_to signin_path, alert: "This invitation has already been used or is no longer valid."
|
||||
return false
|
||||
false
|
||||
end
|
||||
rescue ActiveSupport::MessageVerifier::InvalidSignature
|
||||
redirect_to signin_path, alert: "Invitation link is invalid or has expired."
|
||||
return false
|
||||
false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,6 +3,14 @@ class OidcController < ApplicationController
|
||||
allow_unauthenticated_access only: [:discovery, :jwks, :token, :revoke, :userinfo, :logout]
|
||||
skip_before_action :verify_authenticity_token, only: [:token, :revoke, :logout]
|
||||
|
||||
# Rate limiting to prevent brute force and abuse
|
||||
rate_limit to: 60, within: 1.minute, only: [:token, :revoke], with: -> {
|
||||
render json: {error: "too_many_requests", error_description: "Rate limit exceeded. Try again later."}, status: :too_many_requests
|
||||
}
|
||||
rate_limit to: 30, within: 1.minute, only: [:authorize, :consent], with: -> {
|
||||
render plain: "Too many authorization attempts. Try again later.", status: :too_many_requests
|
||||
}
|
||||
|
||||
# GET /.well-known/openid-configuration
|
||||
def discovery
|
||||
base_url = OidcJwtService.issuer_url
|
||||
@@ -18,11 +26,11 @@ class OidcController < ApplicationController
|
||||
response_types_supported: ["code"],
|
||||
response_modes_supported: ["query"],
|
||||
grant_types_supported: ["authorization_code", "refresh_token"],
|
||||
subject_types_supported: ["public"],
|
||||
subject_types_supported: ["pairwise"],
|
||||
id_token_signing_alg_values_supported: ["RS256"],
|
||||
scopes_supported: ["openid", "profile", "email", "groups", "offline_access"],
|
||||
token_endpoint_auth_methods_supported: ["client_secret_post", "client_secret_basic"],
|
||||
claims_supported: ["sub", "email", "email_verified", "name", "preferred_username", "groups", "admin"],
|
||||
claims_supported: ["sub", "email", "email_verified", "name", "preferred_username", "groups", "admin", "auth_time", "acr", "azp", "at_hash"],
|
||||
code_challenge_methods_supported: ["plain", "S256"],
|
||||
backchannel_logout_supported: true,
|
||||
backchannel_logout_session_supported: true
|
||||
@@ -55,7 +63,7 @@ class OidcController < ApplicationController
|
||||
error_details << "redirect_uri is required" unless redirect_uri.present?
|
||||
error_details << "response_type must be 'code'" unless response_type == "code"
|
||||
|
||||
render plain: "Invalid request: #{error_details.join(', ')}", status: :bad_request
|
||||
render plain: "Invalid request: #{error_details.join(", ")}", status: :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
@@ -82,7 +90,7 @@ class OidcController < ApplicationController
|
||||
Rails.logger.error "OAuth: Available OIDC applications: #{all_oidc_apps.pluck(:id, :client_id, :name)}"
|
||||
|
||||
error_msg = if Rails.env.development?
|
||||
"Invalid request: Application not found for client_id '#{client_id}'. Available OIDC applications: #{all_oidc_apps.pluck(:name, :client_id).map { |name, id| "#{name} (#{id})" }.join(', ')}"
|
||||
"Invalid request: Application not found for client_id '#{client_id}'. Available OIDC applications: #{all_oidc_apps.pluck(:name, :client_id).map { |name, id| "#{name} (#{id})" }.join(", ")}"
|
||||
else
|
||||
"Invalid request: Application not found"
|
||||
end
|
||||
@@ -91,13 +99,13 @@ class OidcController < ApplicationController
|
||||
return
|
||||
end
|
||||
|
||||
# Validate redirect URI
|
||||
# Validate redirect URI first (required before we can safely redirect with errors)
|
||||
unless @application.parsed_redirect_uris.include?(redirect_uri)
|
||||
Rails.logger.error "OAuth: Invalid request - redirect URI mismatch. Expected: #{@application.parsed_redirect_uris}, Got: #{redirect_uri}"
|
||||
|
||||
# For development, show detailed error
|
||||
error_msg = if Rails.env.development?
|
||||
"Invalid request: Redirect URI mismatch. Application is configured for: #{@application.parsed_redirect_uris.join(', ')}, but received: #{redirect_uri}"
|
||||
"Invalid request: Redirect URI mismatch. Application is configured for: #{@application.parsed_redirect_uris.join(", ")}, but received: #{redirect_uri}"
|
||||
else
|
||||
"Invalid request: Redirect URI not registered for this application"
|
||||
end
|
||||
@@ -106,6 +114,15 @@ class OidcController < ApplicationController
|
||||
return
|
||||
end
|
||||
|
||||
# Check if application is active (now we can safely redirect with error)
|
||||
unless @application.active?
|
||||
Rails.logger.error "OAuth: Application is not active: #{@application.name}"
|
||||
error_uri = "#{redirect_uri}?error=unauthorized_client&error_description=Application+is+not+active"
|
||||
error_uri += "&state=#{CGI.escape(state)}" if state.present?
|
||||
redirect_to error_uri, allow_other_host: true
|
||||
return
|
||||
end
|
||||
|
||||
# Check if user is authenticated
|
||||
unless authenticated?
|
||||
# Store OAuth parameters in session and redirect to sign in
|
||||
@@ -137,22 +154,22 @@ class OidcController < ApplicationController
|
||||
existing_consent = user.has_oidc_consent?(@application, requested_scopes)
|
||||
if existing_consent
|
||||
# User has already consented, generate authorization code directly
|
||||
code = SecureRandom.urlsafe_base64(32)
|
||||
auth_code = OidcAuthorizationCode.create!(
|
||||
application: @application,
|
||||
user: user,
|
||||
code: code,
|
||||
redirect_uri: redirect_uri,
|
||||
scope: scope,
|
||||
nonce: nonce,
|
||||
code_challenge: code_challenge,
|
||||
code_challenge_method: code_challenge_method,
|
||||
auth_time: Current.session.created_at.to_i,
|
||||
acr: Current.session.acr,
|
||||
expires_at: 10.minutes.from_now
|
||||
)
|
||||
|
||||
# Redirect back to client with authorization code
|
||||
redirect_uri = "#{redirect_uri}?code=#{code}"
|
||||
redirect_uri += "&state=#{state}" if state.present?
|
||||
# Redirect back to client with authorization code (plaintext)
|
||||
redirect_uri = "#{redirect_uri}?code=#{auth_code.plaintext_code}"
|
||||
redirect_uri += "&state=#{CGI.escape(state)}" if state.present?
|
||||
redirect_to redirect_uri, allow_other_host: true
|
||||
return
|
||||
end
|
||||
@@ -206,49 +223,55 @@ class OidcController < ApplicationController
|
||||
# User denied consent
|
||||
if params[:deny].present?
|
||||
session.delete(:oauth_params)
|
||||
error_uri = "#{oauth_params['redirect_uri']}?error=access_denied"
|
||||
error_uri += "&state=#{oauth_params['state']}" if oauth_params['state']
|
||||
error_uri = "#{oauth_params["redirect_uri"]}?error=access_denied"
|
||||
error_uri += "&state=#{CGI.escape(oauth_params["state"])}" if oauth_params["state"]
|
||||
redirect_to error_uri, allow_other_host: true
|
||||
return
|
||||
end
|
||||
|
||||
# Find the application
|
||||
client_id = oauth_params['client_id']
|
||||
client_id = oauth_params["client_id"]
|
||||
application = Application.find_by(client_id: client_id, app_type: "oidc")
|
||||
|
||||
# Check if application is active (redirect with OAuth error)
|
||||
unless application&.active?
|
||||
Rails.logger.error "OAuth: Application is not active: #{application&.name || client_id}"
|
||||
session.delete(:oauth_params)
|
||||
error_uri = "#{oauth_params["redirect_uri"]}?error=unauthorized_client&error_description=Application+is+not+active"
|
||||
error_uri += "&state=#{CGI.escape(oauth_params["state"])}" if oauth_params["state"].present?
|
||||
redirect_to error_uri, allow_other_host: true
|
||||
return
|
||||
end
|
||||
|
||||
user = Current.session.user
|
||||
|
||||
# Record user consent
|
||||
requested_scopes = oauth_params['scope'].split(' ')
|
||||
OidcUserConsent.upsert(
|
||||
{
|
||||
user_id: user.id,
|
||||
application_id: application.id,
|
||||
scopes_granted: requested_scopes.join(' '),
|
||||
granted_at: Time.current
|
||||
},
|
||||
unique_by: [:user_id, :application_id]
|
||||
)
|
||||
requested_scopes = oauth_params["scope"].split(" ")
|
||||
consent = OidcUserConsent.find_or_initialize_by(user: user, application: application)
|
||||
consent.scopes_granted = requested_scopes.join(" ")
|
||||
consent.granted_at = Time.current
|
||||
consent.save!
|
||||
|
||||
# Generate authorization code
|
||||
code = SecureRandom.urlsafe_base64(32)
|
||||
auth_code = OidcAuthorizationCode.create!(
|
||||
application: application,
|
||||
user: user,
|
||||
code: code,
|
||||
redirect_uri: oauth_params['redirect_uri'],
|
||||
scope: oauth_params['scope'],
|
||||
nonce: oauth_params['nonce'],
|
||||
code_challenge: oauth_params['code_challenge'],
|
||||
code_challenge_method: oauth_params['code_challenge_method'],
|
||||
redirect_uri: oauth_params["redirect_uri"],
|
||||
scope: oauth_params["scope"],
|
||||
nonce: oauth_params["nonce"],
|
||||
code_challenge: oauth_params["code_challenge"],
|
||||
code_challenge_method: oauth_params["code_challenge_method"],
|
||||
auth_time: Current.session.created_at.to_i,
|
||||
acr: Current.session.acr,
|
||||
expires_at: 10.minutes.from_now
|
||||
)
|
||||
|
||||
# Clear OAuth params from session
|
||||
session.delete(:oauth_params)
|
||||
|
||||
# Redirect back to client with authorization code
|
||||
redirect_uri = "#{oauth_params['redirect_uri']}?code=#{code}"
|
||||
redirect_uri += "&state=#{oauth_params['state']}" if oauth_params['state']
|
||||
# Redirect back to client with authorization code (plaintext)
|
||||
redirect_uri = "#{oauth_params["redirect_uri"]}?code=#{auth_code.plaintext_code}"
|
||||
redirect_uri += "&state=#{CGI.escape(oauth_params["state"])}" if oauth_params["state"]
|
||||
|
||||
redirect_to redirect_uri, allow_other_host: true
|
||||
end
|
||||
@@ -263,24 +286,42 @@ class OidcController < ApplicationController
|
||||
when "refresh_token"
|
||||
handle_refresh_token_grant
|
||||
else
|
||||
render json: { error: "unsupported_grant_type" }, status: :bad_request
|
||||
render json: {error: "unsupported_grant_type"}, status: :bad_request
|
||||
end
|
||||
end
|
||||
|
||||
def handle_authorization_code_grant
|
||||
|
||||
# Get client credentials from Authorization header or params
|
||||
client_id, client_secret = extract_client_credentials
|
||||
|
||||
unless client_id && client_secret
|
||||
render json: { error: "invalid_client" }, status: :unauthorized
|
||||
unless client_id
|
||||
render json: {error: "invalid_client", error_description: "client_id is required"}, status: :unauthorized
|
||||
return
|
||||
end
|
||||
|
||||
# Find and validate the application
|
||||
# Find the application
|
||||
application = Application.find_by(client_id: client_id)
|
||||
unless application && application.authenticate_client_secret(client_secret)
|
||||
render json: { error: "invalid_client" }, status: :unauthorized
|
||||
unless application
|
||||
render json: {error: "invalid_client", error_description: "Unknown client"}, status: :unauthorized
|
||||
return
|
||||
end
|
||||
|
||||
# Validate client credentials based on client type
|
||||
if application.public_client?
|
||||
# Public clients don't have a secret - they MUST use PKCE (checked later)
|
||||
Rails.logger.info "OAuth: Public client authentication for #{application.name}"
|
||||
else
|
||||
# Confidential clients MUST provide valid client_secret
|
||||
unless client_secret.present? && application.authenticate_client_secret(client_secret)
|
||||
render json: {error: "invalid_client", error_description: "Invalid client credentials"}, status: :unauthorized
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
# Check if application is active
|
||||
unless application.active?
|
||||
Rails.logger.error "OAuth: Token request for inactive application: #{application.name}"
|
||||
render json: {error: "invalid_client", error_description: "Application is not active"}, status: :forbidden
|
||||
return
|
||||
end
|
||||
|
||||
@@ -289,13 +330,11 @@ class OidcController < ApplicationController
|
||||
redirect_uri = params[:redirect_uri]
|
||||
code_verifier = params[:code_verifier]
|
||||
|
||||
auth_code = OidcAuthorizationCode.find_by(
|
||||
application: application,
|
||||
code: code
|
||||
)
|
||||
# Find authorization code using HMAC verification
|
||||
auth_code = OidcAuthorizationCode.find_by_plaintext(code)
|
||||
|
||||
unless auth_code
|
||||
render json: { error: "invalid_grant" }, status: :bad_request
|
||||
unless auth_code && auth_code.application == application
|
||||
render json: {error: "invalid_grant"}, status: :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
@@ -326,18 +365,18 @@ class OidcController < ApplicationController
|
||||
|
||||
# Check if code is expired
|
||||
if auth_code.expires_at < Time.current
|
||||
render json: { error: "invalid_grant", error_description: "Authorization code expired" }, status: :bad_request
|
||||
render json: {error: "invalid_grant", error_description: "Authorization code expired"}, status: :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
# Validate redirect URI matches
|
||||
unless auth_code.redirect_uri == redirect_uri
|
||||
render json: { error: "invalid_grant", error_description: "Redirect URI mismatch" }, status: :bad_request
|
||||
render json: {error: "invalid_grant", error_description: "Redirect URI mismatch"}, status: :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
# Validate PKCE if code challenge is present
|
||||
pkce_result = validate_pkce(auth_code, code_verifier)
|
||||
# Validate PKCE - required for public clients and optionally for confidential clients
|
||||
pkce_result = validate_pkce(application, auth_code, code_verifier)
|
||||
unless pkce_result[:valid]
|
||||
render json: {
|
||||
error: pkce_result[:error],
|
||||
@@ -364,7 +403,9 @@ class OidcController < ApplicationController
|
||||
application: application,
|
||||
user: user,
|
||||
oidc_access_token: access_token_record,
|
||||
scope: auth_code.scope
|
||||
scope: auth_code.scope,
|
||||
auth_time: auth_code.auth_time,
|
||||
acr: auth_code.acr
|
||||
)
|
||||
|
||||
# Find user consent for this application
|
||||
@@ -372,12 +413,21 @@ class OidcController < ApplicationController
|
||||
|
||||
unless consent
|
||||
Rails.logger.error "OIDC Security: Token requested without consent record (user: #{user.id}, app: #{application.id})"
|
||||
render json: { error: "invalid_grant", error_description: "Authorization consent not found" }, status: :bad_request
|
||||
render json: {error: "invalid_grant", error_description: "Authorization consent not found"}, status: :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
# Generate ID token (JWT) with pairwise SID
|
||||
id_token = OidcJwtService.generate_id_token(user, application, consent: consent, nonce: auth_code.nonce)
|
||||
# Generate ID token (JWT) with pairwise SID, at_hash, auth_time, and acr
|
||||
# auth_time and acr come from the authorization code (captured at /authorize time)
|
||||
id_token = OidcJwtService.generate_id_token(
|
||||
user,
|
||||
application,
|
||||
consent: consent,
|
||||
nonce: auth_code.nonce,
|
||||
access_token: access_token_record.plaintext_token,
|
||||
auth_time: auth_code.auth_time,
|
||||
acr: auth_code.acr
|
||||
)
|
||||
|
||||
# Return tokens
|
||||
render json: {
|
||||
@@ -390,7 +440,7 @@ class OidcController < ApplicationController
|
||||
}
|
||||
end
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
render json: { error: "invalid_grant" }, status: :bad_request
|
||||
render json: {error: "invalid_grant"}, status: :bad_request
|
||||
end
|
||||
end
|
||||
|
||||
@@ -398,40 +448,56 @@ class OidcController < ApplicationController
|
||||
# Get client credentials from Authorization header or params
|
||||
client_id, client_secret = extract_client_credentials
|
||||
|
||||
unless client_id && client_secret
|
||||
render json: { error: "invalid_client" }, status: :unauthorized
|
||||
unless client_id
|
||||
render json: {error: "invalid_client", error_description: "client_id is required"}, status: :unauthorized
|
||||
return
|
||||
end
|
||||
|
||||
# Find and validate the application
|
||||
# Find the application
|
||||
application = Application.find_by(client_id: client_id)
|
||||
unless application && application.authenticate_client_secret(client_secret)
|
||||
render json: { error: "invalid_client" }, status: :unauthorized
|
||||
unless application
|
||||
render json: {error: "invalid_client", error_description: "Unknown client"}, status: :unauthorized
|
||||
return
|
||||
end
|
||||
|
||||
# Validate client credentials based on client type
|
||||
if application.public_client?
|
||||
# Public clients don't have a secret
|
||||
Rails.logger.info "OAuth: Public client refresh token request for #{application.name}"
|
||||
else
|
||||
# Confidential clients MUST provide valid client_secret
|
||||
unless client_secret.present? && application.authenticate_client_secret(client_secret)
|
||||
render json: {error: "invalid_client", error_description: "Invalid client credentials"}, status: :unauthorized
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
# Check if application is active
|
||||
unless application.active?
|
||||
Rails.logger.error "OAuth: Refresh token request for inactive application: #{application.name}"
|
||||
render json: {error: "invalid_client", error_description: "Application is not active"}, status: :forbidden
|
||||
return
|
||||
end
|
||||
|
||||
# Get the refresh token
|
||||
refresh_token = params[:refresh_token]
|
||||
unless refresh_token.present?
|
||||
render json: { error: "invalid_request", error_description: "refresh_token is required" }, status: :bad_request
|
||||
render json: {error: "invalid_request", error_description: "refresh_token is required"}, status: :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
# Find the refresh token record
|
||||
# Note: This is inefficient with BCrypt hashing, but necessary for security
|
||||
# In production, consider adding a token prefix for faster lookup
|
||||
refresh_token_record = OidcRefreshToken.where(application: application).find do |rt|
|
||||
rt.token_matches?(refresh_token)
|
||||
end
|
||||
# Find the refresh token record using indexed token prefix lookup
|
||||
refresh_token_record = OidcRefreshToken.find_by_token(refresh_token)
|
||||
|
||||
unless refresh_token_record
|
||||
render json: { error: "invalid_grant", error_description: "Invalid refresh token" }, status: :bad_request
|
||||
# Verify the token belongs to the correct application
|
||||
unless refresh_token_record && refresh_token_record.application == application
|
||||
render json: {error: "invalid_grant", error_description: "Invalid refresh token"}, status: :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
# Check if refresh token is expired
|
||||
if refresh_token_record.expired?
|
||||
render json: { error: "invalid_grant", error_description: "Refresh token expired" }, status: :bad_request
|
||||
render json: {error: "invalid_grant", error_description: "Refresh token expired"}, status: :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
@@ -442,7 +508,7 @@ class OidcController < ApplicationController
|
||||
Rails.logger.warn "OAuth Security: Revoked refresh token reuse detected for token family #{refresh_token_record.token_family_id}"
|
||||
refresh_token_record.revoke_family!
|
||||
|
||||
render json: { error: "invalid_grant", error_description: "Refresh token has been revoked" }, status: :bad_request
|
||||
render json: {error: "invalid_grant", error_description: "Refresh token has been revoked"}, status: :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
@@ -465,7 +531,9 @@ class OidcController < ApplicationController
|
||||
user: user,
|
||||
oidc_access_token: new_access_token,
|
||||
scope: refresh_token_record.scope,
|
||||
token_family_id: refresh_token_record.token_family_id # Keep same family for rotation tracking
|
||||
token_family_id: refresh_token_record.token_family_id, # Keep same family for rotation tracking
|
||||
auth_time: refresh_token_record.auth_time, # Carry over original auth_time
|
||||
acr: refresh_token_record.acr # Carry over original acr
|
||||
)
|
||||
|
||||
# Find user consent for this application
|
||||
@@ -473,12 +541,20 @@ class OidcController < ApplicationController
|
||||
|
||||
unless consent
|
||||
Rails.logger.error "OIDC Security: Refresh token used without consent record (user: #{user.id}, app: #{application.id})"
|
||||
render json: { error: "invalid_grant", error_description: "Authorization consent not found" }, status: :bad_request
|
||||
render json: {error: "invalid_grant", error_description: "Authorization consent not found"}, status: :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
# Generate new ID token (JWT with pairwise SID, no nonce for refresh grants)
|
||||
id_token = OidcJwtService.generate_id_token(user, application, consent: consent)
|
||||
# Generate new ID token (JWT with pairwise SID, at_hash, auth_time, acr; no nonce for refresh grants)
|
||||
# auth_time and acr come from the original refresh token (carried over from initial auth)
|
||||
id_token = OidcJwtService.generate_id_token(
|
||||
user,
|
||||
application,
|
||||
consent: consent,
|
||||
access_token: new_access_token.plaintext_token,
|
||||
auth_time: refresh_token_record.auth_time,
|
||||
acr: refresh_token_record.acr
|
||||
)
|
||||
|
||||
# Return new tokens
|
||||
render json: {
|
||||
@@ -490,7 +566,7 @@ class OidcController < ApplicationController
|
||||
scope: refresh_token_record.scope
|
||||
}
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
render json: { error: "invalid_grant" }, status: :bad_request
|
||||
render json: {error: "invalid_grant"}, status: :bad_request
|
||||
end
|
||||
|
||||
# GET /oauth/userinfo
|
||||
@@ -511,6 +587,13 @@ class OidcController < ApplicationController
|
||||
return
|
||||
end
|
||||
|
||||
# Check if application is active (immediate cutoff when app is disabled)
|
||||
unless access_token.application&.active?
|
||||
Rails.logger.warn "OAuth: Userinfo request for inactive application: #{access_token.application&.name}"
|
||||
head :forbidden
|
||||
return
|
||||
end
|
||||
|
||||
# Get the user (with fresh data from database)
|
||||
user = access_token.user
|
||||
unless user
|
||||
@@ -567,19 +650,26 @@ class OidcController < ApplicationController
|
||||
|
||||
# Find and validate the application
|
||||
application = Application.find_by(client_id: client_id)
|
||||
unless application && application.authenticate_client_secret(client_secret)
|
||||
unless application&.authenticate_client_secret(client_secret)
|
||||
Rails.logger.warn "OAuth: Token revocation attempted for invalid application: #{client_id}"
|
||||
head :ok
|
||||
return
|
||||
end
|
||||
|
||||
# Check if application is active (RFC 7009: still return 200 OK for privacy)
|
||||
unless application.active?
|
||||
Rails.logger.warn "OAuth: Token revocation attempted for inactive application: #{application.name}"
|
||||
head :ok
|
||||
return
|
||||
end
|
||||
|
||||
# Get the token to revoke
|
||||
token = params[:token]
|
||||
token_type_hint = params[:token_type_hint] # Optional hint: "access_token" or "refresh_token"
|
||||
|
||||
unless token.present?
|
||||
# RFC 7009: Missing token parameter is an error
|
||||
render json: { error: "invalid_request", error_description: "token parameter is required" }, status: :bad_request
|
||||
render json: {error: "invalid_request", error_description: "token parameter is required"}, status: :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
@@ -589,9 +679,7 @@ class OidcController < ApplicationController
|
||||
|
||||
if token_type_hint == "refresh_token" || token_type_hint.nil?
|
||||
# Try to find as refresh token
|
||||
refresh_token_record = OidcRefreshToken.where(application: application).find do |rt|
|
||||
rt.token_matches?(token)
|
||||
end
|
||||
refresh_token_record = OidcRefreshToken.find_by_token(token)
|
||||
|
||||
if refresh_token_record
|
||||
refresh_token_record.revoke!
|
||||
@@ -602,14 +690,12 @@ class OidcController < ApplicationController
|
||||
|
||||
if !revoked && (token_type_hint == "access_token" || token_type_hint.nil?)
|
||||
# Try to find as access token
|
||||
access_token_record = OidcAccessToken.where(application: application).find do |at|
|
||||
at.token_matches?(token)
|
||||
end
|
||||
access_token_record = OidcAccessToken.find_by_token(token)
|
||||
|
||||
if access_token_record
|
||||
access_token_record.revoke!
|
||||
Rails.logger.info "OAuth: Access token revoked for application #{application.name}"
|
||||
revoked = true
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
@@ -623,7 +709,7 @@ class OidcController < ApplicationController
|
||||
# OpenID Connect RP-Initiated Logout
|
||||
# Handle id_token_hint and post_logout_redirect_uri parameters
|
||||
|
||||
id_token_hint = params[:id_token_hint]
|
||||
params[:id_token_hint]
|
||||
post_logout_redirect_uri = params[:post_logout_redirect_uri]
|
||||
state = params[:state]
|
||||
|
||||
@@ -645,7 +731,7 @@ class OidcController < ApplicationController
|
||||
|
||||
if validated_uri
|
||||
redirect_uri = validated_uri
|
||||
redirect_uri += "?state=#{state}" if state.present?
|
||||
redirect_uri += "?state=#{CGI.escape(state)}" if state.present?
|
||||
redirect_to redirect_uri, allow_other_host: true
|
||||
else
|
||||
# Invalid redirect URI - log warning and go to default
|
||||
@@ -660,11 +746,26 @@ class OidcController < ApplicationController
|
||||
|
||||
private
|
||||
|
||||
def validate_pkce(auth_code, code_verifier)
|
||||
# Skip PKCE validation if no code challenge was stored (legacy clients)
|
||||
return { valid: true } unless auth_code.code_challenge.present?
|
||||
def validate_pkce(application, auth_code, code_verifier)
|
||||
# Check if PKCE is required for this application
|
||||
pkce_required = application.requires_pkce?
|
||||
pkce_provided = auth_code.code_challenge.present?
|
||||
|
||||
# PKCE is required but no verifier provided
|
||||
# If PKCE is required but wasn't provided during authorization
|
||||
if pkce_required && !pkce_provided
|
||||
client_type = application.public_client? ? "public clients" : "this application"
|
||||
return {
|
||||
valid: false,
|
||||
error: "invalid_request",
|
||||
error_description: "PKCE is required for #{client_type}. code_challenge must be provided during authorization.",
|
||||
status: :bad_request
|
||||
}
|
||||
end
|
||||
|
||||
# Skip validation if no code challenge was stored (legacy clients without PKCE requirement)
|
||||
return {valid: true} unless pkce_provided
|
||||
|
||||
# PKCE was provided during authorization but no verifier sent with token request
|
||||
unless code_verifier.present?
|
||||
return {
|
||||
valid: false,
|
||||
@@ -686,18 +787,18 @@ class OidcController < ApplicationController
|
||||
|
||||
# Recreate code challenge based on method
|
||||
expected_challenge = case auth_code.code_challenge_method
|
||||
when "plain"
|
||||
code_verifier
|
||||
when "S256"
|
||||
Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false)
|
||||
else
|
||||
return {
|
||||
valid: false,
|
||||
error: "server_error",
|
||||
error_description: "Unsupported code challenge method",
|
||||
status: :internal_server_error
|
||||
}
|
||||
end
|
||||
when "plain"
|
||||
code_verifier
|
||||
when "S256"
|
||||
Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false)
|
||||
else
|
||||
return {
|
||||
valid: false,
|
||||
error: "server_error",
|
||||
error_description: "Unsupported code challenge method",
|
||||
status: :internal_server_error
|
||||
}
|
||||
end
|
||||
|
||||
# Validate the code challenge
|
||||
unless auth_code.code_challenge == expected_challenge
|
||||
@@ -709,7 +810,7 @@ class OidcController < ApplicationController
|
||||
}
|
||||
end
|
||||
|
||||
{ valid: true }
|
||||
{valid: true}
|
||||
end
|
||||
|
||||
def extract_client_credentials
|
||||
@@ -734,7 +835,7 @@ class OidcController < ApplicationController
|
||||
return nil unless parsed_uri.is_a?(URI::HTTP) || parsed_uri.is_a?(URI::HTTPS)
|
||||
|
||||
# Only allow HTTPS in production
|
||||
return nil if Rails.env.production? && parsed_uri.scheme != 'https'
|
||||
return nil if Rails.env.production? && parsed_uri.scheme != "https"
|
||||
|
||||
# Check if URI matches any registered OIDC application's redirect URIs
|
||||
# According to OIDC spec, post_logout_redirect_uri should be pre-registered
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
class PasswordsController < ApplicationController
|
||||
allow_unauthenticated_access
|
||||
before_action :set_user_by_token, only: %i[ edit update ]
|
||||
before_action :set_user_by_token, only: %i[edit update]
|
||||
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_password_path, alert: "Try again later." }
|
||||
|
||||
def new
|
||||
end
|
||||
|
||||
def create
|
||||
if user = User.find_by(email_address: params[:email_address])
|
||||
if (user = User.find_by(email_address: params[:email_address]))
|
||||
PasswordsMailer.reset(user).deliver_later
|
||||
end
|
||||
|
||||
@@ -27,10 +27,11 @@ class PasswordsController < ApplicationController
|
||||
end
|
||||
|
||||
private
|
||||
def set_user_by_token
|
||||
@user = User.find_by_token_for(:password_reset, params[:token])
|
||||
redirect_to new_password_path, alert: "Password reset link is invalid or has expired." if @user.nil?
|
||||
rescue ActiveSupport::MessageVerifier::InvalidSignature
|
||||
redirect_to new_password_path, alert: "Password reset link is invalid or has expired."
|
||||
end
|
||||
|
||||
def set_user_by_token
|
||||
@user = User.find_by_token_for(:password_reset, params[:token])
|
||||
redirect_to new_password_path, alert: "Password reset link is invalid or has expired." if @user.nil?
|
||||
rescue ActiveSupport::MessageVerifier::InvalidSignature
|
||||
redirect_to new_password_path, alert: "Password reset link is invalid or has expired."
|
||||
end
|
||||
end
|
||||
|
||||
@@ -19,13 +19,21 @@ class ProfilesController < ApplicationController
|
||||
else
|
||||
render :show, status: :unprocessable_entity
|
||||
end
|
||||
else
|
||||
# Updating email
|
||||
elsif params[:user][:email_address].present?
|
||||
# Updating email - requires current password (security: prevents account takeover)
|
||||
unless @user.authenticate(params[:user][:current_password])
|
||||
@user.errors.add(:current_password, "is required to change email")
|
||||
render :show, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
if @user.update(email_params)
|
||||
redirect_to profile_path, notice: "Email updated successfully."
|
||||
else
|
||||
render :show, status: :unprocessable_entity
|
||||
end
|
||||
else
|
||||
render :show, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
class SessionsController < ApplicationController
|
||||
allow_unauthenticated_access only: %i[ new create verify_totp webauthn_challenge webauthn_verify ]
|
||||
allow_unauthenticated_access only: %i[new create verify_totp webauthn_challenge webauthn_verify]
|
||||
rate_limit to: 20, within: 3.minutes, only: :create, with: -> { redirect_to signin_path, alert: "Too many attempts. Try again later." }
|
||||
rate_limit to: 10, within: 3.minutes, only: :verify_totp, with: -> { redirect_to totp_verification_path, alert: "Too many attempts. Try again later." }
|
||||
rate_limit to: 10, within: 3.minutes, only: [:webauthn_challenge, :webauthn_verify], with: -> { render json: { error: "Too many attempts. Try again later." }, status: :too_many_requests }
|
||||
rate_limit to: 10, within: 3.minutes, only: [:webauthn_challenge, :webauthn_verify], with: -> { render json: {error: "Too many attempts. Try again later."}, status: :too_many_requests }
|
||||
|
||||
def new
|
||||
# Redirect to signup if this is first run
|
||||
if User.count.zero?
|
||||
respond_to do |format|
|
||||
format.html { redirect_to signup_path }
|
||||
format.json { render json: { error: "No users exist. Please complete initial setup." }, status: :service_unavailable }
|
||||
format.json { render json: {error: "No users exist. Please complete initial setup."}, status: :service_unavailable }
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
respond_to do |format|
|
||||
format.html # render HTML login page
|
||||
format.json { render json: { error: "Authentication required" }, status: :unauthorized }
|
||||
format.json { render json: {error: "Authentication required"}, status: :unauthorized }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -71,8 +71,8 @@ class SessionsController < ApplicationController
|
||||
return
|
||||
end
|
||||
|
||||
# Sign in successful
|
||||
start_new_session_for user
|
||||
# Sign in successful (password only)
|
||||
start_new_session_for user, acr: "1"
|
||||
redirect_to after_authentication_url, notice: "Signed in successfully.", allow_other_host: true
|
||||
end
|
||||
|
||||
@@ -101,33 +101,33 @@ class SessionsController < ApplicationController
|
||||
return
|
||||
end
|
||||
|
||||
# Try TOTP verification first
|
||||
# Try TOTP verification first (password + TOTP = 2FA)
|
||||
if user.verify_totp(code)
|
||||
session.delete(:pending_totp_user_id)
|
||||
# Restore redirect URL if it was preserved
|
||||
if session[:totp_redirect_url].present?
|
||||
session[:return_to_after_authenticating] = session.delete(:totp_redirect_url)
|
||||
end
|
||||
start_new_session_for user
|
||||
start_new_session_for user, acr: "2"
|
||||
redirect_to after_authentication_url, notice: "Signed in successfully.", allow_other_host: true
|
||||
return
|
||||
end
|
||||
|
||||
# Try backup code verification
|
||||
# Try backup code verification (password + backup code = 2FA)
|
||||
if user.verify_backup_code(code)
|
||||
session.delete(:pending_totp_user_id)
|
||||
# Restore redirect URL if it was preserved
|
||||
if session[:totp_redirect_url].present?
|
||||
session[:return_to_after_authenticating] = session.delete(:totp_redirect_url)
|
||||
end
|
||||
start_new_session_for user
|
||||
start_new_session_for user, acr: "2"
|
||||
redirect_to after_authentication_url, notice: "Signed in successfully using backup code.", allow_other_host: true
|
||||
return
|
||||
end
|
||||
|
||||
# Invalid code
|
||||
redirect_to totp_verification_path, alert: "Invalid verification code. Please try again."
|
||||
return
|
||||
nil
|
||||
end
|
||||
|
||||
# Just render the form
|
||||
@@ -155,14 +155,14 @@ class SessionsController < ApplicationController
|
||||
email = params[:email]&.strip&.downcase
|
||||
|
||||
if email.blank?
|
||||
render json: { error: "Email is required" }, status: :unprocessable_entity
|
||||
render json: {error: "Email is required"}, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
user = User.find_by(email_address: email)
|
||||
|
||||
if user.nil? || !user.can_authenticate_with_webauthn?
|
||||
render json: { error: "User not found or WebAuthn not available" }, status: :unprocessable_entity
|
||||
render json: {error: "User not found or WebAuthn not available"}, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
@@ -191,10 +191,9 @@ class SessionsController < ApplicationController
|
||||
session[:webauthn_challenge] = options.challenge
|
||||
|
||||
render json: options
|
||||
|
||||
rescue => e
|
||||
Rails.logger.error "WebAuthn challenge generation error: #{e.message}"
|
||||
render json: { error: "Failed to generate WebAuthn challenge" }, status: :internal_server_error
|
||||
render json: {error: "Failed to generate WebAuthn challenge"}, status: :internal_server_error
|
||||
end
|
||||
end
|
||||
|
||||
@@ -202,21 +201,21 @@ class SessionsController < ApplicationController
|
||||
# Get pending user from session
|
||||
user_id = session[:pending_webauthn_user_id]
|
||||
unless user_id
|
||||
render json: { error: "Session expired. Please try again." }, status: :unprocessable_entity
|
||||
render json: {error: "Session expired. Please try again."}, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
user = User.find_by(id: user_id)
|
||||
unless user
|
||||
session.delete(:pending_webauthn_user_id)
|
||||
render json: { error: "Session expired. Please try again." }, status: :unprocessable_entity
|
||||
render json: {error: "Session expired. Please try again."}, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
# Get the credential and assertion from params
|
||||
credential_data = params[:credential]
|
||||
if credential_data.blank?
|
||||
render json: { error: "Credential data is required" }, status: :unprocessable_entity
|
||||
render json: {error: "Credential data is required"}, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
@@ -224,7 +223,7 @@ class SessionsController < ApplicationController
|
||||
challenge = session.delete(:webauthn_challenge)
|
||||
|
||||
if challenge.blank?
|
||||
render json: { error: "Invalid or expired session" }, status: :unprocessable_entity
|
||||
render json: {error: "Invalid or expired session"}, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
@@ -237,7 +236,7 @@ class SessionsController < ApplicationController
|
||||
stored_credential = user.webauthn_credential_for(external_id)
|
||||
|
||||
if stored_credential.nil?
|
||||
render json: { error: "Credential not found" }, status: :unprocessable_entity
|
||||
render json: {error: "Credential not found"}, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
@@ -268,24 +267,23 @@ class SessionsController < ApplicationController
|
||||
session[:return_to_after_authenticating] = session.delete(:webauthn_redirect_url)
|
||||
end
|
||||
|
||||
# Create session
|
||||
start_new_session_for user
|
||||
# Create session (WebAuthn/passkey = phishing-resistant, ACR = "2")
|
||||
start_new_session_for user, acr: "2"
|
||||
|
||||
render json: {
|
||||
success: true,
|
||||
redirect_to: after_authentication_url,
|
||||
message: "Signed in successfully with passkey"
|
||||
}
|
||||
|
||||
rescue WebAuthn::Error => e
|
||||
Rails.logger.error "WebAuthn verification error: #{e.message}"
|
||||
render json: { error: "Authentication failed: #{e.message}" }, status: :unprocessable_entity
|
||||
render json: {error: "Authentication failed: #{e.message}"}, status: :unprocessable_entity
|
||||
rescue JSON::ParserError => e
|
||||
Rails.logger.error "WebAuthn JSON parsing error: #{e.message}"
|
||||
render json: { error: "Invalid credential format" }, status: :unprocessable_entity
|
||||
render json: {error: "Invalid credential format"}, status: :unprocessable_entity
|
||||
rescue => e
|
||||
Rails.logger.error "Unexpected WebAuthn verification error: #{e.class} - #{e.message}"
|
||||
render json: { error: "An unexpected error occurred" }, status: :internal_server_error
|
||||
render json: {error: "An unexpected error occurred"}, status: :internal_server_error
|
||||
end
|
||||
end
|
||||
|
||||
@@ -301,7 +299,7 @@ class SessionsController < ApplicationController
|
||||
return nil unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
|
||||
|
||||
# Only allow HTTPS in production
|
||||
return nil unless Rails.env.development? || uri.scheme == 'https'
|
||||
return nil unless Rails.env.development? || uri.scheme == "https"
|
||||
|
||||
redirect_domain = uri.host.downcase
|
||||
return nil unless redirect_domain.present?
|
||||
@@ -312,7 +310,6 @@ class SessionsController < ApplicationController
|
||||
end
|
||||
|
||||
matching_app ? url : nil
|
||||
|
||||
rescue URI::InvalidURIError
|
||||
nil
|
||||
end
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
class UsersController < ApplicationController
|
||||
allow_unauthenticated_access only: %i[ new create ]
|
||||
before_action :ensure_first_run, only: %i[ new create ]
|
||||
allow_unauthenticated_access only: %i[new create]
|
||||
before_action :ensure_first_run, only: %i[new create]
|
||||
|
||||
def new
|
||||
@user = User.new
|
||||
|
||||
@@ -2,6 +2,11 @@ class WebauthnController < ApplicationController
|
||||
before_action :set_webauthn_credential, only: [:destroy]
|
||||
skip_before_action :require_authentication, only: [:check]
|
||||
|
||||
# Rate limit check endpoint to prevent enumeration attacks
|
||||
rate_limit to: 10, within: 1.minute, only: [:check], with: -> {
|
||||
render json: {error: "Too many requests. Try again later."}, status: :too_many_requests
|
||||
}
|
||||
|
||||
# GET /webauthn/new
|
||||
def new
|
||||
@webauthn_credential = WebauthnCredential.new
|
||||
@@ -11,7 +16,7 @@ class WebauthnController < ApplicationController
|
||||
# Generate registration challenge for creating a new passkey
|
||||
def challenge
|
||||
user = Current.session&.user
|
||||
return render json: { error: "Not authenticated" }, status: :unauthorized unless user
|
||||
return render json: {error: "Not authenticated"}, status: :unauthorized unless user
|
||||
|
||||
registration_options = WebAuthn::Credential.options_for_create(
|
||||
user: {
|
||||
@@ -39,7 +44,7 @@ class WebauthnController < ApplicationController
|
||||
credential_data, nickname = extract_credential_params
|
||||
|
||||
if credential_data.blank? || nickname.blank?
|
||||
render json: { error: "Credential and nickname are required" }, status: :unprocessable_entity
|
||||
render json: {error: "Credential and nickname are required"}, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
@@ -47,7 +52,7 @@ class WebauthnController < ApplicationController
|
||||
challenge = session.delete(:webauthn_challenge)
|
||||
|
||||
if challenge.blank?
|
||||
render json: { error: "Invalid or expired session" }, status: :unprocessable_entity
|
||||
render json: {error: "Invalid or expired session"}, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
@@ -63,10 +68,10 @@ class WebauthnController < ApplicationController
|
||||
client_extension_results = response["clientExtensionResults"] || {}
|
||||
|
||||
authenticator_type = if response["response"]["authenticatorAttachment"] == "cross-platform"
|
||||
"cross-platform"
|
||||
else
|
||||
"platform"
|
||||
end
|
||||
"cross-platform"
|
||||
else
|
||||
"platform"
|
||||
end
|
||||
|
||||
# Determine if this is a backup/synced credential
|
||||
backup_eligible = client_extension_results["credProps"]&.dig("rk") || false
|
||||
@@ -74,7 +79,7 @@ class WebauthnController < ApplicationController
|
||||
|
||||
# Store the credential
|
||||
user = Current.session&.user
|
||||
return render json: { error: "Not authenticated" }, status: :unauthorized unless user
|
||||
return render json: {error: "Not authenticated"}, status: :unauthorized unless user
|
||||
|
||||
@webauthn_credential = user.webauthn_credentials.create!(
|
||||
external_id: Base64.urlsafe_encode64(webauthn_credential.id),
|
||||
@@ -91,34 +96,25 @@ class WebauthnController < ApplicationController
|
||||
message: "Passkey '#{nickname}' registered successfully",
|
||||
credential_id: @webauthn_credential.id
|
||||
}
|
||||
|
||||
rescue WebAuthn::Error => e
|
||||
Rails.logger.error "WebAuthn registration error: #{e.message}"
|
||||
render json: { error: "Failed to register passkey: #{e.message}" }, status: :unprocessable_entity
|
||||
render json: {error: "Failed to register passkey: #{e.message}"}, status: :unprocessable_entity
|
||||
rescue => e
|
||||
Rails.logger.error "Unexpected WebAuthn registration error: #{e.class} - #{e.message}"
|
||||
render json: { error: "An unexpected error occurred" }, status: :internal_server_error
|
||||
render json: {error: "An unexpected error occurred"}, status: :internal_server_error
|
||||
end
|
||||
end
|
||||
|
||||
# DELETE /webauthn/:id
|
||||
# Remove a passkey
|
||||
def destroy
|
||||
user = Current.session&.user
|
||||
return render json: { error: "Not authenticated" }, status: :unauthorized unless user
|
||||
|
||||
if @webauthn_credential.user != user
|
||||
render json: { error: "Unauthorized" }, status: :forbidden
|
||||
return
|
||||
end
|
||||
|
||||
nickname = @webauthn_credential.nickname
|
||||
@webauthn_credential.destroy
|
||||
|
||||
respond_to do |format|
|
||||
format.html {
|
||||
redirect_to profile_path,
|
||||
notice: "Passkey '#{nickname}' has been removed"
|
||||
notice: "Passkey '#{nickname}' has been removed"
|
||||
}
|
||||
format.json {
|
||||
render json: {
|
||||
@@ -131,25 +127,27 @@ class WebauthnController < ApplicationController
|
||||
|
||||
# GET /webauthn/check
|
||||
# Check if user has WebAuthn credentials (for login page detection)
|
||||
# Security: Returns identical responses for non-existent users to prevent enumeration
|
||||
def check
|
||||
email = params[:email]&.strip&.downcase
|
||||
|
||||
if email.blank?
|
||||
render json: { has_webauthn: false, error: "Email is required" }
|
||||
render json: {has_webauthn: false, requires_webauthn: false}
|
||||
return
|
||||
end
|
||||
|
||||
user = User.find_by(email_address: email)
|
||||
|
||||
# Security: Return identical response for non-existent users
|
||||
# Combined with rate limiting (10/min), this prevents account enumeration
|
||||
if user.nil?
|
||||
render json: { has_webauthn: false, message: "User not found" }
|
||||
render json: {has_webauthn: false, requires_webauthn: false}
|
||||
return
|
||||
end
|
||||
|
||||
# Only return minimal necessary info - no user_id or preferred_method
|
||||
render json: {
|
||||
has_webauthn: user.can_authenticate_with_webauthn?,
|
||||
user_id: user.id,
|
||||
preferred_method: user.preferred_authentication_method,
|
||||
requires_webauthn: user.require_webauthn?
|
||||
}
|
||||
end
|
||||
@@ -159,40 +157,36 @@ class WebauthnController < ApplicationController
|
||||
def extract_credential_params
|
||||
# Use require.permit which is working and reliable
|
||||
# The JavaScript sends params both directly and wrapped in webauthn key
|
||||
begin
|
||||
# Try direct parameters first
|
||||
credential_params = params.require(:credential).permit(:id, :rawId, :type, response: {}, clientExtensionResults: {})
|
||||
nickname = params.require(:nickname)
|
||||
[credential_params, nickname]
|
||||
rescue ActionController::ParameterMissing
|
||||
Rails.logger.error("Using the fallback parameters")
|
||||
# Fallback to webauthn-wrapped parameters
|
||||
webauthn_params = params.require(:webauthn).permit(:nickname, credential: [:id, :rawId, :type, response: {}, clientExtensionResults: {}])
|
||||
[webauthn_params[:credential], webauthn_params[:nickname]]
|
||||
end
|
||||
|
||||
# Try direct parameters first
|
||||
credential_params = params.require(:credential).permit(:id, :rawId, :type, response: {}, clientExtensionResults: {})
|
||||
nickname = params.require(:nickname)
|
||||
[credential_params, nickname]
|
||||
rescue ActionController::ParameterMissing
|
||||
Rails.logger.error("Using the fallback parameters")
|
||||
# Fallback to webauthn-wrapped parameters
|
||||
webauthn_params = params.require(:webauthn).permit(:nickname, credential: [:id, :rawId, :type, response: {}, clientExtensionResults: {}])
|
||||
[webauthn_params[:credential], webauthn_params[:nickname]]
|
||||
end
|
||||
|
||||
def set_webauthn_credential
|
||||
@webauthn_credential = WebauthnCredential.find(params[:id])
|
||||
user = Current.session&.user
|
||||
return render json: {error: "Not authenticated"}, status: :unauthorized unless user
|
||||
@webauthn_credential = user.webauthn_credentials.find(params[:id])
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
respond_to do |format|
|
||||
format.html {
|
||||
redirect_to profile_path,
|
||||
alert: "Passkey not found"
|
||||
}
|
||||
format.json {
|
||||
render json: { error: "Passkey not found" }, status: :not_found
|
||||
}
|
||||
format.html { redirect_to profile_path, alert: "Passkey not found" }
|
||||
format.json { render json: {error: "Passkey not found"}, status: :not_found }
|
||||
end
|
||||
end
|
||||
|
||||
# Helper method to convert Base64 to Base64URL if needed
|
||||
def base64_to_base64url(str)
|
||||
str.gsub('+', '-').gsub('/', '_').gsub(/=+$/, '')
|
||||
str.tr("+", "-").tr("/", "_").gsub(/=+$/, "")
|
||||
end
|
||||
|
||||
# Helper method to convert Base64URL to Base64 if needed
|
||||
def base64url_to_base64(str)
|
||||
str.gsub('-', '+').gsub('_', '/') + '=' * (4 - str.length % 4) % 4
|
||||
str.tr("-", "+").tr("_", "/") + "=" * (4 - str.length % 4) % 4
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,10 +6,10 @@ module ApplicationHelper
|
||||
smtp_port = ENV["SMTP_PORT"]
|
||||
|
||||
smtp_address.present? &&
|
||||
smtp_port.present? &&
|
||||
smtp_address != "localhost" &&
|
||||
!smtp_address.start_with?("127.0.0.1") &&
|
||||
!smtp_address.start_with?("localhost")
|
||||
smtp_port.present? &&
|
||||
smtp_address != "localhost" &&
|
||||
!smtp_address.start_with?("127.0.0.1") &&
|
||||
!smtp_address.start_with?("localhost")
|
||||
end
|
||||
|
||||
def email_delivery_method
|
||||
@@ -22,11 +22,11 @@ module ApplicationHelper
|
||||
|
||||
def border_class_for(type)
|
||||
case type.to_s
|
||||
when 'notice' then 'border-green-200'
|
||||
when 'alert', 'error' then 'border-red-200'
|
||||
when 'warning' then 'border-yellow-200'
|
||||
when 'info' then 'border-blue-200'
|
||||
else 'border-gray-200'
|
||||
when "notice" then "border-green-200"
|
||||
when "alert", "error" then "border-red-200"
|
||||
when "warning" then "border-yellow-200"
|
||||
when "info" then "border-blue-200"
|
||||
else "border-gray-200"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -25,9 +25,7 @@ module ClaimsHelper
|
||||
claims = deep_merge_claims(claims, user.parsed_custom_claims)
|
||||
|
||||
# Merge app-specific claims (arrays are combined)
|
||||
claims = deep_merge_claims(claims, application.custom_claims_for_user(user))
|
||||
|
||||
claims
|
||||
deep_merge_claims(claims, application.custom_claims_for_user(user))
|
||||
end
|
||||
|
||||
# Get claim sources breakdown for display
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["appTypeSelect", "oidcFields", "forwardAuthFields"]
|
||||
static targets = ["appTypeSelect", "oidcFields", "forwardAuthFields", "pkceOptions"]
|
||||
|
||||
connect() {
|
||||
this.updateFieldVisibility()
|
||||
@@ -21,4 +21,17 @@ export default class extends Controller {
|
||||
this.forwardAuthFieldsTarget.classList.add('hidden')
|
||||
}
|
||||
}
|
||||
|
||||
updatePkceVisibility(event) {
|
||||
// Show PKCE options for confidential clients, hide for public clients
|
||||
const isPublicClient = event.target.value === "true"
|
||||
|
||||
if (this.hasPkceOptionsTarget) {
|
||||
if (isPublicClient) {
|
||||
this.pkceOptionsTarget.classList.add('hidden')
|
||||
} else {
|
||||
this.pkceOptionsTarget.classList.remove('hidden')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
121
app/javascript/controllers/image_paste_controller.js
Normal file
121
app/javascript/controllers/image_paste_controller.js
Normal file
@@ -0,0 +1,121 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["input", "dropzone"]
|
||||
|
||||
connect() {
|
||||
// Listen for paste events on the dropzone
|
||||
this.dropzoneTarget.addEventListener("paste", this.handlePaste.bind(this))
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.dropzoneTarget.removeEventListener("paste", this.handlePaste.bind(this))
|
||||
}
|
||||
|
||||
handlePaste(e) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
const clipboardData = e.clipboardData || e.originalEvent.clipboardData
|
||||
|
||||
// First, try to get image data
|
||||
for (let item of clipboardData.items) {
|
||||
if (item.type.indexOf("image") !== -1) {
|
||||
const blob = item.getAsFile()
|
||||
this.handleImageBlob(blob)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// If no image found, check for SVG text
|
||||
const text = clipboardData.getData("text/plain")
|
||||
if (text && this.isSVG(text)) {
|
||||
this.handleSVGText(text)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
isSVG(text) {
|
||||
// Check if the text looks like SVG code
|
||||
const trimmed = text.trim()
|
||||
return trimmed.startsWith("<svg") && trimmed.includes("</svg>")
|
||||
}
|
||||
|
||||
handleSVGText(svgText) {
|
||||
// Validate file size (2MB)
|
||||
const size = new Blob([svgText]).size
|
||||
if (size > 2 * 1024 * 1024) {
|
||||
alert("SVG code is too large (must be less than 2MB)")
|
||||
return
|
||||
}
|
||||
|
||||
// Create a blob from the SVG text
|
||||
const blob = new Blob([svgText], { type: "image/svg+xml" })
|
||||
|
||||
// Create a File object
|
||||
const file = new File([blob], `pasted-svg-${Date.now()}.svg`, {
|
||||
type: "image/svg+xml"
|
||||
})
|
||||
|
||||
// Create a DataTransfer object to set files on the input
|
||||
const dataTransfer = new DataTransfer()
|
||||
dataTransfer.items.add(file)
|
||||
this.inputTarget.files = dataTransfer.files
|
||||
|
||||
// Trigger change event to update preview (file-drop controller will handle it)
|
||||
const event = new Event("change", { bubbles: true })
|
||||
this.inputTarget.dispatchEvent(event)
|
||||
|
||||
// Visual feedback
|
||||
this.dropzoneTarget.classList.add("border-green-500", "bg-green-50")
|
||||
setTimeout(() => {
|
||||
this.dropzoneTarget.classList.remove("border-green-500", "bg-green-50")
|
||||
}, 500)
|
||||
}
|
||||
|
||||
handleImageBlob(blob) {
|
||||
// Validate file type
|
||||
const validTypes = ["image/png", "image/jpg", "image/jpeg", "image/gif", "image/svg+xml"]
|
||||
if (!validTypes.includes(blob.type)) {
|
||||
alert("Please paste a PNG, JPG, GIF, or SVG image")
|
||||
return
|
||||
}
|
||||
|
||||
// Validate file size (2MB)
|
||||
if (blob.size > 2 * 1024 * 1024) {
|
||||
alert("Image size must be less than 2MB")
|
||||
return
|
||||
}
|
||||
|
||||
// Create a File object from the blob with a default name
|
||||
const file = new File([blob], `pasted-image-${Date.now()}.${this.getExtension(blob.type)}`, {
|
||||
type: blob.type
|
||||
})
|
||||
|
||||
// Create a DataTransfer object to set files on the input
|
||||
const dataTransfer = new DataTransfer()
|
||||
dataTransfer.items.add(file)
|
||||
this.inputTarget.files = dataTransfer.files
|
||||
|
||||
// Trigger change event to update preview (file-drop controller will handle it)
|
||||
const event = new Event("change", { bubbles: true })
|
||||
this.inputTarget.dispatchEvent(event)
|
||||
|
||||
// Visual feedback
|
||||
this.dropzoneTarget.classList.add("border-green-500", "bg-green-50")
|
||||
setTimeout(() => {
|
||||
this.dropzoneTarget.classList.remove("border-green-500", "bg-green-50")
|
||||
}, 500)
|
||||
}
|
||||
|
||||
getExtension(mimeType) {
|
||||
const extensions = {
|
||||
"image/png": "png",
|
||||
"image/jpeg": "jpg",
|
||||
"image/jpg": "jpg",
|
||||
"image/gif": "gif",
|
||||
"image/svg+xml": "svg"
|
||||
}
|
||||
return extensions[mimeType] || "png"
|
||||
}
|
||||
}
|
||||
@@ -29,10 +29,10 @@ class BackchannelLogoutJob < ApplicationJob
|
||||
uri = URI.parse(application.backchannel_logout_uri)
|
||||
|
||||
begin
|
||||
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https', open_timeout: 5, read_timeout: 5) do |http|
|
||||
request = Net::HTTP::Post.new(uri.path.presence || '/')
|
||||
request['Content-Type'] = 'application/x-www-form-urlencoded'
|
||||
request.set_form_data({ logout_token: logout_token })
|
||||
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 5, read_timeout: 5) do |http|
|
||||
request = Net::HTTP::Post.new(uri.path.presence || "/")
|
||||
request["Content-Type"] = "application/x-www-form-urlencoded"
|
||||
request.set_form_data({logout_token: logout_token})
|
||||
http.request(request)
|
||||
end
|
||||
|
||||
@@ -44,7 +44,7 @@ class BackchannelLogoutJob < ApplicationJob
|
||||
rescue Net::OpenTimeout, Net::ReadTimeout => e
|
||||
Rails.logger.warn "BackchannelLogout: Timeout sending logout to #{application.name} (#{application.backchannel_logout_uri}): #{e.message}"
|
||||
raise # Retry on timeout
|
||||
rescue StandardError => e
|
||||
rescue => e
|
||||
Rails.logger.error "BackchannelLogout: Failed to send logout to #{application.name} (#{application.backchannel_logout_uri}): #{e.class} - #{e.message}"
|
||||
raise # Retry on error
|
||||
end
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
class ApplicationMailer < ActionMailer::Base
|
||||
default from: ENV.fetch('CLINCH_FROM_EMAIL', 'clinch@example.com')
|
||||
default from: ENV.fetch("CLINCH_FROM_EMAIL", "clinch@example.com")
|
||||
layout "mailer"
|
||||
end
|
||||
|
||||
@@ -3,4 +3,4 @@ class InvitationsMailer < ApplicationMailer
|
||||
@user = user
|
||||
mail subject: "You're invited to join Clinch", to: user.email_address
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
class Application < ApplicationRecord
|
||||
has_secure_password :client_secret, validations: false
|
||||
|
||||
# Virtual attribute to control client type during creation
|
||||
# When true, no client_secret will be generated (public client)
|
||||
attr_accessor :is_public_client
|
||||
|
||||
has_one_attached :icon
|
||||
|
||||
# Fix SVG content type after attachment
|
||||
@@ -15,16 +19,16 @@ class Application < ApplicationRecord
|
||||
has_many :oidc_user_consents, dependent: :destroy
|
||||
|
||||
validates :name, presence: true
|
||||
validates :slug, presence: true, uniqueness: { case_sensitive: false },
|
||||
format: { with: /\A[a-z0-9\-]+\z/, message: "only lowercase letters, numbers, and hyphens" }
|
||||
validates :slug, presence: true, uniqueness: {case_sensitive: false},
|
||||
format: {with: /\A[a-z0-9-]+\z/, message: "only lowercase letters, numbers, and hyphens"}
|
||||
validates :app_type, presence: true,
|
||||
inclusion: { in: %w[oidc forward_auth] }
|
||||
validates :client_id, uniqueness: { allow_nil: true }
|
||||
validates :client_secret, presence: true, on: :create, if: -> { oidc? }
|
||||
validates :domain_pattern, presence: true, uniqueness: { case_sensitive: false }, if: :forward_auth?
|
||||
validates :landing_url, format: { with: URI::regexp(%w[http https]), allow_nil: true, message: "must be a valid URL" }
|
||||
inclusion: {in: %w[oidc forward_auth]}
|
||||
validates :client_id, uniqueness: {allow_nil: true}
|
||||
validates :client_secret, presence: true, on: :create, if: -> { oidc? && confidential_client? }
|
||||
validates :domain_pattern, presence: true, uniqueness: {case_sensitive: false}, if: :forward_auth?
|
||||
validates :landing_url, format: {with: URI::RFC2396_PARSER.make_regexp(%w[http https]), allow_nil: true, message: "must be a valid URL"}
|
||||
validates :backchannel_logout_uri, format: {
|
||||
with: URI::regexp(%w[http https]),
|
||||
with: URI::RFC2396_PARSER.make_regexp(%w[http https]),
|
||||
allow_nil: true,
|
||||
message: "must be a valid HTTP or HTTPS URL"
|
||||
}
|
||||
@@ -34,9 +38,9 @@ class Application < ApplicationRecord
|
||||
validate :icon_validation, if: -> { icon.attached? }
|
||||
|
||||
# Token TTL validations (for OIDC apps)
|
||||
validates :access_token_ttl, numericality: { greater_than_or_equal_to: 300, less_than_or_equal_to: 86400 }, if: :oidc? # 5 min - 24 hours
|
||||
validates :refresh_token_ttl, numericality: { greater_than_or_equal_to: 86400, less_than_or_equal_to: 7776000 }, if: :oidc? # 1 day - 90 days
|
||||
validates :id_token_ttl, numericality: { greater_than_or_equal_to: 300, less_than_or_equal_to: 86400 }, if: :oidc? # 5 min - 24 hours
|
||||
validates :access_token_ttl, numericality: {greater_than_or_equal_to: 300, less_than_or_equal_to: 86400}, if: :oidc? # 5 min - 24 hours
|
||||
validates :refresh_token_ttl, numericality: {greater_than_or_equal_to: 86400, less_than_or_equal_to: 7776000}, if: :oidc? # 1 day - 90 days
|
||||
validates :id_token_ttl, numericality: {greater_than_or_equal_to: 300, less_than_or_equal_to: 86400}, if: :oidc? # 5 min - 24 hours
|
||||
|
||||
normalizes :slug, with: ->(slug) { slug.strip.downcase }
|
||||
normalizes :domain_pattern, with: ->(pattern) {
|
||||
@@ -52,11 +56,11 @@ class Application < ApplicationRecord
|
||||
|
||||
# Default header configuration for ForwardAuth
|
||||
DEFAULT_HEADERS = {
|
||||
user: 'X-Remote-User',
|
||||
email: 'X-Remote-Email',
|
||||
name: 'X-Remote-Name',
|
||||
groups: 'X-Remote-Groups',
|
||||
admin: 'X-Remote-Admin'
|
||||
user: "X-Remote-User",
|
||||
email: "X-Remote-Email",
|
||||
name: "X-Remote-Name",
|
||||
groups: "X-Remote-Groups",
|
||||
admin: "X-Remote-Admin"
|
||||
}.freeze
|
||||
|
||||
# Scopes
|
||||
@@ -74,6 +78,24 @@ class Application < ApplicationRecord
|
||||
app_type == "forward_auth"
|
||||
end
|
||||
|
||||
# Client type checks (for OIDC)
|
||||
def public_client?
|
||||
client_secret_digest.blank?
|
||||
end
|
||||
|
||||
def confidential_client?
|
||||
!public_client?
|
||||
end
|
||||
|
||||
# PKCE requirement check
|
||||
# Public clients MUST use PKCE (no client secret to protect auth code)
|
||||
# Confidential clients can optionally require PKCE (OAuth 2.1 recommendation)
|
||||
def requires_pkce?
|
||||
return false unless oidc?
|
||||
return true if public_client? # Always require PKCE for public clients
|
||||
require_pkce? # Check the flag for confidential clients
|
||||
end
|
||||
|
||||
# Access control
|
||||
def user_allowed?(user)
|
||||
return false unless active?
|
||||
@@ -113,8 +135,8 @@ class Application < ApplicationRecord
|
||||
def matches_domain?(domain)
|
||||
return false if domain.blank? || !forward_auth?
|
||||
|
||||
pattern = domain_pattern.gsub('.', '\.')
|
||||
pattern = pattern.gsub('*', '[^.]*')
|
||||
pattern = domain_pattern.gsub(".", '\.')
|
||||
pattern = pattern.gsub("*", "[^.]*")
|
||||
|
||||
regex = Regexp.new("^#{pattern}$", Regexp::IGNORECASE)
|
||||
regex.match?(domain.downcase)
|
||||
@@ -122,18 +144,18 @@ class Application < ApplicationRecord
|
||||
|
||||
# Policy determination based on user status (for ForwardAuth)
|
||||
def policy_for_user(user)
|
||||
return 'deny' unless active?
|
||||
return 'deny' unless user.active?
|
||||
return "deny" unless active?
|
||||
return "deny" unless user.active?
|
||||
|
||||
# If no groups specified, bypass authentication
|
||||
return 'bypass' if allowed_groups.empty?
|
||||
return "bypass" if allowed_groups.empty?
|
||||
|
||||
# If user is in allowed groups, determine auth level
|
||||
if user_allowed?(user)
|
||||
# Require 2FA if user has TOTP configured, otherwise one factor
|
||||
user.totp_enabled? ? 'two_factor' : 'one_factor'
|
||||
user.totp_enabled? ? "two_factor" : "one_factor"
|
||||
else
|
||||
'deny'
|
||||
"deny"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -175,7 +197,7 @@ class Application < ApplicationRecord
|
||||
def generate_new_client_secret!
|
||||
secret = SecureRandom.urlsafe_base64(48)
|
||||
self.client_secret = secret
|
||||
self.save!
|
||||
save!
|
||||
secret
|
||||
end
|
||||
|
||||
@@ -220,7 +242,7 @@ class Application < ApplicationRecord
|
||||
# (i.e., has valid, non-revoked tokens)
|
||||
def user_has_active_session?(user)
|
||||
oidc_access_tokens.where(user: user).valid.exists? ||
|
||||
oidc_refresh_tokens.where(user: user).valid.exists?
|
||||
oidc_refresh_tokens.where(user: user).valid.exists?
|
||||
end
|
||||
|
||||
private
|
||||
@@ -238,14 +260,14 @@ class Application < ApplicationRecord
|
||||
return unless icon.attached?
|
||||
|
||||
# Check content type
|
||||
allowed_types = ['image/png', 'image/jpg', 'image/jpeg', 'image/gif', 'image/svg+xml']
|
||||
allowed_types = ["image/png", "image/jpg", "image/jpeg", "image/gif", "image/svg+xml"]
|
||||
unless allowed_types.include?(icon.content_type)
|
||||
errors.add(:icon, 'must be a PNG, JPG, GIF, or SVG image')
|
||||
errors.add(:icon, "must be a PNG, JPG, GIF, or SVG image")
|
||||
end
|
||||
|
||||
# Check file size (2MB limit)
|
||||
if icon.blob.byte_size > 2.megabytes
|
||||
errors.add(:icon, 'must be less than 2MB')
|
||||
errors.add(:icon, "must be less than 2MB")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -261,21 +283,27 @@ class Application < ApplicationRecord
|
||||
|
||||
def generate_client_credentials
|
||||
self.client_id ||= SecureRandom.urlsafe_base64(32)
|
||||
# Generate and hash the client secret
|
||||
if new_record? && client_secret.blank?
|
||||
# Generate client secret only for confidential clients
|
||||
# Public clients (is_public_client checked) don't get a secret - they use PKCE only
|
||||
if new_record? && client_secret.blank? && !is_public_client_selected?
|
||||
secret = SecureRandom.urlsafe_base64(48)
|
||||
self.client_secret = secret
|
||||
end
|
||||
end
|
||||
|
||||
# Check if the user selected public client option
|
||||
def is_public_client_selected?
|
||||
ActiveModel::Type::Boolean.new.cast(is_public_client)
|
||||
end
|
||||
|
||||
def backchannel_logout_uri_must_be_https_in_production
|
||||
return unless Rails.env.production?
|
||||
return unless backchannel_logout_uri.present?
|
||||
|
||||
begin
|
||||
uri = URI.parse(backchannel_logout_uri)
|
||||
unless uri.scheme == 'https'
|
||||
errors.add(:backchannel_logout_uri, 'must use HTTPS in production')
|
||||
unless uri.scheme == "https"
|
||||
errors.add(:backchannel_logout_uri, "must use HTTPS in production")
|
||||
end
|
||||
rescue URI::InvalidURIError
|
||||
# Let the format validator handle invalid URIs
|
||||
|
||||
@@ -2,5 +2,5 @@ class ApplicationGroup < ApplicationRecord
|
||||
belongs_to :application
|
||||
belongs_to :group
|
||||
|
||||
validates :application_id, uniqueness: { scope: :group_id }
|
||||
validates :application_id, uniqueness: {scope: :group_id}
|
||||
end
|
||||
|
||||
@@ -9,7 +9,7 @@ class ApplicationUserClaim < ApplicationRecord
|
||||
groups
|
||||
].freeze
|
||||
|
||||
validates :user_id, uniqueness: { scope: :application_id }
|
||||
validates :user_id, uniqueness: {scope: :application_id}
|
||||
validate :no_reserved_claim_names
|
||||
|
||||
# Parse custom_claims JSON field
|
||||
@@ -25,7 +25,7 @@ class ApplicationUserClaim < ApplicationRecord
|
||||
|
||||
reserved_used = parsed_custom_claims.keys.map(&:to_s) & RESERVED_CLAIMS
|
||||
if reserved_used.any?
|
||||
errors.add(:custom_claims, "cannot override reserved OIDC claims: #{reserved_used.join(', ')}")
|
||||
errors.add(:custom_claims, "cannot override reserved OIDC claims: #{reserved_used.join(", ")}")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -11,7 +11,7 @@ class Group < ApplicationRecord
|
||||
groups
|
||||
].freeze
|
||||
|
||||
validates :name, presence: true, uniqueness: { case_sensitive: false }
|
||||
validates :name, presence: true, uniqueness: {case_sensitive: false}
|
||||
normalizes :name, with: ->(name) { name.strip.downcase }
|
||||
validate :no_reserved_claim_names
|
||||
|
||||
@@ -28,7 +28,7 @@ class Group < ApplicationRecord
|
||||
|
||||
reserved_used = parsed_custom_claims.keys.map(&:to_s) & RESERVED_CLAIMS
|
||||
if reserved_used.any?
|
||||
errors.add(:custom_claims, "cannot override reserved OIDC claims: #{reserved_used.join(', ')}")
|
||||
errors.add(:custom_claims, "cannot override reserved OIDC claims: #{reserved_used.join(", ")}")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,7 +6,7 @@ class OidcAccessToken < ApplicationRecord
|
||||
before_validation :generate_token, on: :create
|
||||
before_validation :set_expiry, on: :create
|
||||
|
||||
validates :token, uniqueness: true, presence: true
|
||||
validates :token_hmac, presence: true, uniqueness: true
|
||||
|
||||
scope :valid, -> { where("expires_at > ?", Time.current).where(revoked_at: nil) }
|
||||
scope :expired, -> { where("expires_at <= ?", Time.current) }
|
||||
@@ -15,6 +15,19 @@ class OidcAccessToken < ApplicationRecord
|
||||
|
||||
attr_accessor :plaintext_token # Store plaintext temporarily for returning to client
|
||||
|
||||
# Find access token by plaintext token using HMAC verification
|
||||
def self.find_by_token(plaintext_token)
|
||||
return nil if plaintext_token.blank?
|
||||
|
||||
token_hmac = compute_token_hmac(plaintext_token)
|
||||
find_by(token_hmac: token_hmac)
|
||||
end
|
||||
|
||||
# Compute HMAC for token lookup
|
||||
def self.compute_token_hmac(plaintext_token)
|
||||
OpenSSL::HMAC.hexdigest("SHA256", TokenHmac::KEY, plaintext_token)
|
||||
end
|
||||
|
||||
def expired?
|
||||
expires_at <= Time.current
|
||||
end
|
||||
@@ -33,48 +46,13 @@ class OidcAccessToken < ApplicationRecord
|
||||
oidc_refresh_tokens.each(&:revoke!)
|
||||
end
|
||||
|
||||
# Check if a plaintext token matches the hashed token
|
||||
def token_matches?(plaintext_token)
|
||||
return false if plaintext_token.blank?
|
||||
|
||||
# Use BCrypt to compare if token_digest exists
|
||||
if token_digest.present?
|
||||
BCrypt::Password.new(token_digest) == plaintext_token
|
||||
# Fall back to direct comparison for backward compatibility
|
||||
elsif token.present?
|
||||
token == plaintext_token
|
||||
else
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
# Find by token (validates and checks if revoked)
|
||||
def self.find_by_token(plaintext_token)
|
||||
return nil if plaintext_token.blank?
|
||||
|
||||
# Find all non-revoked, non-expired tokens
|
||||
valid.find_each do |access_token|
|
||||
# Use BCrypt to compare (if token_digest exists) or direct comparison
|
||||
if access_token.token_digest.present?
|
||||
return access_token if BCrypt::Password.new(access_token.token_digest) == plaintext_token
|
||||
elsif access_token.token == plaintext_token
|
||||
return access_token
|
||||
end
|
||||
end
|
||||
nil
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_token
|
||||
return if token.present?
|
||||
|
||||
# Generate opaque access token
|
||||
plaintext = SecureRandom.urlsafe_base64(48)
|
||||
self.plaintext_token = plaintext # Store temporarily for returning to client
|
||||
self.token_digest = BCrypt::Password.create(plaintext)
|
||||
# Keep token column for backward compatibility during migration
|
||||
self.token = plaintext
|
||||
# Generate random plaintext token
|
||||
self.plaintext_token ||= SecureRandom.urlsafe_base64(48)
|
||||
# Store HMAC in database (not plaintext)
|
||||
self.token_hmac ||= self.class.compute_token_hmac(plaintext_token)
|
||||
end
|
||||
|
||||
def set_expiry
|
||||
|
||||
@@ -2,17 +2,32 @@ class OidcAuthorizationCode < ApplicationRecord
|
||||
belongs_to :application
|
||||
belongs_to :user
|
||||
|
||||
attr_accessor :plaintext_code
|
||||
|
||||
before_validation :generate_code, on: :create
|
||||
before_validation :set_expiry, on: :create
|
||||
|
||||
validates :code, presence: true, uniqueness: true
|
||||
validates :code_hmac, presence: true, uniqueness: true
|
||||
validates :redirect_uri, presence: true
|
||||
validates :code_challenge_method, inclusion: { in: %w[plain S256], allow_nil: true }
|
||||
validates :code_challenge_method, inclusion: {in: %w[plain S256], allow_nil: true}
|
||||
validate :validate_code_challenge_format, if: -> { code_challenge.present? }
|
||||
|
||||
scope :valid, -> { where(used: false).where("expires_at > ?", Time.current) }
|
||||
scope :expired, -> { where("expires_at <= ?", Time.current) }
|
||||
|
||||
# Find authorization code by plaintext code using HMAC verification
|
||||
def self.find_by_plaintext(plaintext_code)
|
||||
return nil if plaintext_code.blank?
|
||||
|
||||
code_hmac = compute_code_hmac(plaintext_code)
|
||||
find_by(code_hmac: code_hmac)
|
||||
end
|
||||
|
||||
# Compute HMAC for code lookup
|
||||
def self.compute_code_hmac(plaintext_code)
|
||||
OpenSSL::HMAC.hexdigest("SHA256", TokenHmac::KEY, plaintext_code)
|
||||
end
|
||||
|
||||
def expired?
|
||||
expires_at <= Time.current
|
||||
end
|
||||
@@ -32,7 +47,10 @@ class OidcAuthorizationCode < ApplicationRecord
|
||||
private
|
||||
|
||||
def generate_code
|
||||
self.code ||= SecureRandom.urlsafe_base64(32)
|
||||
# Generate random plaintext code
|
||||
self.plaintext_code ||= SecureRandom.urlsafe_base64(32)
|
||||
# Store HMAC in database (not plaintext)
|
||||
self.code_hmac ||= self.class.compute_code_hmac(plaintext_code)
|
||||
end
|
||||
|
||||
def set_expiry
|
||||
|
||||
@@ -2,13 +2,12 @@ class OidcRefreshToken < ApplicationRecord
|
||||
belongs_to :application
|
||||
belongs_to :user
|
||||
belongs_to :oidc_access_token
|
||||
has_many :oidc_access_tokens, foreign_key: :oidc_access_token_id, dependent: :nullify
|
||||
|
||||
before_validation :generate_token, on: :create
|
||||
before_validation :set_expiry, on: :create
|
||||
before_validation :set_token_family_id, on: :create
|
||||
|
||||
validates :token_digest, presence: true, uniqueness: true
|
||||
validates :token_hmac, presence: true, uniqueness: true
|
||||
|
||||
scope :valid, -> { where("expires_at > ?", Time.current).where(revoked_at: nil) }
|
||||
scope :expired, -> { where("expires_at <= ?", Time.current) }
|
||||
@@ -20,6 +19,19 @@ class OidcRefreshToken < ApplicationRecord
|
||||
|
||||
attr_accessor :token # Store plaintext token temporarily for returning to client
|
||||
|
||||
# Find refresh token by plaintext token using HMAC verification
|
||||
def self.find_by_token(plaintext_token)
|
||||
return nil if plaintext_token.blank?
|
||||
|
||||
token_hmac = compute_token_hmac(plaintext_token)
|
||||
find_by(token_hmac: token_hmac)
|
||||
end
|
||||
|
||||
# Compute HMAC for token lookup
|
||||
def self.compute_token_hmac(plaintext_token)
|
||||
OpenSSL::HMAC.hexdigest("SHA256", TokenHmac::KEY, plaintext_token)
|
||||
end
|
||||
|
||||
def expired?
|
||||
expires_at <= Time.current
|
||||
end
|
||||
@@ -43,35 +55,13 @@ class OidcRefreshToken < ApplicationRecord
|
||||
OidcRefreshToken.in_family(token_family_id).update_all(revoked_at: Time.current)
|
||||
end
|
||||
|
||||
# Verify a plaintext token against the stored digest
|
||||
def self.find_by_token(plaintext_token)
|
||||
return nil if plaintext_token.blank?
|
||||
|
||||
# Try to find tokens that could match (we can't search by hash directly)
|
||||
# This is less efficient but necessary with BCrypt
|
||||
# In production, you might want to add a token prefix or other optimization
|
||||
all.find do |refresh_token|
|
||||
refresh_token.token_matches?(plaintext_token)
|
||||
end
|
||||
end
|
||||
|
||||
def token_matches?(plaintext_token)
|
||||
return false if plaintext_token.blank? || token_digest.blank?
|
||||
|
||||
BCrypt::Password.new(token_digest) == plaintext_token
|
||||
rescue BCrypt::Errors::InvalidHash
|
||||
false
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_token
|
||||
# Generate a secure random token
|
||||
plaintext = SecureRandom.urlsafe_base64(48)
|
||||
self.token = plaintext # Store temporarily for returning to client
|
||||
|
||||
# Hash it with BCrypt for storage
|
||||
self.token_digest = BCrypt::Password.create(plaintext)
|
||||
# Generate random plaintext token
|
||||
self.token ||= SecureRandom.urlsafe_base64(48)
|
||||
# Store HMAC in database (not plaintext)
|
||||
self.token_hmac ||= self.class.compute_token_hmac(token)
|
||||
end
|
||||
|
||||
def set_expiry
|
||||
|
||||
@@ -3,19 +3,19 @@ class OidcUserConsent < ApplicationRecord
|
||||
belongs_to :application
|
||||
|
||||
validates :user, :application, :scopes_granted, :granted_at, presence: true
|
||||
validates :user_id, uniqueness: { scope: :application_id }
|
||||
validates :user_id, uniqueness: {scope: :application_id}
|
||||
|
||||
before_validation :set_granted_at, on: :create
|
||||
before_validation :set_sid, on: :create
|
||||
|
||||
# Parse scopes_granted into an array
|
||||
def scopes
|
||||
scopes_granted.split(' ')
|
||||
scopes_granted.split(" ")
|
||||
end
|
||||
|
||||
# Set scopes from an array
|
||||
def scopes=(scope_array)
|
||||
self.scopes_granted = Array(scope_array).uniq.join(' ')
|
||||
self.scopes_granted = Array(scope_array).uniq.join(" ")
|
||||
end
|
||||
|
||||
# Check if this consent covers the requested scopes
|
||||
@@ -31,18 +31,18 @@ class OidcUserConsent < ApplicationRecord
|
||||
def formatted_scopes
|
||||
scopes.map do |scope|
|
||||
case scope
|
||||
when 'openid'
|
||||
'Basic authentication'
|
||||
when 'profile'
|
||||
'Profile information'
|
||||
when 'email'
|
||||
'Email address'
|
||||
when 'groups'
|
||||
'Group membership'
|
||||
when "openid"
|
||||
"Basic authentication"
|
||||
when "profile"
|
||||
"Profile information"
|
||||
when "email"
|
||||
"Email address"
|
||||
when "groups"
|
||||
"Group membership"
|
||||
else
|
||||
scope.humanize
|
||||
end
|
||||
end.join(', ')
|
||||
end.join(", ")
|
||||
end
|
||||
|
||||
# Find consent by SID
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
class User < ApplicationRecord
|
||||
# Encrypt TOTP secrets at rest (key derived from SECRET_KEY_BASE)
|
||||
encrypts :totp_secret
|
||||
|
||||
has_secure_password
|
||||
has_many :sessions, dependent: :destroy
|
||||
has_many :user_groups, dependent: :destroy
|
||||
@@ -16,10 +19,6 @@ class User < ApplicationRecord
|
||||
updated_at
|
||||
end
|
||||
|
||||
generates_token_for :magic_login, expires_in: 15.minutes do
|
||||
last_sign_in_at
|
||||
end
|
||||
|
||||
normalizes :email_address, with: ->(e) { e.strip.downcase }
|
||||
normalizes :username, with: ->(u) { u.strip.downcase if u.present? }
|
||||
|
||||
@@ -30,16 +29,16 @@ class User < ApplicationRecord
|
||||
groups
|
||||
].freeze
|
||||
|
||||
validates :email_address, presence: true, uniqueness: { case_sensitive: false },
|
||||
format: { with: URI::MailTo::EMAIL_REGEXP }
|
||||
validates :username, uniqueness: { case_sensitive: false }, allow_nil: true,
|
||||
format: { with: /\A[a-zA-Z0-9_-]+\z/, message: "can only contain letters, numbers, underscores, and hyphens" },
|
||||
length: { minimum: 2, maximum: 30 }
|
||||
validates :password, length: { minimum: 8 }, allow_nil: true
|
||||
validates :email_address, presence: true, uniqueness: {case_sensitive: false},
|
||||
format: {with: URI::MailTo::EMAIL_REGEXP}
|
||||
validates :username, uniqueness: {case_sensitive: false}, allow_nil: true,
|
||||
format: {with: /\A[a-zA-Z0-9_-]+\z/, message: "can only contain letters, numbers, underscores, and hyphens"},
|
||||
length: {minimum: 2, maximum: 30}
|
||||
validates :password, length: {minimum: 8}, allow_nil: true
|
||||
validate :no_reserved_claim_names
|
||||
|
||||
# Enum - automatically creates scopes (User.active, User.disabled, etc.)
|
||||
enum :status, { active: 0, disabled: 1, pending_invitation: 2 }
|
||||
enum :status, {active: 0, disabled: 1, pending_invitation: 2}
|
||||
|
||||
# Scopes
|
||||
scope :admins, -> { where(admin: true) }
|
||||
@@ -78,6 +77,14 @@ class User < ApplicationRecord
|
||||
totp.verify(code, drift_behind: 30, drift_ahead: 30)
|
||||
end
|
||||
|
||||
# Console/debug helper: get current TOTP code
|
||||
def console_totp
|
||||
return nil unless totp_enabled?
|
||||
|
||||
require "rotp"
|
||||
ROTP::TOTP.new(totp_secret).now
|
||||
end
|
||||
|
||||
def verify_backup_code(code)
|
||||
return false unless backup_codes.present?
|
||||
|
||||
@@ -115,12 +122,7 @@ class User < ApplicationRecord
|
||||
cache_key = "backup_code_failed_attempts_#{id}"
|
||||
attempts = Rails.cache.read(cache_key) || 0
|
||||
|
||||
if attempts >= 5 # Allow max 5 failed attempts per hour
|
||||
true
|
||||
else
|
||||
# Don't increment here - increment only on failed attempts
|
||||
false
|
||||
end
|
||||
attempts >= 5
|
||||
end
|
||||
|
||||
# Increment failed attempt counter
|
||||
@@ -224,7 +226,7 @@ class User < ApplicationRecord
|
||||
|
||||
reserved_used = parsed_custom_claims.keys.map(&:to_s) & RESERVED_CLAIMS
|
||||
if reserved_used.any?
|
||||
errors.add(:custom_claims, "cannot override reserved OIDC claims: #{reserved_used.join(', ')}")
|
||||
errors.add(:custom_claims, "cannot override reserved OIDC claims: #{reserved_used.join(", ")}")
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@ class UserGroup < ApplicationRecord
|
||||
belongs_to :user
|
||||
belongs_to :group
|
||||
|
||||
validates :user_id, uniqueness: { scope: :group_id }
|
||||
validates :user_id, uniqueness: {scope: :group_id}
|
||||
end
|
||||
|
||||
@@ -4,9 +4,9 @@ class WebauthnCredential < ApplicationRecord
|
||||
# Validations
|
||||
validates :external_id, presence: true, uniqueness: true
|
||||
validates :public_key, presence: true
|
||||
validates :sign_count, presence: true, numericality: { greater_than_or_equal_to: 0, only_integer: true }
|
||||
validates :sign_count, presence: true, numericality: {greater_than_or_equal_to: 0, only_integer: true}
|
||||
validates :nickname, presence: true
|
||||
validates :authenticator_type, inclusion: { in: %w[platform cross-platform] }
|
||||
validates :authenticator_type, inclusion: {in: %w[platform cross-platform]}
|
||||
|
||||
# Scopes for querying
|
||||
scope :active, -> { where(nil) } # All credentials are active (we can add revoked_at later if needed)
|
||||
@@ -84,13 +84,13 @@ class WebauthnCredential < ApplicationRecord
|
||||
days = hours / 24
|
||||
|
||||
if days > 0
|
||||
"#{days.floor} day#{'s' if days > 1} ago"
|
||||
"#{days.floor} day#{"s" if days > 1} ago"
|
||||
elsif hours > 0
|
||||
"#{hours.floor} hour#{'s' if hours > 1} ago"
|
||||
"#{hours.floor} hour#{"s" if hours > 1} ago"
|
||||
elsif minutes > 0
|
||||
"#{minutes.floor} minute#{'s' if minutes > 1} ago"
|
||||
"#{minutes.floor} minute#{"s" if minutes > 1} ago"
|
||||
else
|
||||
"Just now"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -13,20 +13,20 @@ module ClaimsMerger
|
||||
result = base.dup
|
||||
|
||||
incoming.each do |key, value|
|
||||
if result.key?(key)
|
||||
result[key] = if result.key?(key)
|
||||
# If both values are arrays, combine them (union to avoid duplicates)
|
||||
if result[key].is_a?(Array) && value.is_a?(Array)
|
||||
result[key] = (result[key] + value).uniq
|
||||
(result[key] + value).uniq
|
||||
# If both values are hashes, recursively merge them
|
||||
elsif result[key].is_a?(Hash) && value.is_a?(Hash)
|
||||
result[key] = deep_merge_claims(result[key], value)
|
||||
deep_merge_claims(result[key], value)
|
||||
else
|
||||
# Otherwise, incoming value wins (override)
|
||||
result[key] = value
|
||||
value
|
||||
end
|
||||
else
|
||||
# New key, just add it
|
||||
result[key] = value
|
||||
value
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ class OidcJwtService
|
||||
|
||||
class << self
|
||||
# Generate an ID token (JWT) for the user
|
||||
def generate_id_token(user, application, consent: nil, nonce: nil)
|
||||
def generate_id_token(user, application, consent: nil, nonce: nil, access_token: nil, auth_time: nil, acr: nil)
|
||||
now = Time.current.to_i
|
||||
# Use application's configured ID token TTL (defaults to 1 hour)
|
||||
ttl = application.id_token_expiry_seconds
|
||||
@@ -26,6 +26,24 @@ class OidcJwtService
|
||||
# Add nonce if provided (OIDC requires this for implicit flow)
|
||||
payload[:nonce] = nonce if nonce.present?
|
||||
|
||||
# Add auth_time if provided (OIDC Core §2 - required when max_age is used)
|
||||
payload[:auth_time] = auth_time if auth_time.present?
|
||||
|
||||
# Add acr if provided (OIDC Core §2 - authentication context class reference)
|
||||
payload[:acr] = acr if acr.present?
|
||||
|
||||
# Add azp (authorized party) - the client_id this token was issued to
|
||||
# OIDC Core §2 - required when aud has multiple values, optional but useful for single
|
||||
payload[:azp] = application.client_id
|
||||
|
||||
# Add at_hash if access token is provided (OIDC Core spec §3.1.3.6)
|
||||
# at_hash = left-most 128 bits of SHA-256 hash of access token, base64url encoded
|
||||
if access_token.present?
|
||||
sha256 = Digest::SHA256.digest(access_token)
|
||||
at_hash = Base64.urlsafe_encode64(sha256[0..15], padding: false)
|
||||
payload[:at_hash] = at_hash
|
||||
end
|
||||
|
||||
# Add groups if user has any
|
||||
if user.groups.any?
|
||||
payload[:groups] = user.groups.pluck(:name)
|
||||
@@ -42,7 +60,7 @@ class OidcJwtService
|
||||
# Merge app-specific custom claims (highest priority, arrays are combined)
|
||||
payload = deep_merge_claims(payload, application.custom_claims_for_user(user))
|
||||
|
||||
JWT.encode(payload, private_key, "RS256", { kid: key_id, typ: "JWT" })
|
||||
JWT.encode(payload, private_key, "RS256", {kid: key_id, typ: "JWT"})
|
||||
end
|
||||
|
||||
# Generate a backchannel logout token (JWT)
|
||||
@@ -66,12 +84,12 @@ class OidcJwtService
|
||||
}
|
||||
|
||||
# Important: Do NOT include nonce in logout tokens (spec requirement)
|
||||
JWT.encode(payload, private_key, "RS256", { kid: key_id, typ: "JWT" })
|
||||
JWT.encode(payload, private_key, "RS256", {kid: key_id, typ: "JWT"})
|
||||
end
|
||||
|
||||
# Decode and verify an ID token
|
||||
def decode_id_token(token)
|
||||
JWT.decode(token, public_key, true, { algorithm: "RS256" })
|
||||
JWT.decode(token, public_key, true, {algorithm: "RS256"})
|
||||
end
|
||||
|
||||
# Get the public key in JWK format for the JWKS endpoint
|
||||
|
||||
@@ -120,6 +120,51 @@
|
||||
<div id="oidc-fields" class="space-y-6 border-t border-gray-200 pt-6 <%= 'hidden' unless application.oidc? || !application.persisted? %>" data-application-form-target="oidcFields">
|
||||
<h3 class="text-base font-semibold text-gray-900">OIDC Configuration</h3>
|
||||
|
||||
<!-- Client Type Selection (only for new applications) -->
|
||||
<% unless application.persisted? %>
|
||||
<div class="border border-gray-200 rounded-lg p-4 bg-gray-50">
|
||||
<h4 class="text-sm font-semibold text-gray-900 mb-3">Client Type</h4>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-start">
|
||||
<%= form.radio_button :is_public_client, "false", checked: !application.is_public_client, class: "mt-1 h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-500", data: { action: "change->application-form#updatePkceVisibility" } %>
|
||||
<div class="ml-3">
|
||||
<label for="application_is_public_client_false" class="block text-sm font-medium text-gray-900">Confidential Client (Recommended)</label>
|
||||
<p class="text-sm text-gray-500">Backend server app that can securely store a client secret. Examples: traditional web apps, server-to-server APIs.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start">
|
||||
<%= form.radio_button :is_public_client, "true", checked: application.is_public_client, class: "mt-1 h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-500", data: { action: "change->application-form#updatePkceVisibility" } %>
|
||||
<div class="ml-3">
|
||||
<label for="application_is_public_client_true" class="block text-sm font-medium text-gray-900">Public Client</label>
|
||||
<p class="text-sm text-gray-500">Frontend-only app that cannot store secrets securely. Examples: SPAs (React/Vue), mobile apps, CLI tools. <strong class="text-amber-600">PKCE is required.</strong></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<!-- Show client type for existing applications (read-only) -->
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="font-medium text-gray-700">Client Type:</span>
|
||||
<% if application.public_client? %>
|
||||
<span class="inline-flex items-center rounded-md bg-amber-50 px-2 py-1 text-xs font-medium text-amber-700 ring-1 ring-inset ring-amber-600/20">Public Client (PKCE Required)</span>
|
||||
<% else %>
|
||||
<span class="inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/20">Confidential Client</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- PKCE Requirement (only for confidential clients) -->
|
||||
<div id="pkce-options" data-application-form-target="pkceOptions" class="<%= 'hidden' if application.persisted? && application.public_client? %>">
|
||||
<div class="flex items-center">
|
||||
<%= form.check_box :require_pkce, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %>
|
||||
<%= form.label :require_pkce, "Require PKCE (Proof Key for Code Exchange)", class: "ml-2 block text-sm font-medium text-gray-900" %>
|
||||
</div>
|
||||
<p class="ml-6 text-sm text-gray-500">
|
||||
Recommended for enhanced security (OAuth 2.1 best practice).
|
||||
<br><span class="text-xs text-gray-400">Note: Public clients always require PKCE regardless of this setting.</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form.label :redirect_uris, "Redirect URIs", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.text_area :redirect_uris, rows: 4, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono", placeholder: "https://example.com/callback\nhttps://app.example.com/auth/callback" %>
|
||||
|
||||
@@ -1,17 +1,30 @@
|
||||
<div class="mb-6">
|
||||
<% if flash[:client_id] && flash[:client_secret] %>
|
||||
<% if flash[:client_id] %>
|
||||
<div class="bg-yellow-50 border border-yellow-200 rounded-md p-4 mb-6">
|
||||
<h4 class="text-sm font-medium text-yellow-800 mb-2">🔐 OIDC Client Credentials</h4>
|
||||
<p class="text-xs text-yellow-700 mb-3">Copy these credentials now. The client secret will not be shown again.</p>
|
||||
<% if flash[:public_client] %>
|
||||
<p class="text-xs text-yellow-700 mb-3">This is a public client. Copy the client ID below.</p>
|
||||
<% else %>
|
||||
<p class="text-xs text-yellow-700 mb-3">Copy these credentials now. The client secret will not be shown again.</p>
|
||||
<% end %>
|
||||
<div class="space-y-2">
|
||||
<div>
|
||||
<span class="text-xs font-medium text-yellow-700">Client ID:</span>
|
||||
</div>
|
||||
<code class="block bg-yellow-100 px-3 py-2 rounded font-mono text-xs break-all"><%= flash[:client_id] %></code>
|
||||
<div class="mt-3">
|
||||
<span class="text-xs font-medium text-yellow-700">Client Secret:</span>
|
||||
</div>
|
||||
<code class="block bg-yellow-100 px-3 py-2 rounded font-mono text-xs break-all"><%= flash[:client_secret] %></code>
|
||||
<% if flash[:client_secret] %>
|
||||
<div class="mt-3">
|
||||
<span class="text-xs font-medium text-yellow-700">Client Secret:</span>
|
||||
</div>
|
||||
<code class="block bg-yellow-100 px-3 py-2 rounded font-mono text-xs break-all"><%= flash[:client_secret] %></code>
|
||||
<% elsif flash[:public_client] %>
|
||||
<div class="mt-3">
|
||||
<span class="text-xs font-medium text-yellow-700">Client Secret:</span>
|
||||
</div>
|
||||
<div class="bg-yellow-100 px-3 py-2 rounded text-xs text-yellow-600">
|
||||
Public clients do not have a client secret. PKCE is required.
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -93,24 +106,57 @@
|
||||
<%= button_to "Regenerate Credentials", regenerate_credentials_admin_application_path(@application), method: :post, data: { turbo_confirm: "This will invalidate the current credentials. Continue?" }, class: "text-sm text-red-600 hover:text-red-900" %>
|
||||
</div>
|
||||
<dl class="space-y-4">
|
||||
<% unless flash[:client_id] && flash[:client_secret] %>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Client Type</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">
|
||||
<% if @application.public_client? %>
|
||||
<span class="inline-flex items-center rounded-full bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700">Public</span>
|
||||
<% else %>
|
||||
<span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700">Confidential</span>
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">PKCE</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">
|
||||
<% if @application.requires_pkce? %>
|
||||
<span class="inline-flex items-center rounded-full bg-green-100 px-2 py-1 text-xs font-medium text-green-700">Required</span>
|
||||
<% else %>
|
||||
<span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700">Optional</span>
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
<% unless flash[:client_id] %>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Client ID</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">
|
||||
<code class="block bg-gray-100 px-3 py-2 rounded font-mono text-xs break-all"><%= @application.client_id %></code>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Client Secret</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">
|
||||
<div class="bg-gray-100 px-3 py-2 rounded text-xs text-gray-500 italic">
|
||||
🔒 Client secret is stored securely and cannot be displayed
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500">
|
||||
To get a new client secret, use the "Regenerate Credentials" button above.
|
||||
</p>
|
||||
</dd>
|
||||
</div>
|
||||
<% if @application.confidential_client? %>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Client Secret</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">
|
||||
<div class="bg-gray-100 px-3 py-2 rounded text-xs text-gray-500 italic">
|
||||
🔒 Client secret is stored securely and cannot be displayed
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500">
|
||||
To get a new client secret, use the "Regenerate Credentials" button above.
|
||||
</p>
|
||||
</dd>
|
||||
</div>
|
||||
<% else %>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Client Secret</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">
|
||||
<div class="bg-blue-50 px-3 py-2 rounded text-xs text-blue-600">
|
||||
Public clients do not use a client secret. PKCE is required for authorization.
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Redirect URIs</dt>
|
||||
|
||||
@@ -31,6 +31,15 @@
|
||||
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form.label :current_password, "Current Password", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.password_field :current_password,
|
||||
autocomplete: "current-password",
|
||||
placeholder: "Required to change email",
|
||||
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
||||
<p class="mt-1 text-sm text-gray-500">Enter your current password to confirm this change</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form.submit "Update Email", class: "inline-flex justify-center rounded-md border border-transparent bg-blue-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" %>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<%# Enhanced Flash Messages with Support for Multiple Types and Auto-Dismiss %>
|
||||
<% flash.each do |type, message| %>
|
||||
<% next if message.blank? %>
|
||||
<%# Skip credential-related flash messages - they're displayed in a special credentials box %>
|
||||
<% next if %w[client_id client_secret public_client].include?(type.to_s) %>
|
||||
|
||||
<%
|
||||
# Map flash types to styling
|
||||
|
||||
@@ -2,6 +2,4 @@
|
||||
require "rubygems"
|
||||
require "bundler/setup"
|
||||
|
||||
ARGV.unshift("--ensure-latest")
|
||||
|
||||
load Gem.bin_path("brakeman", "brakeman")
|
||||
|
||||
5
bin/standardrb
Executable file
5
bin/standardrb
Executable file
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env ruby
|
||||
require "rubygems"
|
||||
require "bundler/setup"
|
||||
|
||||
load Gem.bin_path("standard", "standardrb")
|
||||
@@ -24,16 +24,16 @@ module Clinch
|
||||
# config.time_zone = "Central Time (US & Canada)"
|
||||
# config.eager_load_paths << Rails.root.join("extras")
|
||||
|
||||
# Configure SMTP settings using environment variables
|
||||
# Configure SMTP settings using environment variables
|
||||
config.action_mailer.delivery_method = :smtp
|
||||
config.action_mailer.smtp_settings = {
|
||||
address: ENV.fetch('SMTP_ADDRESS', 'localhost'),
|
||||
port: ENV.fetch('SMTP_PORT', 587),
|
||||
domain: ENV.fetch('SMTP_DOMAIN', 'localhost'),
|
||||
user_name: ENV.fetch('SMTP_USERNAME', nil),
|
||||
password: ENV.fetch('SMTP_PASSWORD', nil),
|
||||
authentication: ENV.fetch('SMTP_AUTHENTICATION', 'plain').to_sym,
|
||||
enable_starttls_auto: ENV.fetch('SMTP_STARTTLS_AUTO', 'true') == 'true',
|
||||
address: ENV.fetch("SMTP_ADDRESS", "localhost"),
|
||||
port: ENV.fetch("SMTP_PORT", 587),
|
||||
domain: ENV.fetch("SMTP_DOMAIN", "localhost"),
|
||||
user_name: ENV.fetch("SMTP_USERNAME", nil),
|
||||
password: ENV.fetch("SMTP_PASSWORD", nil),
|
||||
authentication: ENV.fetch("SMTP_AUTHENTICATION", "plain").to_sym,
|
||||
enable_starttls_auto: ENV.fetch("SMTP_STARTTLS_AUTO", "true") == "true",
|
||||
openssl_verify_mode: OpenSSL::SSL::VERIFY_PEER
|
||||
}
|
||||
end
|
||||
|
||||
@@ -20,7 +20,7 @@ Rails.application.configure do
|
||||
if Rails.root.join("tmp/caching-dev.txt").exist?
|
||||
config.action_controller.perform_caching = true
|
||||
config.action_controller.enable_fragment_cache_logging = true
|
||||
config.public_file_server.headers = { "cache-control" => "public, max-age=#{2.days.to_i}" }
|
||||
config.public_file_server.headers = {"cache-control" => "public, max-age=#{2.days.to_i}"}
|
||||
else
|
||||
config.action_controller.perform_caching = false
|
||||
end
|
||||
@@ -39,10 +39,10 @@ Rails.application.configure do
|
||||
config.action_mailer.perform_caching = false
|
||||
|
||||
# Set localhost to be used by links generated in mailer templates.
|
||||
config.action_mailer.default_url_options = { host: "localhost", port: 3000 }
|
||||
config.action_mailer.default_url_options = {host: "localhost", port: 3000}
|
||||
|
||||
# Log with request_id as a tag (same as production).
|
||||
config.log_tags = [ :request_id ]
|
||||
config.log_tags = [:request_id]
|
||||
|
||||
# Print deprecation notices to the Rails logger.
|
||||
config.active_support.deprecation = :log
|
||||
@@ -61,7 +61,6 @@ Rails.application.configure do
|
||||
|
||||
# Use async processor for background jobs in development
|
||||
config.active_job.queue_adapter = :async
|
||||
|
||||
|
||||
# Highlight code that triggered redirect in logs.
|
||||
config.action_dispatch.verbose_redirect_logs = true
|
||||
|
||||
@@ -16,7 +16,7 @@ Rails.application.configure do
|
||||
config.action_controller.perform_caching = true
|
||||
|
||||
# Cache assets for far-future expiry since they are all digest stamped.
|
||||
config.public_file_server.headers = { "cache-control" => "public, max-age=#{1.year.to_i}" }
|
||||
config.public_file_server.headers = {"cache-control" => "public, max-age=#{1.year.to_i}"}
|
||||
|
||||
# Enable serving of images, stylesheets, and JavaScripts from an asset server.
|
||||
# config.asset_host = "http://assets.example.com"
|
||||
@@ -30,12 +30,20 @@ Rails.application.configure do
|
||||
# Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies.
|
||||
config.force_ssl = true
|
||||
|
||||
# Additional security headers (beyond Rails defaults)
|
||||
# Note: Rails already sets X-Content-Type-Options: nosniff by default
|
||||
# Note: Permissions-Policy is configured in config/initializers/permissions_policy.rb
|
||||
config.action_dispatch.default_headers.merge!(
|
||||
"X-Frame-Options" => "DENY", # Override default SAMEORIGIN to prevent clickjacking
|
||||
"Referrer-Policy" => "strict-origin-when-cross-origin" # Control referrer information
|
||||
)
|
||||
|
||||
# Skip http-to-https redirect for the default health check endpoint.
|
||||
# config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } }
|
||||
|
||||
# Log to STDOUT with the current request id as a default log tag.
|
||||
config.log_tags = [ :request_id ]
|
||||
config.logger = ActiveSupport::TaggedLogging.logger(STDOUT)
|
||||
config.log_tags = [:request_id]
|
||||
config.logger = ActiveSupport::TaggedLogging.logger($stdout)
|
||||
|
||||
# Change to "debug" to log everything (including potentially personally-identifiable information!).
|
||||
config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info")
|
||||
@@ -49,8 +57,8 @@ Rails.application.configure do
|
||||
# Replace the default in-process memory cache store with a durable alternative.
|
||||
config.cache_store = :solid_cache_store
|
||||
|
||||
# Use async processor for background jobs (modify as needed for production)
|
||||
config.active_job.queue_adapter = :async
|
||||
# Use Solid Queue for background jobs
|
||||
config.active_job.queue_adapter = :solid_queue
|
||||
|
||||
# Ignore bad email addresses and do not raise email delivery errors.
|
||||
# Set this to true and configure the email server for immediate delivery to raise delivery errors.
|
||||
@@ -58,7 +66,7 @@ Rails.application.configure do
|
||||
|
||||
# Set host to be used by links generated in mailer templates.
|
||||
config.action_mailer.default_url_options = {
|
||||
host: ENV.fetch('CLINCH_HOST', 'example.com')
|
||||
host: ENV.fetch("CLINCH_HOST", "example.com")
|
||||
}
|
||||
|
||||
# Specify outgoing SMTP server. Remember to add smtp/* credentials via bin/rails credentials:edit.
|
||||
@@ -78,13 +86,13 @@ Rails.application.configure do
|
||||
config.active_record.dump_schema_after_migration = false
|
||||
|
||||
# Only use :id for inspections in production.
|
||||
config.active_record.attributes_for_inspect = [ :id ]
|
||||
config.active_record.attributes_for_inspect = [:id]
|
||||
|
||||
# Helper method to extract domain from CLINCH_HOST (removes protocol if present)
|
||||
def self.extract_domain(host)
|
||||
return host if host.blank?
|
||||
# Remove protocol (http:// or https://) if present
|
||||
host.gsub(/^https?:\/\//, '')
|
||||
host.gsub(/^https?:\/\//, "")
|
||||
end
|
||||
|
||||
# Helper method to ensure URL has https:// protocol
|
||||
@@ -97,11 +105,11 @@ Rails.application.configure do
|
||||
# Enable DNS rebinding protection and other `Host` header attacks.
|
||||
# Configure allowed hosts based on deployment scenario
|
||||
allowed_hosts = [
|
||||
extract_domain(ENV.fetch('CLINCH_HOST', 'auth.example.com')), # External domain (auth service itself)
|
||||
extract_domain(ENV.fetch("CLINCH_HOST", "auth.example.com")) # External domain (auth service itself)
|
||||
]
|
||||
|
||||
# Use PublicSuffix to extract registrable domain and allow all subdomains
|
||||
host_domain = extract_domain(ENV.fetch('CLINCH_HOST', 'auth.example.com'))
|
||||
host_domain = extract_domain(ENV.fetch("CLINCH_HOST", "auth.example.com"))
|
||||
if host_domain.present?
|
||||
begin
|
||||
# Use PublicSuffix to properly extract the domain
|
||||
@@ -115,20 +123,20 @@ Rails.application.configure do
|
||||
rescue PublicSuffix::DomainInvalid
|
||||
# Fallback to simple domain extraction if PublicSuffix fails
|
||||
Rails.logger.warn "Could not parse domain '#{host_domain}' with PublicSuffix, using fallback"
|
||||
base_domain = host_domain.split('.').last(2).join('.')
|
||||
base_domain = host_domain.split(".").last(2).join(".")
|
||||
allowed_hosts << /.*#{Regexp.escape(base_domain)}/
|
||||
end
|
||||
end
|
||||
|
||||
# Allow Docker service names if running in same compose
|
||||
if ENV['CLINCH_DOCKER_SERVICE_NAME']
|
||||
allowed_hosts << ENV['CLINCH_DOCKER_SERVICE_NAME']
|
||||
if ENV["CLINCH_DOCKER_SERVICE_NAME"]
|
||||
allowed_hosts << ENV["CLINCH_DOCKER_SERVICE_NAME"]
|
||||
end
|
||||
|
||||
# Allow internal IP access for cross-compose or host networking
|
||||
if ENV['CLINCH_ALLOW_INTERNAL_IPS'] == 'true'
|
||||
if ENV["CLINCH_ALLOW_INTERNAL_IPS"] == "true"
|
||||
# Specific host IP
|
||||
allowed_hosts << '192.168.2.246'
|
||||
allowed_hosts << "192.168.2.246"
|
||||
|
||||
# Private IP ranges for internal network access
|
||||
allowed_hosts += [
|
||||
@@ -139,14 +147,14 @@ Rails.application.configure do
|
||||
end
|
||||
|
||||
# Local development fallbacks
|
||||
if ENV['CLINCH_ALLOW_LOCALHOST'] == 'true'
|
||||
allowed_hosts += ['localhost', '127.0.0.1', '0.0.0.0']
|
||||
if ENV["CLINCH_ALLOW_LOCALHOST"] == "true"
|
||||
allowed_hosts += ["localhost", "127.0.0.1", "0.0.0.0"]
|
||||
end
|
||||
|
||||
config.hosts = allowed_hosts
|
||||
|
||||
# Skip DNS rebinding protection for the default health check endpoint.
|
||||
config.host_authorization = { exclude: ->(request) { request.path == "/up" } }
|
||||
config.host_authorization = {exclude: ->(request) { request.path == "/up" }}
|
||||
|
||||
# Sentry configuration for production
|
||||
# Only enabled if SENTRY_DSN environment variable is set
|
||||
|
||||
@@ -16,7 +16,7 @@ Rails.application.configure do
|
||||
config.eager_load = ENV["CI"].present?
|
||||
|
||||
# Configure public file server for tests with cache-control for performance.
|
||||
config.public_file_server.headers = { "cache-control" => "public, max-age=3600" }
|
||||
config.public_file_server.headers = {"cache-control" => "public, max-age=3600"}
|
||||
|
||||
# Show full error reports.
|
||||
config.consider_all_requests_local = true
|
||||
@@ -37,7 +37,7 @@ Rails.application.configure do
|
||||
config.action_mailer.delivery_method = :test
|
||||
|
||||
# Set host to be used by links generated in mailer templates.
|
||||
config.action_mailer.default_url_options = { host: "example.com" }
|
||||
config.action_mailer.default_url_options = {host: "example.com"}
|
||||
|
||||
# Print deprecation notices to the stderr.
|
||||
config.active_support.deprecation = :stderr
|
||||
|
||||
28
config/initializers/active_record_encryption.rb
Normal file
28
config/initializers/active_record_encryption.rb
Normal file
@@ -0,0 +1,28 @@
|
||||
# ActiveRecord Encryption Configuration
|
||||
# Encryption keys derived from SECRET_KEY_BASE (no separate key storage needed)
|
||||
# Used for encrypting sensitive columns (currently: TOTP secrets)
|
||||
#
|
||||
# Optional: Override with env vars (for key rotation or explicit key management):
|
||||
# - ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY
|
||||
# - ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY
|
||||
# - ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT
|
||||
|
||||
# Use env vars if set, otherwise derive from SECRET_KEY_BASE (deterministic)
|
||||
primary_key = ENV.fetch("ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY") do
|
||||
Rails.application.key_generator.generate_key("active_record_encryption_primary", 32)
|
||||
end
|
||||
deterministic_key = ENV.fetch("ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY") do
|
||||
Rails.application.key_generator.generate_key("active_record_encryption_deterministic", 32)
|
||||
end
|
||||
key_derivation_salt = ENV.fetch("ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT") do
|
||||
Rails.application.key_generator.generate_key("active_record_encryption_salt", 32)
|
||||
end
|
||||
|
||||
# Configure Rails 7.1+ ActiveRecord encryption
|
||||
Rails.application.config.active_record.encryption.primary_key = primary_key
|
||||
Rails.application.config.active_record.encryption.deterministic_key = deterministic_key
|
||||
Rails.application.config.active_record.encryption.key_derivation_salt = key_derivation_salt
|
||||
|
||||
# Allow unencrypted data for existing records (new/updated records will be encrypted)
|
||||
# Set to false after all existing encrypted columns have been migrated
|
||||
Rails.application.config.active_record.encryption.support_unencrypted_data = true
|
||||
@@ -56,14 +56,13 @@ Rails.application.configure do
|
||||
policy.require_trusted_types_for :none
|
||||
|
||||
# CSP reporting using report_uri (supported method)
|
||||
policy.report_uri "/api/csp-violation-report"
|
||||
policy.report_uri "/api/csp-violation-report"
|
||||
end
|
||||
|
||||
|
||||
# Start with CSP in report-only mode for testing
|
||||
# Set to false after verifying everything works in production
|
||||
config.content_security_policy_report_only = Rails.env.development?
|
||||
|
||||
# Report CSP violations (optional - uncomment to enable)
|
||||
# config.content_security_policy_report_uri = "/csp-violations"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -8,7 +8,7 @@ Rails.application.config.after_initialize do
|
||||
# Configure log rotation
|
||||
csp_logger = Logger.new(
|
||||
csp_log_path,
|
||||
'daily', # Rotate daily
|
||||
"daily", # Rotate daily
|
||||
30 # Keep 30 old log files
|
||||
)
|
||||
|
||||
@@ -16,7 +16,7 @@ Rails.application.config.after_initialize do
|
||||
|
||||
# Format: [TIMESTAMP] LEVEL MESSAGE
|
||||
csp_logger.formatter = proc do |severity, datetime, progname, msg|
|
||||
"[#{datetime.strftime('%Y-%m-%d %H:%M:%S')}] #{severity} #{msg}\n"
|
||||
"[#{datetime.strftime("%Y-%m-%d %H:%M:%S")}] #{severity} #{msg}\n"
|
||||
end
|
||||
|
||||
module CspViolationLocalLogger
|
||||
@@ -25,9 +25,9 @@ Rails.application.config.after_initialize do
|
||||
|
||||
# Skip logging if there's no meaningful violation data
|
||||
return if csp_data.empty? ||
|
||||
(csp_data[:violated_directive].nil? &&
|
||||
csp_data[:blocked_uri].nil? &&
|
||||
csp_data[:document_uri].nil?)
|
||||
(csp_data[:violated_directive].nil? &&
|
||||
csp_data[:blocked_uri].nil? &&
|
||||
csp_data[:document_uri].nil?)
|
||||
|
||||
# Build a structured log message
|
||||
violated_directive = csp_data[:violated_directive] || "unknown"
|
||||
@@ -69,7 +69,6 @@ Rails.application.config.after_initialize do
|
||||
|
||||
# Also log to main Rails logger for visibility
|
||||
Rails.logger.info "CSP violation logged to csp_violations.log: #{violated_directive} - #{blocked_uri}"
|
||||
|
||||
rescue => e
|
||||
# Ensure logger errors don't break the CSP reporting flow
|
||||
Rails.logger.error "Failed to log CSP violation to file: #{e.message}"
|
||||
@@ -81,12 +80,12 @@ Rails.application.config.after_initialize do
|
||||
csp_log_path = Rails.root.join("log", "csp_violations.log")
|
||||
logger = Logger.new(
|
||||
csp_log_path,
|
||||
'daily', # Rotate daily
|
||||
"daily", # Rotate daily
|
||||
30 # Keep 30 old log files
|
||||
)
|
||||
logger.level = Logger::INFO
|
||||
logger.formatter = proc do |severity, datetime, progname, msg|
|
||||
"[#{datetime.strftime('%Y-%m-%d %H:%M:%S')}] #{severity} #{msg}\n"
|
||||
"[#{datetime.strftime("%Y-%m-%d %H:%M:%S")}] #{severity} #{msg}\n"
|
||||
end
|
||||
logger
|
||||
end
|
||||
@@ -120,9 +119,8 @@ Rails.application.config.after_initialize do
|
||||
|
||||
# Test write to ensure permissions are correct
|
||||
csp_logger.info "CSP Logger initialized at #{Time.current}"
|
||||
|
||||
rescue => e
|
||||
Rails.logger.error "Failed to initialize CSP local logger: #{e.message}"
|
||||
Rails.logger.error "CSP violations will only be sent to Sentry (if configured)"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
19
config/initializers/permissions_policy.rb
Normal file
19
config/initializers/permissions_policy.rb
Normal file
@@ -0,0 +1,19 @@
|
||||
# Configure the Permissions-Policy header
|
||||
# See https://api.rubyonrails.org/classes/ActionDispatch/PermissionsPolicy.html
|
||||
|
||||
Rails.application.config.permissions_policy do |f|
|
||||
# Disable sensitive browser features for security
|
||||
f.camera :none
|
||||
f.gyroscope :none
|
||||
f.microphone :none
|
||||
f.payment :none
|
||||
f.usb :none
|
||||
f.magnetometer :none
|
||||
|
||||
# You can enable specific features as needed:
|
||||
# f.fullscreen :self
|
||||
# f.geolocation :self
|
||||
|
||||
# You can also allow specific origins:
|
||||
# f.payment :self, "https://secure.example.com"
|
||||
end
|
||||
@@ -74,7 +74,7 @@ Rails.application.configure do
|
||||
app_environment: Rails.env,
|
||||
# Add CSP policy status
|
||||
csp_enabled: defined?(Rails.application.config.content_security_policy) &&
|
||||
Rails.application.config.content_security_policy.present?
|
||||
Rails.application.config.content_security_policy.present?
|
||||
}
|
||||
end
|
||||
|
||||
@@ -120,13 +120,13 @@ Rails.application.configure do
|
||||
if breadcrumb[:data]
|
||||
breadcrumb[:data].reject! { |key, value|
|
||||
key.to_s.match?(/password|secret|token|key|authorization/i) ||
|
||||
value.to_s.match?(/password|secret/i)
|
||||
value.to_s.match?(/password|secret/i)
|
||||
}
|
||||
end
|
||||
|
||||
# Mark CSP-related events
|
||||
if breadcrumb[:message]&.include?("CSP Violation") ||
|
||||
breadcrumb[:category]&.include?("csp")
|
||||
breadcrumb[:category]&.include?("csp")
|
||||
breadcrumb[:data] ||= {}
|
||||
breadcrumb[:data][:security_event] = true
|
||||
breadcrumb[:data][:csp_violation] = true
|
||||
@@ -137,4 +137,4 @@ Rails.application.configure do
|
||||
|
||||
# Only send errors in production unless explicitly enabled
|
||||
config.sentry.enabled = Rails.env.production? || ENV["SENTRY_ENABLED_IN_DEVELOPMENT"] == "true"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -47,7 +47,7 @@ Rails.application.config.after_initialize do
|
||||
timestamp: csp_data[:timestamp]
|
||||
}
|
||||
},
|
||||
user: csp_data[:current_user_id] ? { id: csp_data[:current_user_id] } : nil
|
||||
user: csp_data[:current_user_id] ? {id: csp_data[:current_user_id]} : nil
|
||||
)
|
||||
|
||||
# Log to Rails logger for redundancy
|
||||
@@ -69,10 +69,10 @@ Rails.application.config.after_initialize do
|
||||
parsed.host
|
||||
rescue URI::InvalidURIError
|
||||
# Handle cases where URI might be malformed or just a path
|
||||
if uri.start_with?('/')
|
||||
if uri.start_with?("/")
|
||||
nil # It's a relative path, no domain
|
||||
else
|
||||
uri.split('/').first # Best effort extraction
|
||||
uri.split("/").first # Best effort extraction
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -117,4 +117,4 @@ Rails.application.config.after_initialize do
|
||||
else
|
||||
Rails.logger.info "Sentry not initialized - CSP violations will only be logged locally"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
7
config/initializers/token_hmac.rb
Normal file
7
config/initializers/token_hmac.rb
Normal file
@@ -0,0 +1,7 @@
|
||||
# Token HMAC key derivation
|
||||
# This key is used to compute HMAC-based token prefixes for fast lookup
|
||||
# Derived from SECRET_KEY_BASE - no storage needed, deterministic output
|
||||
# Optional: Set OIDC_TOKEN_PREFIX_HMAC env var to override with explicit key
|
||||
module TokenHmac
|
||||
KEY = ENV["OIDC_TOKEN_PREFIX_HMAC"] || Rails.application.key_generator.generate_key("oidc_token_prefix", 32)
|
||||
end
|
||||
@@ -1,5 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Clinch
|
||||
VERSION = "0.6.4"
|
||||
VERSION = "0.8.1"
|
||||
end
|
||||
|
||||
@@ -68,4 +68,4 @@ end
|
||||
# CLINCH_RP_NAME="Example Company Identity Provider"
|
||||
# CLINCH_WEBAUTHN_ATTESTATION=none
|
||||
# CLINCH_WEBAUTHN_USER_VERIFICATION=preferred
|
||||
# CLINCH_WEBAUTHN_RESIDENT_KEY=preferred
|
||||
# CLINCH_WEBAUTHN_RESIDENT_KEY=preferred
|
||||
|
||||
@@ -31,7 +31,6 @@ threads threads_count, threads_count
|
||||
# Specifies the `port` that Puma will listen on to receive requests; default is 3000.
|
||||
port ENV.fetch("PORT", 3000)
|
||||
|
||||
|
||||
# Allow puma to be restarted by `bin/rails restart` command.
|
||||
plugin :tmp_restart
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ Rails.application.routes.draw do
|
||||
|
||||
# Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.
|
||||
# Can be used by load balancers and uptime monitors to verify that the app is live.
|
||||
get "up" => "rails/health#show", as: :rails_health_check
|
||||
get "up" => "rails/health#show", :as => :rails_health_check
|
||||
|
||||
# Authentication routes
|
||||
get "/signup", to: "users#new", as: :signup
|
||||
@@ -61,21 +61,21 @@ Rails.application.routes.draw do
|
||||
end
|
||||
|
||||
# TOTP (2FA) routes
|
||||
get '/totp/new', to: 'totp#new', as: :new_totp
|
||||
post '/totp', to: 'totp#create', as: :totp
|
||||
delete '/totp', to: 'totp#destroy'
|
||||
get '/totp/backup_codes', to: 'totp#backup_codes', as: :backup_codes_totp
|
||||
post '/totp/verify_password', to: 'totp#verify_password', as: :verify_password_totp
|
||||
get '/totp/regenerate_backup_codes', to: 'totp#regenerate_backup_codes', as: :regenerate_backup_codes_totp
|
||||
post '/totp/regenerate_backup_codes', to: 'totp#create_new_backup_codes', as: :create_new_backup_codes_totp
|
||||
post '/totp/complete_setup', to: 'totp#complete_setup', as: :complete_totp_setup
|
||||
get "/totp/new", to: "totp#new", as: :new_totp
|
||||
post "/totp", to: "totp#create", as: :totp
|
||||
delete "/totp", to: "totp#destroy"
|
||||
get "/totp/backup_codes", to: "totp#backup_codes", as: :backup_codes_totp
|
||||
post "/totp/verify_password", to: "totp#verify_password", as: :verify_password_totp
|
||||
get "/totp/regenerate_backup_codes", to: "totp#regenerate_backup_codes", as: :regenerate_backup_codes_totp
|
||||
post "/totp/regenerate_backup_codes", to: "totp#create_new_backup_codes", as: :create_new_backup_codes_totp
|
||||
post "/totp/complete_setup", to: "totp#complete_setup", as: :complete_totp_setup
|
||||
|
||||
# WebAuthn (Passkeys) routes
|
||||
get '/webauthn/new', to: 'webauthn#new', as: :new_webauthn
|
||||
post '/webauthn/challenge', to: 'webauthn#challenge'
|
||||
post '/webauthn/create', to: 'webauthn#create'
|
||||
delete '/webauthn/:id', to: 'webauthn#destroy', as: :webauthn_credential
|
||||
get '/webauthn/check', to: 'webauthn#check'
|
||||
get "/webauthn/new", to: "webauthn#new", as: :new_webauthn
|
||||
post "/webauthn/challenge", to: "webauthn#challenge"
|
||||
post "/webauthn/create", to: "webauthn#create"
|
||||
delete "/webauthn/:id", to: "webauthn#destroy", as: :webauthn_credential
|
||||
get "/webauthn/check", to: "webauthn#check"
|
||||
|
||||
# Admin routes
|
||||
namespace :admin do
|
||||
|
||||
@@ -7,6 +7,6 @@ class CreateUserGroups < ActiveRecord::Migration[8.1]
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :user_groups, [ :user_id, :group_id ], unique: true
|
||||
add_index :user_groups, [:user_id, :group_id], unique: true
|
||||
end
|
||||
end
|
||||
|
||||
@@ -7,6 +7,6 @@ class CreateApplicationGroups < ActiveRecord::Migration[8.1]
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :application_groups, [ :application_id, :group_id ], unique: true
|
||||
add_index :application_groups, [:application_id, :group_id], unique: true
|
||||
end
|
||||
end
|
||||
|
||||
@@ -13,6 +13,6 @@ class CreateOidcAuthorizationCodes < ActiveRecord::Migration[8.1]
|
||||
end
|
||||
add_index :oidc_authorization_codes, :code, unique: true
|
||||
add_index :oidc_authorization_codes, :expires_at
|
||||
add_index :oidc_authorization_codes, [ :application_id, :user_id ]
|
||||
add_index :oidc_authorization_codes, [:application_id, :user_id]
|
||||
end
|
||||
end
|
||||
|
||||
@@ -11,6 +11,6 @@ class CreateOidcAccessTokens < ActiveRecord::Migration[8.1]
|
||||
end
|
||||
add_index :oidc_access_tokens, :token, unique: true
|
||||
add_index :oidc_access_tokens, :expires_at
|
||||
add_index :oidc_access_tokens, [ :application_id, :user_id ]
|
||||
add_index :oidc_access_tokens, [:application_id, :user_id]
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
class AddRoleMappingToApplications < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
add_column :applications, :role_mapping_mode, :string, default: 'disabled', null: false
|
||||
add_column :applications, :role_mapping_mode, :string, default: "disabled", null: false
|
||||
add_column :applications, :role_prefix, :string
|
||||
add_column :applications, :managed_permissions, :json, default: {}
|
||||
add_column :applications, :role_claim_name, :string, default: 'roles'
|
||||
add_column :applications, :role_claim_name, :string, default: "roles"
|
||||
|
||||
create_table :application_roles do |t|
|
||||
t.references :application, null: false, foreign_key: true
|
||||
@@ -21,7 +21,7 @@ class AddRoleMappingToApplications < ActiveRecord::Migration[8.1]
|
||||
create_table :user_role_assignments do |t|
|
||||
t.references :user, null: false, foreign_key: true
|
||||
t.references :application_role, null: false, foreign_key: true
|
||||
t.string :source, default: 'oidc' # 'oidc', 'manual', 'group_sync'
|
||||
t.string :source, default: "oidc" # 'oidc', 'manual', 'group_sync'
|
||||
t.json :metadata, default: {}
|
||||
|
||||
t.timestamps
|
||||
|
||||
@@ -41,7 +41,7 @@ class MigrateForwardAuthRulesToApplications < ActiveRecord::Migration[8.1]
|
||||
app = application_class.create!(
|
||||
name: rule.domain_pattern.titleize,
|
||||
slug: rule.domain_pattern.parameterize.presence || "forward-auth-#{rule.id}",
|
||||
app_type: 'forward_auth',
|
||||
app_type: "forward_auth",
|
||||
domain_pattern: rule.domain_pattern,
|
||||
headers_config: rule.headers_config || {},
|
||||
active: rule.active
|
||||
@@ -59,7 +59,7 @@ class MigrateForwardAuthRulesToApplications < ActiveRecord::Migration[8.1]
|
||||
|
||||
def down
|
||||
# Remove all forward_auth applications created by this migration
|
||||
Application.where(app_type: 'forward_auth').destroy_all
|
||||
Application.where(app_type: "forward_auth").destroy_all
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -5,7 +5,7 @@ class CreateWebauthnCredentials < ActiveRecord::Migration[8.1]
|
||||
t.references :user, null: false, foreign_key: true, index: true
|
||||
|
||||
# WebAuthn specification fields
|
||||
t.string :external_id, null: false, index: { unique: true } # credential ID (base64)
|
||||
t.string :external_id, null: false, index: {unique: true} # credential ID (base64)
|
||||
t.string :public_key, null: false # public key (base64)
|
||||
t.integer :sign_count, null: false, default: 0 # signature counter (clone detection)
|
||||
|
||||
|
||||
@@ -17,6 +17,6 @@ class CreateOidcRefreshTokens < ActiveRecord::Migration[8.1]
|
||||
add_index :oidc_refresh_tokens, :expires_at
|
||||
add_index :oidc_refresh_tokens, :revoked_at
|
||||
add_index :oidc_refresh_tokens, :token_family_id
|
||||
add_index :oidc_refresh_tokens, [ :application_id, :user_id ]
|
||||
add_index :oidc_refresh_tokens, [:application_id, :user_id]
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
class CreateApplicationUserClaims < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
create_table :application_user_claims do |t|
|
||||
t.references :application, null: false, foreign_key: { on_delete: :cascade }
|
||||
t.references :user, null: false, foreign_key: { on_delete: :cascade }
|
||||
t.references :application, null: false, foreign_key: {on_delete: :cascade}
|
||||
t.references :user, null: false, foreign_key: {on_delete: :cascade}
|
||||
t.json :custom_claims, default: {}, null: false
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :application_user_claims, [:application_id, :user_id], unique: true, name: 'index_app_user_claims_unique'
|
||||
add_index :application_user_claims, [:application_id, :user_id], unique: true, name: "index_app_user_claims_unique"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -5,13 +5,13 @@ class CreateActiveStorageTables < ActiveRecord::Migration[7.0]
|
||||
primary_key_type, foreign_key_type = primary_and_foreign_key_types
|
||||
|
||||
create_table :active_storage_blobs, id: primary_key_type do |t|
|
||||
t.string :key, null: false
|
||||
t.string :filename, null: false
|
||||
t.string :content_type
|
||||
t.text :metadata
|
||||
t.string :service_name, null: false
|
||||
t.bigint :byte_size, null: false
|
||||
t.string :checksum
|
||||
t.string :key, null: false
|
||||
t.string :filename, null: false
|
||||
t.string :content_type
|
||||
t.text :metadata
|
||||
t.string :service_name, null: false
|
||||
t.bigint :byte_size, null: false
|
||||
t.string :checksum
|
||||
|
||||
if connection.supports_datetime_with_precision?
|
||||
t.datetime :created_at, precision: 6, null: false
|
||||
@@ -19,13 +19,13 @@ class CreateActiveStorageTables < ActiveRecord::Migration[7.0]
|
||||
t.datetime :created_at, null: false
|
||||
end
|
||||
|
||||
t.index [ :key ], unique: true
|
||||
t.index [:key], unique: true
|
||||
end
|
||||
|
||||
create_table :active_storage_attachments, id: primary_key_type do |t|
|
||||
t.string :name, null: false
|
||||
t.references :record, null: false, polymorphic: true, index: false, type: foreign_key_type
|
||||
t.references :blob, null: false, type: foreign_key_type
|
||||
t.string :name, null: false
|
||||
t.references :record, null: false, polymorphic: true, index: false, type: foreign_key_type
|
||||
t.references :blob, null: false, type: foreign_key_type
|
||||
|
||||
if connection.supports_datetime_with_precision?
|
||||
t.datetime :created_at, precision: 6, null: false
|
||||
@@ -33,7 +33,7 @@ class CreateActiveStorageTables < ActiveRecord::Migration[7.0]
|
||||
t.datetime :created_at, null: false
|
||||
end
|
||||
|
||||
t.index [ :record_type, :record_id, :name, :blob_id ], name: :index_active_storage_attachments_uniqueness, unique: true
|
||||
t.index [:record_type, :record_id, :name, :blob_id], name: :index_active_storage_attachments_uniqueness, unique: true
|
||||
t.foreign_key :active_storage_blobs, column: :blob_id
|
||||
end
|
||||
|
||||
@@ -41,17 +41,18 @@ class CreateActiveStorageTables < ActiveRecord::Migration[7.0]
|
||||
t.belongs_to :blob, null: false, index: false, type: foreign_key_type
|
||||
t.string :variation_digest, null: false
|
||||
|
||||
t.index [ :blob_id, :variation_digest ], name: :index_active_storage_variant_records_uniqueness, unique: true
|
||||
t.index [:blob_id, :variation_digest], name: :index_active_storage_variant_records_uniqueness, unique: true
|
||||
t.foreign_key :active_storage_blobs, column: :blob_id
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def primary_and_foreign_key_types
|
||||
config = Rails.configuration.generators
|
||||
setting = config.options[config.orm][:primary_key_type]
|
||||
primary_key_type = setting || :primary_key
|
||||
foreign_key_type = setting || :bigint
|
||||
[ primary_key_type, foreign_key_type ]
|
||||
end
|
||||
|
||||
def primary_and_foreign_key_types
|
||||
config = Rails.configuration.generators
|
||||
setting = config.options[config.orm][:primary_key_type]
|
||||
primary_key_type = setting || :primary_key
|
||||
foreign_key_type = setting || :bigint
|
||||
[primary_key_type, foreign_key_type]
|
||||
end
|
||||
end
|
||||
|
||||
42
db/migrate/20251229220739_add_token_prefix_to_tokens.rb
Normal file
42
db/migrate/20251229220739_add_token_prefix_to_tokens.rb
Normal file
@@ -0,0 +1,42 @@
|
||||
class AddTokenPrefixToTokens < ActiveRecord::Migration[8.1]
|
||||
def up
|
||||
add_column :oidc_access_tokens, :token_prefix, :string, limit: 8
|
||||
add_column :oidc_refresh_tokens, :token_prefix, :string, limit: 8
|
||||
|
||||
# Backfill existing tokens with prefix and digest
|
||||
say_with_time "Backfilling token prefixes and digests..." do
|
||||
[OidcAccessToken, OidcRefreshToken].each do |klass|
|
||||
klass.reset_column_information # Ensure Rails knows about new column
|
||||
|
||||
klass.where(token_prefix: nil).find_each do |token|
|
||||
next unless token.token.present?
|
||||
|
||||
updates = {}
|
||||
|
||||
# Compute HMAC prefix
|
||||
prefix = klass.compute_token_prefix(token.token)
|
||||
updates[:token_prefix] = prefix if prefix.present?
|
||||
|
||||
# Backfill digest if missing
|
||||
if token.token_digest.nil?
|
||||
updates[:token_digest] = BCrypt::Password.create(token.token)
|
||||
end
|
||||
|
||||
token.update_columns(updates) if updates.any?
|
||||
end
|
||||
|
||||
say " #{klass.name}: #{klass.where.not(token_prefix: nil).count} tokens backfilled"
|
||||
end
|
||||
end
|
||||
|
||||
add_index :oidc_access_tokens, :token_prefix
|
||||
add_index :oidc_refresh_tokens, :token_prefix
|
||||
end
|
||||
|
||||
def down
|
||||
remove_index :oidc_access_tokens, :token_prefix
|
||||
remove_index :oidc_refresh_tokens, :token_prefix
|
||||
remove_column :oidc_access_tokens, :token_prefix
|
||||
remove_column :oidc_refresh_tokens, :token_prefix
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,10 @@
|
||||
class RemovePlaintextTokenFromOidcAccessTokens < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
# Remove the unique index first
|
||||
remove_index :oidc_access_tokens, :token, if_exists: true
|
||||
|
||||
# Remove the plaintext token column - no longer needed
|
||||
# Tokens are now stored as BCrypt-hashed token_digest with HMAC token_prefix
|
||||
remove_column :oidc_access_tokens, :token, :string
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,14 @@
|
||||
class AddPkceOptionsToApplications < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
# Add require_pkce column for confidential clients
|
||||
# Default true for new apps (secure by default), existing apps will be false
|
||||
add_column :applications, :require_pkce, :boolean, default: true, null: false
|
||||
|
||||
# Set existing applications to not require PKCE (backwards compatibility)
|
||||
reversible do |dir|
|
||||
dir.up do
|
||||
execute "UPDATE applications SET require_pkce = false WHERE id > 0"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,20 @@
|
||||
class RenameCodeToCodeHmacAndAddTokenHmac < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
# Authorization codes: rename code to code_hmac
|
||||
rename_column :oidc_authorization_codes, :code, :code_hmac
|
||||
|
||||
# Access tokens: add token_hmac, remove old columns
|
||||
add_column :oidc_access_tokens, :token_hmac, :string
|
||||
add_index :oidc_access_tokens, :token_hmac, unique: true
|
||||
|
||||
remove_column :oidc_access_tokens, :token_prefix
|
||||
remove_column :oidc_access_tokens, :token_digest
|
||||
|
||||
# Refresh tokens: add token_hmac, remove old columns
|
||||
add_column :oidc_refresh_tokens, :token_hmac, :string
|
||||
add_index :oidc_refresh_tokens, :token_hmac, unique: true
|
||||
|
||||
remove_column :oidc_refresh_tokens, :token_prefix
|
||||
remove_column :oidc_refresh_tokens, :token_digest
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,6 @@
|
||||
class AddAuthTimeToOidcTokens < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
add_column :oidc_authorization_codes, :auth_time, :integer
|
||||
add_column :oidc_refresh_tokens, :auth_time, :integer
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
class AddAcrToOidcTokensAndSessions < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
add_column :sessions, :acr, :string
|
||||
add_column :oidc_authorization_codes, :acr, :string
|
||||
add_column :oidc_refresh_tokens, :acr, :string
|
||||
end
|
||||
end
|
||||
22
db/schema.rb
generated
22
db/schema.rb
generated
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.1].define(version: 2025_11_25_081147) do
|
||||
ActiveRecord::Schema[8.1].define(version: 2025_12_31_060112) do
|
||||
create_table "active_storage_attachments", force: :cascade do |t|
|
||||
t.bigint "blob_id", null: false
|
||||
t.datetime "created_at", null: false
|
||||
@@ -77,6 +77,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_25_081147) do
|
||||
t.string "name", null: false
|
||||
t.text "redirect_uris"
|
||||
t.integer "refresh_token_ttl", default: 2592000
|
||||
t.boolean "require_pkce", default: true, null: false
|
||||
t.string "slug", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["active"], name: "index_applications_on_active"
|
||||
@@ -100,24 +101,24 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_25_081147) do
|
||||
t.datetime "expires_at", null: false
|
||||
t.datetime "revoked_at"
|
||||
t.string "scope"
|
||||
t.string "token"
|
||||
t.string "token_digest"
|
||||
t.string "token_hmac"
|
||||
t.datetime "updated_at", null: false
|
||||
t.integer "user_id", null: false
|
||||
t.index ["application_id", "user_id"], name: "index_oidc_access_tokens_on_application_id_and_user_id"
|
||||
t.index ["application_id"], name: "index_oidc_access_tokens_on_application_id"
|
||||
t.index ["expires_at"], name: "index_oidc_access_tokens_on_expires_at"
|
||||
t.index ["revoked_at"], name: "index_oidc_access_tokens_on_revoked_at"
|
||||
t.index ["token"], name: "index_oidc_access_tokens_on_token", unique: true
|
||||
t.index ["token_digest"], name: "index_oidc_access_tokens_on_token_digest", unique: true
|
||||
t.index ["token_hmac"], name: "index_oidc_access_tokens_on_token_hmac", unique: true
|
||||
t.index ["user_id"], name: "index_oidc_access_tokens_on_user_id"
|
||||
end
|
||||
|
||||
create_table "oidc_authorization_codes", force: :cascade do |t|
|
||||
t.string "acr"
|
||||
t.integer "application_id", null: false
|
||||
t.string "code", null: false
|
||||
t.integer "auth_time"
|
||||
t.string "code_challenge"
|
||||
t.string "code_challenge_method"
|
||||
t.string "code_hmac", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "expires_at", null: false
|
||||
t.string "nonce"
|
||||
@@ -128,21 +129,23 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_25_081147) do
|
||||
t.integer "user_id", null: false
|
||||
t.index ["application_id", "user_id"], name: "index_oidc_authorization_codes_on_application_id_and_user_id"
|
||||
t.index ["application_id"], name: "index_oidc_authorization_codes_on_application_id"
|
||||
t.index ["code"], name: "index_oidc_authorization_codes_on_code", unique: true
|
||||
t.index ["code_challenge"], name: "index_oidc_authorization_codes_on_code_challenge"
|
||||
t.index ["code_hmac"], name: "index_oidc_authorization_codes_on_code_hmac", unique: true
|
||||
t.index ["expires_at"], name: "index_oidc_authorization_codes_on_expires_at"
|
||||
t.index ["user_id"], name: "index_oidc_authorization_codes_on_user_id"
|
||||
end
|
||||
|
||||
create_table "oidc_refresh_tokens", force: :cascade do |t|
|
||||
t.string "acr"
|
||||
t.integer "application_id", null: false
|
||||
t.integer "auth_time"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "expires_at", null: false
|
||||
t.integer "oidc_access_token_id", null: false
|
||||
t.datetime "revoked_at"
|
||||
t.string "scope"
|
||||
t.string "token_digest", null: false
|
||||
t.integer "token_family_id"
|
||||
t.string "token_hmac"
|
||||
t.datetime "updated_at", null: false
|
||||
t.integer "user_id", null: false
|
||||
t.index ["application_id", "user_id"], name: "index_oidc_refresh_tokens_on_application_id_and_user_id"
|
||||
@@ -150,8 +153,8 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_25_081147) do
|
||||
t.index ["expires_at"], name: "index_oidc_refresh_tokens_on_expires_at"
|
||||
t.index ["oidc_access_token_id"], name: "index_oidc_refresh_tokens_on_oidc_access_token_id"
|
||||
t.index ["revoked_at"], name: "index_oidc_refresh_tokens_on_revoked_at"
|
||||
t.index ["token_digest"], name: "index_oidc_refresh_tokens_on_token_digest", unique: true
|
||||
t.index ["token_family_id"], name: "index_oidc_refresh_tokens_on_token_family_id"
|
||||
t.index ["token_hmac"], name: "index_oidc_refresh_tokens_on_token_hmac", unique: true
|
||||
t.index ["user_id"], name: "index_oidc_refresh_tokens_on_user_id"
|
||||
end
|
||||
|
||||
@@ -171,6 +174,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_25_081147) do
|
||||
end
|
||||
|
||||
create_table "sessions", force: :cascade do |t|
|
||||
t.string "acr"
|
||||
t.datetime "created_at", null: false
|
||||
t.string "device_name"
|
||||
t.datetime "expires_at"
|
||||
|
||||
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!
|
||||
316
docs/backchannel-logout.md
Normal file
316
docs/backchannel-logout.md
Normal file
@@ -0,0 +1,316 @@
|
||||
# OpenID Connect Backchannel Logout
|
||||
|
||||
## Overview
|
||||
|
||||
Backchannel logout is an OpenID Connect feature that enables Clinch to notify applications when a user logs out, ensuring sessions are terminated across all connected applications immediately.
|
||||
|
||||
## How It Works
|
||||
|
||||
When a user logs out from Clinch (or any connected application), Clinch sends server-to-server HTTP POST requests to all applications that have configured a backchannel logout endpoint. This happens automatically in the background.
|
||||
|
||||
### Logout Triggers
|
||||
|
||||
Backchannel logout notifications are sent when:
|
||||
|
||||
1. **User clicks "Sign Out" in Clinch** - All connected OIDC applications are notified, then the Clinch session is terminated
|
||||
2. **User logs out via OIDC `/logout` endpoint** (RP-Initiated Logout) - All connected applications are notified, then the Clinch session is terminated
|
||||
3. **User clicks "Logout" on an app (Dashboard)** - Backchannel logout is sent to that app, all access/refresh tokens are revoked, but OAuth consent is preserved (user can sign back in without re-authorizing)
|
||||
4. **User clicks "Revoke Access" for a specific app (Active Sessions page)** - Backchannel logout is sent to that app to terminate its session, all access/refresh tokens are revoked, then the OAuth consent is permanently destroyed (user must re-authorize the app to use it again)
|
||||
5. **User clicks "Revoke All App Access"** - All connected applications receive backchannel logout notifications, all tokens are revoked, then all OAuth consents are permanently destroyed
|
||||
|
||||
### The Logout Flow
|
||||
|
||||
```
|
||||
User logs out → Clinch finds all connected apps
|
||||
↓
|
||||
For each app with backchannel_logout_uri:
|
||||
↓
|
||||
Generate signed JWT logout token
|
||||
↓
|
||||
HTTP POST to app's logout endpoint
|
||||
↓
|
||||
App validates JWT and terminates session
|
||||
↓
|
||||
Clinch revokes access and refresh tokens
|
||||
```
|
||||
|
||||
### Logout vs Revoke Access
|
||||
|
||||
Clinch provides two distinct actions for managing application access:
|
||||
|
||||
| Action | Location | What Happens | When to Use |
|
||||
|--------|----------|--------------|-------------|
|
||||
| **Logout** | Dashboard | • Sends backchannel logout to app<br>• Revokes all access tokens<br>• Revokes all refresh tokens<br>• **Keeps OAuth consent intact** | You want to end your session with an app but still trust it. Next login will skip the authorization screen. |
|
||||
| **Revoke Access** | Active Sessions page | • Sends backchannel logout to app<br>• Revokes all access tokens<br>• Revokes all refresh tokens<br>• **Destroys OAuth consent** | You want to completely de-authorize an app. Next login will require you to re-authorize the app. |
|
||||
|
||||
**Key Difference**: "Logout" preserves the authorization relationship while terminating the active session. "Revoke Access" completely removes the app's authorization to access your account.
|
||||
|
||||
**Example Use Cases**:
|
||||
- **Logout**: "I left my Jellyfin session open at a friend's house. I want to kill that session but I still use Jellyfin."
|
||||
- **Revoke Access**: "I no longer trust this app and want to remove its authorization completely."
|
||||
|
||||
**Technical Details**:
|
||||
- Both actions revoke access tokens (opaque, database-backed, validated on each use)
|
||||
- Both actions revoke refresh tokens (prevents obtaining new access tokens)
|
||||
- ID tokens remain valid until expiry (stateless JWTs), but apps should honor backchannel logout
|
||||
- Backchannel logout ensures the app clears its local session immediately
|
||||
|
||||
## Configuring Applications
|
||||
|
||||
### In Clinch Admin UI
|
||||
|
||||
1. Navigate to **Admin → Applications**
|
||||
2. Edit or create an OIDC application
|
||||
3. In the "Backchannel Logout URI" field, enter the application's logout endpoint
|
||||
- Example: `https://kavita.local/oidc/backchannel-logout`
|
||||
- Must be HTTPS in production
|
||||
- Leave blank if the application doesn't support backchannel logout
|
||||
|
||||
### Checking Support
|
||||
|
||||
The OIDC discovery endpoint advertises backchannel logout support:
|
||||
|
||||
```bash
|
||||
curl https://clinch.local/.well-known/openid-configuration | jq
|
||||
```
|
||||
|
||||
Look for:
|
||||
```json
|
||||
{
|
||||
"backchannel_logout_supported": true,
|
||||
"backchannel_logout_session_supported": true
|
||||
}
|
||||
```
|
||||
|
||||
## Implementing a Backchannel Logout Endpoint (for RPs)
|
||||
|
||||
If you're developing an application that integrates with Clinch, here's how to implement backchannel logout support:
|
||||
|
||||
### 1. Create the Endpoint
|
||||
|
||||
The endpoint must:
|
||||
- Accept HTTP POST requests
|
||||
- Parse the `logout_token` parameter from the form body
|
||||
- Validate the JWT signature
|
||||
- Terminate the user's session
|
||||
- Return 200 OK quickly (within 5 seconds)
|
||||
|
||||
### 2. Example Implementation (Ruby/Rails)
|
||||
|
||||
```ruby
|
||||
# config/routes.rb
|
||||
post '/oidc/backchannel-logout', to: 'oidc_backchannel_logout#logout'
|
||||
|
||||
# app/controllers/oidc_backchannel_logout_controller.rb
|
||||
class OidcBackchannelLogoutController < ApplicationController
|
||||
skip_before_action :verify_authenticity_token # Server-to-server call
|
||||
skip_before_action :authenticate_user! # No user session yet
|
||||
|
||||
def logout
|
||||
logout_token = params[:logout_token]
|
||||
|
||||
unless logout_token.present?
|
||||
head :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
begin
|
||||
# Decode and verify the JWT
|
||||
# Get Clinch's public key from JWKS endpoint
|
||||
jwks = fetch_clinch_jwks
|
||||
decoded = JWT.decode(
|
||||
logout_token,
|
||||
nil, # Will be verified using JWKS
|
||||
true,
|
||||
{
|
||||
algorithms: ['RS256'],
|
||||
jwks: jwks,
|
||||
verify_aud: true,
|
||||
aud: YOUR_CLIENT_ID,
|
||||
verify_iss: true,
|
||||
iss: 'https://clinch.local' # Your Clinch URL
|
||||
}
|
||||
)
|
||||
|
||||
claims = decoded.first
|
||||
|
||||
# Validate required claims
|
||||
unless claims['events']&.key?('http://schemas.openid.net/event/backchannel-logout')
|
||||
head :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
# Get session ID from the token
|
||||
sid = claims['sid']
|
||||
sub = claims['sub']
|
||||
|
||||
# Terminate sessions
|
||||
if sid.present?
|
||||
# Terminate specific session by SID (recommended)
|
||||
Session.where(oidc_sid: sid).destroy_all
|
||||
elsif sub.present?
|
||||
# Terminate all sessions for this user
|
||||
user = User.find_by(oidc_sub: sub)
|
||||
user&.sessions&.destroy_all
|
||||
end
|
||||
|
||||
Rails.logger.info "Backchannel logout: Terminated session for sid=#{sid}, sub=#{sub}"
|
||||
head :ok
|
||||
|
||||
rescue JWT::DecodeError => e
|
||||
Rails.logger.error "Backchannel logout: Invalid JWT - #{e.message}"
|
||||
head :bad_request
|
||||
rescue => e
|
||||
Rails.logger.error "Backchannel logout: Error - #{e.class}: #{e.message}"
|
||||
head :internal_server_error
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_clinch_jwks
|
||||
# Cache this in production!
|
||||
response = HTTParty.get('https://clinch.local/.well-known/jwks.json')
|
||||
JSON.parse(response.body, symbolize_names: true)
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### 3. Required JWT Claims Validation
|
||||
|
||||
The logout token will contain:
|
||||
|
||||
| Claim | Description | Required |
|
||||
|-------|-------------|----------|
|
||||
| `iss` | Issuer (Clinch URL) | Yes |
|
||||
| `aud` | Your application's client_id | Yes |
|
||||
| `iat` | Issued at timestamp | Yes |
|
||||
| `jti` | Unique token ID | Yes |
|
||||
| `sub` | Pairwise subject identifier (user's SID) | Yes |
|
||||
| `sid` | Session ID (same as sub) | Yes |
|
||||
| `events` | Must contain `http://schemas.openid.net/event/backchannel-logout` | Yes |
|
||||
| `nonce` | Must NOT be present (spec requirement) | No |
|
||||
|
||||
### 4. Session Tracking Requirements
|
||||
|
||||
To support backchannel logout, your application must:
|
||||
|
||||
1. **Store the `sid` claim from ID tokens**:
|
||||
```ruby
|
||||
# When user logs in via OIDC
|
||||
id_token = decode_id_token(params[:id_token])
|
||||
session[:oidc_sid] = id_token['sid'] # Store this!
|
||||
```
|
||||
|
||||
2. **Associate sessions with SID**:
|
||||
```ruby
|
||||
# Create session with SID tracking
|
||||
Session.create!(
|
||||
user: current_user,
|
||||
oidc_sid: id_token['sid'],
|
||||
...
|
||||
)
|
||||
```
|
||||
|
||||
3. **Terminate sessions by SID**:
|
||||
```ruby
|
||||
# When backchannel logout is received
|
||||
Session.where(oidc_sid: sid).destroy_all
|
||||
```
|
||||
|
||||
### 5. Testing Your Endpoint
|
||||
|
||||
Test with curl:
|
||||
|
||||
```bash
|
||||
# Get a valid logout token (you'll need to capture this from Clinch logs)
|
||||
LOGOUT_TOKEN="eyJhbGc..."
|
||||
|
||||
curl -X POST https://your-app.local/oidc/backchannel-logout \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
-d "logout_token=$LOGOUT_TOKEN"
|
||||
```
|
||||
|
||||
Expected response: `200 OK` (empty body)
|
||||
|
||||
## Monitoring and Troubleshooting
|
||||
|
||||
### Checking Logs
|
||||
|
||||
Clinch logs all backchannel logout attempts:
|
||||
|
||||
```bash
|
||||
# In development
|
||||
tail -f log/development.log | grep BackchannelLogout
|
||||
|
||||
# Example log output:
|
||||
# BackchannelLogout: Successfully sent logout notification to Kavita (https://kavita.local/oidc/backchannel-logout)
|
||||
# BackchannelLogout: Application Jellyfin doesn't support backchannel logout
|
||||
# BackchannelLogout: Timeout sending logout to HomeAssistant (https://ha.local/logout): Connection timeout
|
||||
```
|
||||
|
||||
### Common Issues
|
||||
|
||||
**1. HTTP Timeout**
|
||||
- Symptom: `Timeout sending logout to...` in logs
|
||||
- Solution: Ensure the RP's backchannel logout endpoint responds within 5 seconds
|
||||
- Note: Clinch will retry 3 times with exponential backoff
|
||||
|
||||
**2. HTTP Errors (Non-200 Status)**
|
||||
- Symptom: `Application X returned HTTP 400/500...` in logs
|
||||
- Solution: Check the RP's logs for JWT validation errors
|
||||
- Common causes:
|
||||
- Wrong JWKS (public key mismatch)
|
||||
- Incorrect `aud` (client_id) validation
|
||||
- Missing required claims validation
|
||||
|
||||
**3. Network Unreachable**
|
||||
- Symptom: `Failed to send logout to...` with connection errors
|
||||
- Solution: Ensure the RP's logout endpoint is accessible from Clinch server
|
||||
- Check: Firewalls, DNS, SSL certificates
|
||||
|
||||
**4. Sessions Not Terminating**
|
||||
- Symptom: User still logged into RP after logging out of Clinch
|
||||
- Solution: Verify the RP is storing and checking `sid` correctly
|
||||
- Debug: Add logging to the RP's backchannel logout handler
|
||||
|
||||
### Verification Checklist
|
||||
|
||||
For RPs (Application Developers):
|
||||
- [ ] Endpoint accepts POST requests
|
||||
- [ ] Endpoint validates JWT signature using Clinch's JWKS
|
||||
- [ ] Endpoint validates all required claims
|
||||
- [ ] Endpoint terminates sessions by SID
|
||||
- [ ] Endpoint returns 200 OK quickly (< 5 seconds)
|
||||
- [ ] Sessions store the `sid` claim from ID tokens
|
||||
- [ ] Backchannel logout URI is configured in Clinch admin
|
||||
|
||||
For Administrators:
|
||||
- [ ] Application has `backchannel_logout_uri` configured
|
||||
- [ ] URI uses HTTPS (in production)
|
||||
- [ ] URI is reachable from Clinch server
|
||||
- [ ] Check logs for successful logout notifications
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **JWT Signature Verification**: Always verify the logout token signature using Clinch's public key
|
||||
2. **Audience Validation**: Ensure the `aud` claim matches your client_id
|
||||
3. **Issuer Validation**: Ensure the `iss` claim matches your Clinch URL
|
||||
4. **No Authentication Required**: The endpoint should not require user authentication (it's server-to-server)
|
||||
5. **HTTPS Only**: Always use HTTPS in production (Clinch enforces this)
|
||||
6. **Fire-and-Forget**: RPs should log failures but not block on errors
|
||||
|
||||
## Comparison with Other Logout Methods
|
||||
|
||||
| Method | Communication | When Sessions Terminate | Reliability |
|
||||
|--------|--------------|------------------------|-------------|
|
||||
| **Backchannel Logout** | Server-to-server POST | Immediately | High (retries on failure) |
|
||||
| **Front-Channel Logout** | Browser iframes | When browser loads iframes | Low (blocked by privacy settings) |
|
||||
| **RP-Initiated Logout** | User redirects to Clinch | Only affects Clinch session | N/A (just triggers other methods) |
|
||||
| **Token Expiry** | None | When access token expires | Guaranteed but delayed |
|
||||
|
||||
## References
|
||||
|
||||
- [OpenID Connect Back-Channel Logout 1.0](https://openid.net/specs/openid-connect-backchannel-1_0.html)
|
||||
- [RFC 7009: OAuth 2.0 Token Revocation](https://tools.ietf.org/html/rfc7009)
|
||||
- [Clinch OIDC Discovery](/.well-known/openid-configuration)
|
||||
268
docs/beta-checklist.md
Normal file
268
docs/beta-checklist.md
Normal file
@@ -0,0 +1,268 @@
|
||||
# Beta Release Readiness Checklist
|
||||
|
||||
This checklist ensures Clinch meets security, quality, and documentation standards before moving from "experimental" to "Beta" status.
|
||||
|
||||
> **Security Implementation Status:** See [security-todo.md](security-todo.md) for detailed vulnerability tracking and fixes.
|
||||
> **Outstanding Security Issues:** 3 (all MEDIUM/LOW priority) - Phases 1-4 complete ✅
|
||||
|
||||
---
|
||||
|
||||
## Security Scanning
|
||||
|
||||
### Automated Security Tools
|
||||
- [x] **Brakeman** - Static security analysis for Rails
|
||||
- Status: ✅ Passing (2 weak warnings documented and accepted)
|
||||
- Command: `bin/brakeman --no-pager`
|
||||
- CI: Runs on every PR and push to main
|
||||
- Warnings documented in `config/brakeman.ignore`
|
||||
|
||||
- [x] **bundler-audit** - Dependency vulnerability scanning
|
||||
- Status: ✅ No vulnerabilities found
|
||||
- Command: `bin/bundler-audit check --update`
|
||||
- CI: Runs on every PR and push to main
|
||||
|
||||
- [x] **importmap audit** - JavaScript dependency scanning
|
||||
- CI: Runs on every PR and push to main
|
||||
|
||||
- [x] **Test Coverage** - SimpleCov integration
|
||||
- Command: `COVERAGE=1 bin/rails test`
|
||||
- Coverage report: `coverage/index.html`
|
||||
|
||||
### Security Features Implemented
|
||||
|
||||
#### Authentication
|
||||
- [x] Secure password storage (bcrypt with Rails defaults)
|
||||
- [x] TOTP 2FA with backup codes
|
||||
- [x] WebAuthn/Passkey support (FIDO2)
|
||||
- [x] Session management with device tracking
|
||||
- [x] Session revocation (individual and bulk)
|
||||
- [x] Remember me with configurable expiry
|
||||
- [x] Account invitation flow with expiring tokens
|
||||
- [x] Password reset with expiring tokens
|
||||
|
||||
#### OIDC Security
|
||||
- [x] Authorization code flow with PKCE support
|
||||
- [x] Refresh token rotation
|
||||
- [x] Token family tracking (detects replay attacks)
|
||||
- [x] All tokens HMAC-SHA256 hashed in database
|
||||
- [x] Configurable token expiry (access, refresh, ID)
|
||||
- [x] One-time use authorization codes
|
||||
- [x] Pairwise subject identifiers (privacy)
|
||||
- [x] ID tokens signed with RS256
|
||||
- [x] Token revocation endpoint (RFC 7009)
|
||||
- [x] Proper `at_hash` validation
|
||||
- [x] OIDC standard claims (auth_time, acr, azp)
|
||||
- [x] Automatic cleanup of expired tokens
|
||||
|
||||
#### Access Control
|
||||
- [x] Group-based authorization
|
||||
- [x] Application-level access control
|
||||
- [x] Admin vs. regular user roles
|
||||
- [x] User status management (active, disabled, pending)
|
||||
- [x] TOTP enforcement per-user
|
||||
- [x] ForwardAuth policy enforcement
|
||||
|
||||
#### Input Validation
|
||||
- [x] Strong parameter filtering
|
||||
- [x] URL validation for redirect URIs and landing URLs
|
||||
- [x] Email validation and normalization
|
||||
- [x] Slug validation (alphanumeric + hyphens)
|
||||
- [x] Domain pattern validation for ForwardAuth
|
||||
- [x] JSON parsing with error handling
|
||||
- [x] File upload validation (type, size for app icons)
|
||||
|
||||
#### Output Encoding
|
||||
- [x] HTML escaping by default (Rails 8)
|
||||
- [x] JSON encoding for API responses
|
||||
- [x] JWT encoding for ID tokens
|
||||
- [x] Proper content types for responses
|
||||
|
||||
#### Session Security
|
||||
- [x] Secure, httponly cookies
|
||||
- [x] SameSite cookie attribute
|
||||
- [x] Session timeout
|
||||
- [x] IP and User-Agent tracking
|
||||
- [x] CSRF protection
|
||||
|
||||
#### Cryptography
|
||||
- [x] SecureRandom for tokens
|
||||
- [x] bcrypt for passwords
|
||||
- [x] HMAC-SHA256 for token hashing
|
||||
- [x] RS256 for JWT signing
|
||||
- [x] Proper secret management (Rails credentials)
|
||||
|
||||
## Testing
|
||||
|
||||
### Test Coverage
|
||||
- [x] **341 tests** across integration, model, controller, service, and system tests
|
||||
- [x] **1349 assertions**
|
||||
- [x] **0 failures, 0 errors**
|
||||
|
||||
### Test Categories
|
||||
- [x] Integration tests (invitation flow, forward auth, WebAuthn, session security)
|
||||
- [x] Model tests (OIDC tokens, users, applications, groups, authorization codes)
|
||||
- [x] Controller tests (TOTP, sessions, passwords, OIDC flows, input validation)
|
||||
- [x] Service tests (JWT generation and validation)
|
||||
- [x] System tests (forward auth, WebAuthn security)
|
||||
|
||||
### Security-Critical Test Coverage
|
||||
- [x] OIDC authorization code flow
|
||||
- [x] PKCE flow
|
||||
- [x] Refresh token rotation
|
||||
- [x] Token replay attack detection
|
||||
- [x] Access control (group-based)
|
||||
- [x] Input validation
|
||||
- [x] Session security
|
||||
- [x] WebAuthn credential handling
|
||||
- [x] TOTP validation
|
||||
|
||||
## Code Quality
|
||||
|
||||
- [x] **RuboCop** - Code style and linting
|
||||
- Configuration: Rails Omakase
|
||||
- CI: Runs on every PR and push to main
|
||||
|
||||
- [x] **Documentation** - Comprehensive README
|
||||
- Feature documentation
|
||||
- Setup instructions
|
||||
- Configuration guide
|
||||
- Rails console guide
|
||||
- API/protocol documentation
|
||||
|
||||
## Production Readiness
|
||||
|
||||
### Configuration
|
||||
- [ ] Review all environment variables
|
||||
- [ ] Document required vs. optional configuration
|
||||
- [ ] Provide sensible defaults
|
||||
- [ ] Validate production SMTP configuration
|
||||
- [ ] Ensure OIDC private key generation process is documented
|
||||
|
||||
### Database
|
||||
- [x] Migrations are idempotent
|
||||
- [x] Indexes on foreign keys
|
||||
- [x] Proper constraints and validations
|
||||
- [x] SQLite production-ready (Rails 8)
|
||||
|
||||
### Performance
|
||||
- [ ] Review N+1 queries
|
||||
- [ ] Add database indexes where needed
|
||||
- [ ] Test with realistic data volumes
|
||||
- [ ] Review token cleanup job performance
|
||||
|
||||
### Deployment
|
||||
- [x] Docker support
|
||||
- [x] Docker Compose example
|
||||
- [ ] Production deployment guide
|
||||
- [ ] Backup and restore documentation
|
||||
- [ ] Migration strategy documentation
|
||||
|
||||
## Security Hardening
|
||||
|
||||
### Headers & CSP
|
||||
- [ ] Review Content Security Policy
|
||||
- [ ] HSTS configuration
|
||||
- [ ] X-Frame-Options
|
||||
- [ ] X-Content-Type-Options
|
||||
- [ ] Referrer-Policy
|
||||
|
||||
### Rate Limiting
|
||||
- [ ] Login attempt rate limiting
|
||||
- [ ] API endpoint rate limiting
|
||||
- [ ] Token endpoint rate limiting
|
||||
- [ ] Password reset rate limiting
|
||||
|
||||
### Secrets Management
|
||||
- [x] No secrets in code
|
||||
- [x] Rails credentials for sensitive data
|
||||
- [ ] Document secret rotation process
|
||||
- [ ] Document OIDC key rotation process
|
||||
|
||||
### Logging & Monitoring
|
||||
- [x] Sentry integration (optional)
|
||||
- [ ] Document what should be logged
|
||||
- [ ] Document what should NOT be logged (tokens, passwords)
|
||||
- [ ] Audit log for admin actions
|
||||
|
||||
## Known Limitations & Risks
|
||||
|
||||
### Documented Risks
|
||||
- [ ] Document that ForwardAuth requires same-domain setup
|
||||
- [ ] Document HTTPS requirement for production
|
||||
- [ ] Document backup code security (single-use, store securely)
|
||||
- [ ] Document admin password security requirements
|
||||
|
||||
### Future Security Enhancements
|
||||
- [ ] Rate limiting on authentication endpoints
|
||||
- [ ] Account lockout after N failed attempts
|
||||
- [ ] Admin audit logging
|
||||
- [ ] Security event notifications
|
||||
- [ ] Brute force detection
|
||||
- [ ] Suspicious login detection
|
||||
- [ ] IP allowlist/blocklist
|
||||
|
||||
## External Security Review
|
||||
|
||||
- [ ] Consider bug bounty or security audit
|
||||
- [ ] Penetration testing for OIDC flows
|
||||
- [ ] WebAuthn implementation review
|
||||
- [ ] Token security review
|
||||
|
||||
## Documentation for Users
|
||||
|
||||
- [ ] Security best practices guide
|
||||
- [ ] Incident response guide
|
||||
- [ ] Backup and disaster recovery guide
|
||||
- [ ] Upgrade guide
|
||||
- [ ] Breaking change policy
|
||||
|
||||
## Beta Release Criteria
|
||||
|
||||
To move from "experimental" to "Beta", the following must be completed:
|
||||
|
||||
**Critical (Required for Beta):**
|
||||
- [x] All automated security scans passing
|
||||
- [x] All tests passing
|
||||
- [x] Core features implemented and tested
|
||||
- [x] Basic documentation complete
|
||||
- [ ] At least one external security review or penetration test
|
||||
- [ ] Production deployment guide
|
||||
- [ ] Backup/restore documentation
|
||||
|
||||
**Important (Should have for Beta):**
|
||||
- [ ] Rate limiting on auth endpoints
|
||||
- [ ] Security headers configuration documented
|
||||
- [ ] Admin audit logging
|
||||
- [ ] Known limitations documented
|
||||
|
||||
**Nice to have (Can defer to post-Beta):**
|
||||
- [ ] Bug bounty program
|
||||
- [ ] Advanced monitoring/alerting
|
||||
- [ ] Automated security testing in CI beyond brakeman/bundler-audit
|
||||
|
||||
## Status Summary
|
||||
|
||||
**Current Status:** Pre-Beta / Experimental
|
||||
|
||||
**Strengths:**
|
||||
- ✅ Comprehensive security tooling in place
|
||||
- ✅ Strong test coverage (341 tests, 1349 assertions)
|
||||
- ✅ Modern security features (PKCE, token rotation, WebAuthn)
|
||||
- ✅ Clean security scans (brakeman, bundler-audit)
|
||||
- ✅ Well-documented codebase
|
||||
|
||||
**Before Beta Release:**
|
||||
- 🔶 External security review recommended
|
||||
- 🔶 Rate limiting implementation needed
|
||||
- 🔶 Production deployment documentation
|
||||
- 🔶 Security hardening checklist completion
|
||||
|
||||
**Recommendation:** Consider Beta status after:
|
||||
1. External security review or penetration testing
|
||||
2. Rate limiting implementation
|
||||
3. Production hardening documentation
|
||||
4. 1-2 months of real-world testing
|
||||
|
||||
---
|
||||
|
||||
Last updated: 2026-01-01
|
||||
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
|
||||
```
|
||||
315
docs/claude-review.md
Normal file
315
docs/claude-review.md
Normal file
@@ -0,0 +1,315 @@
|
||||
# Clinch - Independent Code Review
|
||||
|
||||
**Reviewer:** Claude (Anthropic)
|
||||
**Review Date:** December 2024
|
||||
**Codebase Version:** Commit 4f31fad
|
||||
**Review Type:** Security-focused OIDC/OAuth2 correctness review with full application assessment
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Clinch is a self-hosted identity and SSO portal built with Ruby on Rails. This review examined the complete codebase with particular focus on the OIDC/OAuth2 implementation, comparing it against production-grade reference implementations (Rodauth-OAuth, Authelia, Authentik).
|
||||
|
||||
**Overall Assessment: Production-Ready**
|
||||
|
||||
The implementation demonstrates solid security practices, proper adherence to OAuth 2.0 and OpenID Connect specifications, and comprehensive test coverage. The codebase is well-structured, readable, and maintainable.
|
||||
|
||||
---
|
||||
|
||||
## Feature Overview
|
||||
|
||||
### Authentication Methods
|
||||
| Feature | Status | Notes |
|
||||
|---------|--------|-------|
|
||||
| Password Authentication | Implemented | bcrypt hashing, rate-limited |
|
||||
| WebAuthn/Passkeys | Implemented | FIDO2 compliant, clone detection |
|
||||
| TOTP 2FA | Implemented | With backup codes, admin enforcement |
|
||||
| Session Management | Implemented | Device tracking, revocation |
|
||||
|
||||
### SSO Protocols
|
||||
| Protocol | Status | Notes |
|
||||
|----------|--------|-------|
|
||||
| OpenID Connect | Implemented | Full OIDC Core compliance |
|
||||
| OAuth 2.0 | Implemented | Authorization Code + Refresh Token grants |
|
||||
| ForwardAuth | Implemented | Traefik/Caddy/nginx compatible |
|
||||
|
||||
### User & Access Management
|
||||
| Feature | Status | Notes |
|
||||
|---------|--------|-------|
|
||||
| User CRUD | Implemented | Invitation flow, status management |
|
||||
| Group Management | Implemented | With custom claims |
|
||||
| Application Management | Implemented | OIDC + ForwardAuth types |
|
||||
| Group-based Access Control | Implemented | Per-application restrictions |
|
||||
|
||||
---
|
||||
|
||||
## OIDC/OAuth2 Implementation Review
|
||||
|
||||
### Specification Compliance
|
||||
|
||||
| Specification | Status | Evidence |
|
||||
|---------------|--------|----------|
|
||||
| RFC 6749 (OAuth 2.0) | Compliant | Proper auth code flow, client authentication |
|
||||
| RFC 7636 (PKCE) | Compliant | S256 and plain methods, enforced for public clients |
|
||||
| RFC 7009 (Token Revocation) | Compliant | Always returns 200 OK, prevents scanning |
|
||||
| OpenID Connect Core 1.0 | Compliant | All required claims, proper JWT structure |
|
||||
| OIDC Discovery | Compliant | `.well-known/openid-configuration` |
|
||||
| OIDC Back-Channel Logout | Compliant | Logout tokens per spec |
|
||||
|
||||
### ID Token Claims
|
||||
|
||||
The implementation includes all required and recommended OIDC claims:
|
||||
|
||||
```
|
||||
Standard: iss, sub, aud, exp, iat, nonce
|
||||
Profile: email, email_verified, preferred_username, name
|
||||
Security: at_hash, auth_time, acr, azp
|
||||
Custom: groups, plus arbitrary claims from groups/users/apps
|
||||
```
|
||||
|
||||
### Token Security
|
||||
|
||||
| Aspect | Implementation | Assessment |
|
||||
|--------|----------------|------------|
|
||||
| Authorization Codes | HMAC-SHA256 hashed, 10-min expiry, single-use | Secure |
|
||||
| Access Tokens | HMAC-SHA256 hashed, configurable TTL, indexed lookup | Secure |
|
||||
| Refresh Tokens | HMAC-SHA256 hashed, rotation with family tracking | Secure |
|
||||
| ID Tokens | RS256 signed JWTs | Secure |
|
||||
|
||||
### Security Features
|
||||
|
||||
1. **Authorization Code Reuse Prevention**
|
||||
- Pessimistic database locking prevents race conditions
|
||||
- Code reuse triggers revocation of all tokens from that code
|
||||
- Location: `oidc_controller.rb:342-364`
|
||||
|
||||
2. **Refresh Token Rotation**
|
||||
- Old refresh tokens revoked on use
|
||||
- Token family tracking detects stolen token reuse
|
||||
- Revoked token reuse triggers family-wide revocation
|
||||
- Location: `oidc_controller.rb:504-513`
|
||||
|
||||
3. **PKCE Enforcement**
|
||||
- Required for all public clients
|
||||
- Configurable for confidential clients
|
||||
- Proper S256 challenge verification
|
||||
- Location: `oidc_controller.rb:749-814`
|
||||
|
||||
4. **Pairwise Subject Identifiers**
|
||||
- Each user gets a unique `sub` per application
|
||||
- Prevents cross-application user tracking
|
||||
- Location: `oidc_user_consent.rb:59-61`
|
||||
|
||||
---
|
||||
|
||||
## Security Assessment
|
||||
|
||||
### Strengths
|
||||
|
||||
1. **Token Storage Architecture**
|
||||
- All tokens (auth codes, access, refresh) are HMAC-hashed before storage
|
||||
- Database compromise does not reveal usable tokens
|
||||
- O(1) indexed lookup via HMAC (not O(n) iteration)
|
||||
|
||||
2. **Rate Limiting**
|
||||
- Sign-in: 20/3min
|
||||
- TOTP verification: 10/3min
|
||||
- Token endpoint: 60/min
|
||||
- Authorization: 30/min
|
||||
- WebAuthn enumeration check: 10/min
|
||||
|
||||
3. **WebAuthn Implementation**
|
||||
- Sign count validation (clone detection)
|
||||
- Backup eligibility tracking
|
||||
- Platform vs roaming authenticator distinction
|
||||
- Credential enumeration prevention
|
||||
|
||||
4. **TOTP Implementation**
|
||||
- Encrypted secret storage (ActiveRecord Encryption)
|
||||
- Backup codes are bcrypt-hashed and single-use
|
||||
- Admin can enforce TOTP requirement per user
|
||||
|
||||
5. **Session Security**
|
||||
- ACR (Authentication Context Class Reference) tracking
|
||||
- `acr: "1"` for password-only, `acr: "2"` for 2FA/passkey
|
||||
- Session activity timestamps
|
||||
- Remote session revocation
|
||||
|
||||
### Attack Mitigations
|
||||
|
||||
| Attack Vector | Mitigation |
|
||||
|---------------|------------|
|
||||
| Credential Stuffing | Rate limiting, account lockout via status |
|
||||
| Token Theft | HMAC storage, short-lived access tokens, rotation |
|
||||
| Session Hijacking | Secure cookies, session binding |
|
||||
| CSRF | Rails CSRF protection, state parameter validation |
|
||||
| Open Redirect | Strict redirect_uri validation against registered URIs |
|
||||
| Authorization Code Injection | PKCE enforcement, redirect_uri binding |
|
||||
| Refresh Token Replay | Token rotation, family-based revocation |
|
||||
| User Enumeration | Constant-time responses, rate limiting |
|
||||
|
||||
### Areas Reviewed (No Issues Found)
|
||||
|
||||
- Redirect URI validation (exact match required)
|
||||
- Client authentication (bcrypt for secrets)
|
||||
- Error response handling (no sensitive data leakage in production)
|
||||
- Logout implementation (backchannel notifications, session cleanup)
|
||||
- Custom claims handling (reserved claim protection)
|
||||
|
||||
---
|
||||
|
||||
## Code Quality Assessment
|
||||
|
||||
### Architecture
|
||||
|
||||
| Aspect | Assessment |
|
||||
|--------|------------|
|
||||
| Controller Structure | Clean separation, ~900 lines for OIDC (acceptable) |
|
||||
| Model Design | Well-normalized, proper associations |
|
||||
| Service Objects | Used appropriately (OidcJwtService, ClaimsMerger) |
|
||||
| Concerns | TokenPrefixable for shared hashing logic |
|
||||
|
||||
### Code Metrics
|
||||
|
||||
```
|
||||
Controllers: ~1,500 lines
|
||||
Models: ~1,500 lines
|
||||
Services: ~200 lines
|
||||
Total App Code: ~3,100 lines
|
||||
Test Files: 36 files
|
||||
```
|
||||
|
||||
### Readability
|
||||
|
||||
- Clear method naming
|
||||
- Inline documentation for complex logic
|
||||
- Consistent Ruby style
|
||||
- No deeply nested conditionals
|
||||
|
||||
---
|
||||
|
||||
## Test Coverage
|
||||
|
||||
### Test Statistics
|
||||
|
||||
```
|
||||
Total Tests: 341
|
||||
Assertions: 1,349
|
||||
Failures: 0
|
||||
Errors: 0
|
||||
Run Time: 23.5 seconds (parallel)
|
||||
```
|
||||
|
||||
### Test Categories
|
||||
|
||||
| Category | Files | Coverage |
|
||||
|----------|-------|----------|
|
||||
| OIDC Security | 2 | Auth code reuse, token rotation, PKCE |
|
||||
| Integration | 4 | WebAuthn, sessions, invitations, forward auth |
|
||||
| Controllers | 8 | All major endpoints |
|
||||
| Models | 10 | Validations, associations, business logic |
|
||||
| Jobs | 4 | Mailers, token cleanup |
|
||||
|
||||
### Security-Specific Tests
|
||||
|
||||
The test suite includes dedicated security tests:
|
||||
- `oidc_authorization_code_security_test.rb` - Code reuse, timing attacks, client auth
|
||||
- `oidc_pkce_controller_test.rb` - PKCE flow validation
|
||||
- `webauthn_credential_enumeration_test.rb` - Enumeration prevention
|
||||
- `session_security_test.rb` - Session handling
|
||||
- `totp_security_test.rb` - 2FA bypass prevention
|
||||
- `input_validation_test.rb` - Input sanitization
|
||||
|
||||
---
|
||||
|
||||
## Comparison with Reference Implementations
|
||||
|
||||
### vs. Rodauth-OAuth (OpenID Certified)
|
||||
|
||||
| Aspect | Rodauth | Clinch |
|
||||
|--------|---------|--------|
|
||||
| Modularity | 46 feature modules | Monolithic controller |
|
||||
| Token Storage | Optional hashing | HMAC-SHA256 (always) |
|
||||
| PKCE | Dedicated feature | Integrated |
|
||||
| Certification | OpenID Certified | Not certified |
|
||||
|
||||
Clinch has comparable security but less modularity.
|
||||
|
||||
### vs. Authelia (Production-Grade Go)
|
||||
|
||||
| Aspect | Authelia | Clinch |
|
||||
|--------|----------|--------|
|
||||
| PKCE Config | `always/public/never` | Per-app toggle |
|
||||
| Key Rotation | Supported | Single key |
|
||||
| PAR Support | Yes | No |
|
||||
| DPoP Support | Yes | No |
|
||||
|
||||
Clinch lacks some advanced features but covers core use cases.
|
||||
|
||||
### vs. Authentik (Enterprise Python)
|
||||
|
||||
| Aspect | Authentik | Clinch |
|
||||
|--------|-----------|--------|
|
||||
| Scale | Enterprise/distributed | Single instance |
|
||||
| Protocols | OAuth, SAML, LDAP, RADIUS | OAuth/OIDC, ForwardAuth |
|
||||
| Complexity | High | Low |
|
||||
|
||||
Clinch is intentionally simpler for self-hosting.
|
||||
|
||||
---
|
||||
|
||||
## Recommendations
|
||||
|
||||
### Implemented During Review
|
||||
|
||||
The following issues were identified and fixed during this review:
|
||||
|
||||
1. **Token lookup performance** - Changed from O(n) BCrypt iteration to O(1) HMAC lookup
|
||||
2. **`at_hash` claim** - Added per OIDC Core spec
|
||||
3. **`auth_time` claim** - Added for authentication timestamp
|
||||
4. **`acr` claim** - Added for authentication context class
|
||||
5. **`azp` claim** - Added for authorized party
|
||||
6. **Authorization code hashing** - Changed from plaintext to HMAC
|
||||
7. **Consent SID preservation** - Fixed to preserve pairwise subject ID
|
||||
8. **Discovery metadata** - Fixed `subject_types_supported` to `["pairwise"]`
|
||||
|
||||
### Optional Future Enhancements
|
||||
|
||||
| Enhancement | Priority | Effort |
|
||||
|-------------|----------|--------|
|
||||
| Key Rotation (multi-key JWKS) | Medium | Medium |
|
||||
| Token Introspection (RFC 7662) | Low | Low |
|
||||
| PAR (RFC 9126) | Low | Medium |
|
||||
| OpenID Certification | Low | High |
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
Clinch provides a solid, security-conscious OIDC/OAuth2 implementation suitable for self-hosted identity management. The codebase demonstrates:
|
||||
|
||||
- **Correct protocol implementation** - Follows OAuth 2.0 and OIDC specifications
|
||||
- **Defense in depth** - Multiple layers of security controls
|
||||
- **Modern authentication** - WebAuthn/passkeys, TOTP, proper session management
|
||||
- **Maintainable code** - Clear structure, good test coverage
|
||||
|
||||
The implementation is appropriate for its intended use case: a lightweight, self-hosted alternative to complex enterprise identity solutions.
|
||||
|
||||
---
|
||||
|
||||
## Methodology
|
||||
|
||||
This review was conducted by examining:
|
||||
|
||||
1. All OIDC-related controllers, models, and services
|
||||
2. Reference implementations (Rodauth-OAuth, Authelia, Authentik) in `tmp/`
|
||||
3. Test files and coverage
|
||||
4. Database schema and migrations
|
||||
5. Security-critical code paths
|
||||
|
||||
Tools used: Static analysis, code reading, test execution, comparison with OpenID-certified implementations.
|
||||
|
||||
---
|
||||
|
||||
*This review was conducted by Claude (Anthropic) at the request of the project maintainer. The reviewer has no financial interest in the project.*
|
||||
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.
|
||||
154
docs/security-todo.md
Normal file
154
docs/security-todo.md
Normal file
@@ -0,0 +1,154 @@
|
||||
# Security Status
|
||||
|
||||
**Last Audit:** 2025-12-31
|
||||
**Target Users:** Self-hosters, small businesses
|
||||
|
||||
> **Beta Release Criteria:** See [beta-checklist.md](beta-checklist.md) for overall release readiness assessment.
|
||||
>
|
||||
> This document demonstrates our proactive approach to security through systematic vulnerability tracking and remediation.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Phase | Status | Description |
|
||||
|-------|--------|-------------|
|
||||
| Phase 1-2 | ✅ Complete | Rate limiting, security headers, tests |
|
||||
| Phase 3 | ✅ Complete | Critical fixes (token DoS, plaintext storage, fail-open) |
|
||||
| Phase 4 | ✅ Complete | High priority (PKCE, WebAuthn, email re-auth, TOTP encryption) |
|
||||
| Phase 5 | 🟡 In Progress | Security enhancements |
|
||||
| Phase 6 | ⏳ Optional | Hardening & documentation |
|
||||
|
||||
---
|
||||
|
||||
## Outstanding Security Issues
|
||||
|
||||
---
|
||||
|
||||
### MEDIUM - Account Lockout Mechanism
|
||||
|
||||
**Files:** `app/controllers/sessions_controller.rb`, `app/models/user.rb`
|
||||
**Impact:** Brute force attack mitigation
|
||||
|
||||
**Implementation:**
|
||||
- Add `failed_login_attempts` and `locked_until` columns to users
|
||||
- Progressive delays: 5 attempts → 5s, 10 → 1min, 15 → 15min, 20+ → 1hr
|
||||
- Admin notification on lockout
|
||||
- Configurable via `MAX_LOGIN_ATTEMPTS` ENV
|
||||
|
||||
---
|
||||
|
||||
### MEDIUM - Per-Account Rate Limiting
|
||||
|
||||
**Files:** `app/controllers/sessions_controller.rb`, `config/initializers/rack_attack.rb`
|
||||
**Impact:** Distributed brute force prevention
|
||||
|
||||
**Current:** Global rate limiting only
|
||||
**Needed:** Add per-email rate limiting (10 failed attempts/email/hour)
|
||||
|
||||
---
|
||||
|
||||
### LOW - WebAuthn Clone Detection Action
|
||||
|
||||
**File:** `app/controllers/sessions_controller.rb:252-256`
|
||||
**Impact:** Cloned credential detection
|
||||
|
||||
**Current:** Logs warning on suspicious sign count
|
||||
**Improvement:** Block authentication, notify user/admin
|
||||
|
||||
---
|
||||
|
||||
## Configuration Choices (Not Vulnerabilities)
|
||||
|
||||
These are policy decisions for self-hosters, not security bugs:
|
||||
|
||||
| Item | Default | Notes |
|
||||
|------|---------|-------|
|
||||
| Session cookie domain | Root domain | Enables SSO across subdomains. Add `SECURE_SUBDOMAIN_ISOLATION` ENV to disable |
|
||||
| CSP policy | unsafe-inline, unsafe-eval | Required for Stimulus/Turbo. Audit JS to remove if needed |
|
||||
| Logout redirect validation | Allows query params | Per OAuth 2.0 spec. Document behavior |
|
||||
| Invitation token lifetime | 24 hours | Add `INVITATION_TOKEN_LIFETIME` ENV for high-security deployments |
|
||||
| Password minimum length | 8 chars | Add `PASSWORD_MIN_LENGTH` ENV, consider zxcvbn |
|
||||
| Admin self-demotion check | String comparison | Minor - use `.to_i` for integer comparison |
|
||||
|
||||
---
|
||||
|
||||
## Completed Fixes
|
||||
|
||||
### Phase 3 - Critical (December 2025)
|
||||
|
||||
**1. Token Lookup DoS** ✅
|
||||
- Problem: O(n) BCrypt comparisons on token lookup
|
||||
- Solution: HMAC-based token prefix for O(1) indexed lookup
|
||||
- Files: `token_prefixable.rb`, token models, migration
|
||||
|
||||
**2. Plaintext Token Storage** ✅
|
||||
- Problem: Access tokens stored in plaintext
|
||||
- Solution: Removed `token` column, use BCrypt digest only
|
||||
- Files: Migration, fixtures, tests
|
||||
|
||||
**3. Forward Auth Fail-Open** ✅
|
||||
- Problem: Unmatched domains allowed by default
|
||||
- Solution: Changed to fail-closed (403 for unconfigured domains)
|
||||
- Files: `forward_auth_controller.rb`
|
||||
|
||||
---
|
||||
|
||||
### Phase 4 - High Priority (December 2025)
|
||||
|
||||
**4. PKCE Enforcement** ✅
|
||||
- Problem: PKCE was optional
|
||||
- Solution: Per-app PKCE with mandatory enforcement for public clients
|
||||
- Files: Application model, OIDC controller, migration
|
||||
|
||||
**5. WebAuthn Info Disclosure** ✅
|
||||
- Problem: `/webauthn/check` leaked user_id and preferred_method
|
||||
- Solution: Minimal response, rate limiting (10/min), identical responses for non-existent users
|
||||
- Files: `webauthn_controller.rb`
|
||||
|
||||
**6. OIDC State URL Encoding** ✅
|
||||
- Problem: State parameter not consistently URL-encoded
|
||||
- Solution: `CGI.escape()` on all redirect URLs
|
||||
- Files: `oidc_controller.rb` (4 locations)
|
||||
|
||||
**7. Email Change Re-authentication** ✅
|
||||
- Problem: Email could be changed without password
|
||||
- Solution: Require current password for email changes
|
||||
- Files: `profiles_controller.rb`, view
|
||||
|
||||
**12. TOTP Secret Encryption** ✅
|
||||
- Problem: TOTP secrets stored in plaintext
|
||||
- Solution: Rails `encrypts` with keys derived from SECRET_KEY_BASE
|
||||
- Files: `user.rb`, `active_record_encryption.rb`
|
||||
|
||||
**13. WebAuthn Credential ID Enumeration** ✅
|
||||
- Problem: Global credential lookup allowed enumeration via 404 vs 403 responses
|
||||
- Solution: Scoped credential lookup to current user, identical responses
|
||||
- Files: `webauthn_controller.rb`, `webauthn_credential_enumeration_test.rb`
|
||||
|
||||
---
|
||||
|
||||
## Security Strengths
|
||||
|
||||
- **Token security:** HMAC prefix + BCrypt, no plaintext storage
|
||||
- **Authorization codes:** Pessimistic locking, single-use enforcement
|
||||
- **Refresh tokens:** Family tracking for rotation attack detection
|
||||
- **Reserved claims:** Validation prevents claim override attacks
|
||||
- **Rate limiting:** Applied on all authentication endpoints
|
||||
- **Forward auth:** Fail-closed by default
|
||||
- **TOTP:** AES-256-GCM encryption at rest
|
||||
- **Email changes:** Require password re-authentication
|
||||
- **Credential isolation:** Scoped lookups prevent enumeration attacks
|
||||
|
||||
---
|
||||
|
||||
## Audit History
|
||||
|
||||
| Date | Event |
|
||||
|------|-------|
|
||||
| 2025-12-31 | Credential ID enumeration fix (scoped lookups) |
|
||||
| 2025-12-31 | Security review - 1 new issue found (credential enumeration) |
|
||||
| 2025-12-31 | Phase 4 completed (PKCE, WebAuthn, email re-auth, TOTP) |
|
||||
| 2025-12-30 | Phase 3 completed (token DoS, plaintext storage, fail-open) |
|
||||
| 2025-12-30 | Comprehensive security audit - 18 issues identified |
|
||||
| Earlier | Phase 1-2 completed (rate limiting, headers, tests) |
|
||||
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*
|
||||
31
lib/tasks/security.rake
Normal file
31
lib/tasks/security.rake
Normal file
@@ -0,0 +1,31 @@
|
||||
namespace :security do
|
||||
desc "Run all security checks (brakeman + bundler-audit)"
|
||||
task all: :environment do
|
||||
Rake::Task["security:brakeman"].invoke
|
||||
Rake::Task["security:bundler_audit"].invoke
|
||||
end
|
||||
|
||||
desc "Run Brakeman static security scanner"
|
||||
task brakeman: :environment do
|
||||
puts "Running Brakeman security scanner..."
|
||||
system("bin/brakeman --no-pager") || abort("Brakeman found security issues!")
|
||||
end
|
||||
|
||||
desc "Run bundler-audit to check for vulnerable dependencies"
|
||||
task bundler_audit: :environment do
|
||||
puts "Running bundler-audit..."
|
||||
system("bin/bundler-audit check --update") || abort("bundler-audit found vulnerable dependencies!")
|
||||
end
|
||||
|
||||
desc "Generate code coverage report (requires tests to be run with COVERAGE=1)"
|
||||
task :coverage do
|
||||
puts "Running tests with coverage..."
|
||||
ENV["COVERAGE"] = "1"
|
||||
system("bin/rails test") || abort("Tests failed!")
|
||||
puts "\nCoverage report generated at coverage/index.html"
|
||||
end
|
||||
end
|
||||
|
||||
# Alias for convenience
|
||||
desc "Run all security checks"
|
||||
task security: "security:all"
|
||||
@@ -1,5 +1,5 @@
|
||||
require "test_helper"
|
||||
|
||||
class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
|
||||
driven_by :selenium, using: :headless_chrome, screen_size: [ 1400, 1400 ]
|
||||
driven_by :selenium, using: :headless_chrome, screen_size: [1400, 1400]
|
||||
end
|
||||
|
||||
@@ -5,58 +5,34 @@ module Api
|
||||
setup do
|
||||
@user = users(:bob)
|
||||
@admin_user = users(:alice)
|
||||
@inactive_user = users(:bob) # We'll create an inactive user in setup if needed
|
||||
@inactive_user = User.create!(email_address: "inactive@example.com", password: "password", status: :disabled)
|
||||
@group = groups(:admin_group)
|
||||
@rule = ForwardAuthRule.create!(domain_pattern: "test.example.com", active: true)
|
||||
@inactive_rule = ForwardAuthRule.create!(domain_pattern: "inactive.example.com", active: false)
|
||||
@rule = Application.create!(name: "Test App", slug: "test-app", app_type: "forward_auth", domain_pattern: "test.example.com", active: true)
|
||||
@inactive_rule = Application.create!(name: "Inactive App", slug: "inactive-app", app_type: "forward_auth", domain_pattern: "inactive.example.com", active: false)
|
||||
end
|
||||
|
||||
# Authentication Tests
|
||||
test "should redirect to login when no session cookie" do
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
||||
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
|
||||
|
||||
assert_response 302
|
||||
assert_match %r{/signin}, response.location
|
||||
assert_equal "No session cookie", response.headers["X-Auth-Reason"]
|
||||
end
|
||||
|
||||
test "should redirect when session cookie is invalid" do
|
||||
get "/api/verify", headers: {
|
||||
"X-Forwarded-Host" => "test.example.com",
|
||||
"Cookie" => "_clinch_session_id=invalid_session_id"
|
||||
}
|
||||
|
||||
assert_response 302
|
||||
assert_match %r{/signin}, response.location
|
||||
assert_equal "Invalid session", response.headers["X-Auth-Reason"]
|
||||
end
|
||||
|
||||
test "should redirect when session is expired" do
|
||||
expired_session = @user.sessions.create!(created_at: 1.year.ago)
|
||||
|
||||
get "/api/verify", headers: {
|
||||
"X-Forwarded-Host" => "test.example.com",
|
||||
"Cookie" => "_clinch_session_id=#{expired_session.id}"
|
||||
}
|
||||
|
||||
assert_response 302
|
||||
assert_match %r{/signin}, response.location
|
||||
assert_equal "Session expired", response.headers["X-Auth-Reason"]
|
||||
assert_equal "No session cookie", response.headers["x-auth-reason"]
|
||||
end
|
||||
|
||||
test "should redirect when user is inactive" do
|
||||
sign_in_as(@inactive_user)
|
||||
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
||||
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
|
||||
|
||||
assert_response 302
|
||||
assert_equal "User account is not active", response.headers["X-Auth-Reason"]
|
||||
assert_equal "User account is not active", response.headers["x-auth-reason"]
|
||||
end
|
||||
|
||||
test "should return 200 when user is authenticated" do
|
||||
sign_in_as(@user)
|
||||
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
||||
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
|
||||
|
||||
assert_response 200
|
||||
end
|
||||
@@ -65,38 +41,37 @@ module Api
|
||||
test "should return 200 when matching rule exists" do
|
||||
sign_in_as(@user)
|
||||
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
||||
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
|
||||
|
||||
assert_response 200
|
||||
end
|
||||
|
||||
test "should return 200 with default headers when no rule matches" do
|
||||
test "should return 403 when no rule matches (fail-closed security)" do
|
||||
sign_in_as(@user)
|
||||
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "unknown.example.com" }
|
||||
get "/api/verify", headers: {"X-Forwarded-Host" => "unknown.example.com"}
|
||||
|
||||
assert_response 200
|
||||
assert_equal @user.email_address, response.headers["X-Remote-User"]
|
||||
assert_equal @user.email_address, response.headers["X-Remote-Email"]
|
||||
assert_response 403
|
||||
assert_equal "No authentication rule configured for this domain", response.headers["x-auth-reason"]
|
||||
end
|
||||
|
||||
test "should return 403 when rule exists but is inactive" do
|
||||
sign_in_as(@user)
|
||||
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "inactive.example.com" }
|
||||
get "/api/verify", headers: {"X-Forwarded-Host" => "inactive.example.com"}
|
||||
|
||||
assert_response 403
|
||||
assert_equal "No authentication rule configured for this domain", response.headers["X-Auth-Reason"]
|
||||
assert_equal "No authentication rule configured for this domain", response.headers["x-auth-reason"]
|
||||
end
|
||||
|
||||
test "should return 403 when rule exists but user not in allowed groups" do
|
||||
@rule.allowed_groups << @group
|
||||
sign_in_as(@user) # User not in group
|
||||
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
||||
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
|
||||
|
||||
assert_response 403
|
||||
assert_match %r{permission to access this domain}, response.headers["X-Auth-Reason"]
|
||||
assert_match %r{permission to access this domain}, response.headers["x-auth-reason"]
|
||||
end
|
||||
|
||||
test "should return 200 when user is in allowed groups" do
|
||||
@@ -104,52 +79,57 @@ module Api
|
||||
@user.groups << @group
|
||||
sign_in_as(@user)
|
||||
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
||||
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
|
||||
|
||||
assert_response 200
|
||||
end
|
||||
|
||||
# Domain Pattern Tests
|
||||
test "should match wildcard domains correctly" do
|
||||
wildcard_rule = ForwardAuthRule.create!(domain_pattern: "*.example.com", active: true)
|
||||
Application.create!(name: "Wildcard App", slug: "wildcard-app", app_type: "forward_auth", domain_pattern: "*.example.com", active: true)
|
||||
sign_in_as(@user)
|
||||
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "app.example.com" }
|
||||
get "/api/verify", headers: {"X-Forwarded-Host" => "app.example.com"}
|
||||
assert_response 200
|
||||
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "api.example.com" }
|
||||
get "/api/verify", headers: {"X-Forwarded-Host" => "api.example.com"}
|
||||
assert_response 200
|
||||
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "other.com" }
|
||||
assert_response 200 # Falls back to default behavior
|
||||
get "/api/verify", headers: {"X-Forwarded-Host" => "other.com"}
|
||||
assert_response 403 # No rule configured - fail-closed
|
||||
assert_equal "No authentication rule configured for this domain", response.headers["x-auth-reason"]
|
||||
end
|
||||
|
||||
test "should match exact domains correctly" do
|
||||
exact_rule = ForwardAuthRule.create!(domain_pattern: "api.example.com", active: true)
|
||||
Application.create!(name: "Exact App", slug: "exact-app", app_type: "forward_auth", domain_pattern: "api.example.com", active: true)
|
||||
sign_in_as(@user)
|
||||
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "api.example.com" }
|
||||
get "/api/verify", headers: {"X-Forwarded-Host" => "api.example.com"}
|
||||
assert_response 200
|
||||
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "app.api.example.com" }
|
||||
assert_response 200 # Falls back to default behavior
|
||||
get "/api/verify", headers: {"X-Forwarded-Host" => "app.api.example.com"}
|
||||
assert_response 403 # No rule configured - fail-closed
|
||||
assert_equal "No authentication rule configured for this domain", response.headers["x-auth-reason"]
|
||||
end
|
||||
|
||||
# Header Configuration Tests
|
||||
test "should return default headers when rule has no custom config" do
|
||||
sign_in_as(@user)
|
||||
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
||||
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
|
||||
|
||||
assert_response 200
|
||||
assert_equal "X-Remote-User", response.headers.keys.find { |k| k.include?("User") }
|
||||
assert_equal "X-Remote-Email", response.headers.keys.find { |k| k.include?("Email") }
|
||||
assert_equal "X-Remote-Name", response.headers.keys.find { |k| k.include?("Name") }
|
||||
assert_equal @user.email_address, response.headers["X-Remote-User"]
|
||||
assert_equal @user.email_address, response.headers["x-remote-user"]
|
||||
assert_equal @user.email_address, response.headers["x-remote-email"]
|
||||
assert response.headers["x-remote-name"].present?
|
||||
assert_equal (@user.admin? ? "true" : "false"), response.headers["x-remote-admin"]
|
||||
end
|
||||
|
||||
test "should return custom headers when configured" do
|
||||
custom_rule = ForwardAuthRule.create!(
|
||||
Application.create!(
|
||||
name: "Custom App",
|
||||
slug: "custom-app",
|
||||
app_type: "forward_auth",
|
||||
domain_pattern: "custom.example.com",
|
||||
active: true,
|
||||
headers_config: {
|
||||
@@ -160,55 +140,65 @@ module Api
|
||||
)
|
||||
sign_in_as(@user)
|
||||
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "custom.example.com" }
|
||||
get "/api/verify", headers: {"X-Forwarded-Host" => "custom.example.com"}
|
||||
|
||||
assert_response 200
|
||||
assert_equal "X-WEBAUTH-USER", response.headers.keys.find { |k| k.include?("USER") }
|
||||
assert_equal "X-WEBAUTH-EMAIL", response.headers.keys.find { |k| k.include?("EMAIL") }
|
||||
assert_equal @user.email_address, response.headers["X-WEBAUTH-USER"]
|
||||
assert_equal @user.email_address, response.headers["x-webauth-user"]
|
||||
assert_equal @user.email_address, response.headers["x-webauth-email"]
|
||||
# Default headers should NOT be present
|
||||
assert_nil response.headers["x-remote-user"]
|
||||
assert_nil response.headers["x-remote-email"]
|
||||
end
|
||||
|
||||
test "should return no headers when all headers disabled" do
|
||||
no_headers_rule = ForwardAuthRule.create!(
|
||||
Application.create!(
|
||||
name: "No Headers App",
|
||||
slug: "no-headers-app",
|
||||
app_type: "forward_auth",
|
||||
domain_pattern: "noheaders.example.com",
|
||||
active: true,
|
||||
headers_config: { user: "", email: "", name: "", groups: "", admin: "" }
|
||||
headers_config: {user: "", email: "", name: "", groups: "", admin: ""}
|
||||
)
|
||||
sign_in_as(@user)
|
||||
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "noheaders.example.com" }
|
||||
get "/api/verify", headers: {"X-Forwarded-Host" => "noheaders.example.com"}
|
||||
|
||||
assert_response 200
|
||||
auth_headers = response.headers.select { |k, v| k.match?(/^(X-|Remote-)/i) }
|
||||
assert_empty auth_headers
|
||||
# Check that auth-specific headers are not present (exclude Rails security headers)
|
||||
auth_headers = response.headers.select { |k, v| k.match?(/^X-Remote-/i) || k.match?(/^X-WEBAUTH/i) }
|
||||
assert_empty auth_headers, "Should not have any auth headers when all are disabled"
|
||||
end
|
||||
|
||||
test "should include groups header when user has groups" do
|
||||
@user.groups << @group
|
||||
sign_in_as(@user)
|
||||
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
||||
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
|
||||
|
||||
assert_response 200
|
||||
assert_equal @group.name, response.headers["X-Remote-Groups"]
|
||||
groups_header = response.headers["x-remote-groups"]
|
||||
assert_includes groups_header, @group.name
|
||||
# Bob also has editor_group from fixtures
|
||||
assert_includes groups_header, "Editors"
|
||||
end
|
||||
|
||||
test "should not include groups header when user has no groups" do
|
||||
@user.groups.clear # Remove fixture groups
|
||||
sign_in_as(@user)
|
||||
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
||||
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
|
||||
|
||||
assert_response 200
|
||||
assert_nil response.headers["X-Remote-Groups"]
|
||||
assert_nil response.headers["x-remote-groups"]
|
||||
end
|
||||
|
||||
test "should include admin header correctly" do
|
||||
sign_in_as(@admin_user) # Assuming users(:two) is admin
|
||||
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
||||
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
|
||||
|
||||
assert_response 200
|
||||
assert_equal "true", response.headers["X-Remote-Admin"]
|
||||
assert_equal "true", response.headers["x-remote-admin"]
|
||||
end
|
||||
|
||||
test "should include multiple groups when user has multiple groups" do
|
||||
@@ -217,10 +207,10 @@ module Api
|
||||
@user.groups << group2
|
||||
sign_in_as(@user)
|
||||
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
||||
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
|
||||
|
||||
assert_response 200
|
||||
groups_header = response.headers["X-Remote-Groups"]
|
||||
groups_header = response.headers["x-remote-groups"]
|
||||
assert_includes groups_header, @group.name
|
||||
assert_includes groups_header, group2.name
|
||||
end
|
||||
@@ -229,7 +219,7 @@ module Api
|
||||
test "should fall back to Host header when X-Forwarded-Host is missing" do
|
||||
sign_in_as(@user)
|
||||
|
||||
get "/api/verify", headers: { "Host" => "test.example.com" }
|
||||
get "/api/verify", headers: {"Host" => "test.example.com"}
|
||||
|
||||
assert_response 200
|
||||
end
|
||||
@@ -239,99 +229,31 @@ module Api
|
||||
|
||||
get "/api/verify"
|
||||
|
||||
assert_response 200
|
||||
assert_equal "User #{@user.email_address} authenticated (no domain specified)",
|
||||
request.env["action_dispatch.instance"].instance_variable_get(:@logged_messages)&.last
|
||||
# User is authenticated but no domain rule matches (default test host)
|
||||
assert_response 403
|
||||
assert_equal "No authentication rule configured for this domain", response.headers["x-auth-reason"]
|
||||
end
|
||||
|
||||
# Security Tests
|
||||
test "should handle malformed session IDs gracefully" do
|
||||
get "/api/verify", headers: {
|
||||
"X-Forwarded-Host" => "test.example.com",
|
||||
"Cookie" => "_clinch_session_id=malformed_session_id_with_special_chars!@#$%"
|
||||
}
|
||||
|
||||
assert_response 302
|
||||
assert_equal "Invalid session", response.headers["X-Auth-Reason"]
|
||||
end
|
||||
|
||||
test "should handle very long domain names" do
|
||||
long_domain = "a" * 250 + ".example.com"
|
||||
sign_in_as(@user)
|
||||
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => long_domain }
|
||||
get "/api/verify", headers: {"X-Forwarded-Host" => long_domain}
|
||||
|
||||
assert_response 200 # Should fall back to default behavior
|
||||
assert_response 403 # No rule configured - fail-closed
|
||||
assert_equal "No authentication rule configured for this domain", response.headers["x-auth-reason"]
|
||||
end
|
||||
|
||||
test "should handle case insensitive domain matching" do
|
||||
sign_in_as(@user)
|
||||
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "TEST.Example.COM" }
|
||||
get "/api/verify", headers: {"X-Forwarded-Host" => "TEST.Example.COM"}
|
||||
|
||||
assert_response 200
|
||||
end
|
||||
|
||||
# Open Redirect Security Tests
|
||||
test "should redirect to malicious external domain when rd parameter is provided" do
|
||||
# This test demonstrates the current vulnerability
|
||||
evil_url = "https://evil-phishing-site.com/steal-credentials"
|
||||
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" },
|
||||
params: { rd: evil_url }
|
||||
|
||||
assert_response 302
|
||||
# Current vulnerable behavior: redirects to the evil URL
|
||||
assert_match evil_url, response.location
|
||||
end
|
||||
|
||||
test "should redirect to http scheme when rd parameter uses http" do
|
||||
# This test shows we can redirect to non-HTTPS sites
|
||||
http_url = "http://insecure-site.com/login"
|
||||
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" },
|
||||
params: { rd: http_url }
|
||||
|
||||
assert_response 302
|
||||
assert_match http_url, response.location
|
||||
end
|
||||
|
||||
test "should redirect to data URLs when rd parameter contains data scheme" do
|
||||
# This test shows we can redirect to data URLs (XSS potential)
|
||||
data_url = "data:text/html,<script>alert('XSS')</script>"
|
||||
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" },
|
||||
params: { rd: data_url }
|
||||
|
||||
assert_response 302
|
||||
# Currently redirects to data URL (XSS vulnerability)
|
||||
assert_match data_url, response.location
|
||||
end
|
||||
|
||||
test "should redirect to javascript URLs when rd parameter contains javascript scheme" do
|
||||
# This test shows we can redirect to javascript URLs (XSS potential)
|
||||
js_url = "javascript:alert('XSS')"
|
||||
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" },
|
||||
params: { rd: js_url }
|
||||
|
||||
assert_response 302
|
||||
# Currently redirects to JavaScript URL (XSS vulnerability)
|
||||
assert_match js_url, response.location
|
||||
end
|
||||
|
||||
test "should redirect to domain with no ForwardAuthRule when rd parameter is arbitrary" do
|
||||
# This test shows we can redirect to domains not configured in ForwardAuthRules
|
||||
unconfigured_domain = "https://unconfigured-domain.com/admin"
|
||||
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" },
|
||||
params: { rd: unconfigured_domain }
|
||||
|
||||
assert_response 302
|
||||
# Currently redirects to unconfigured domain
|
||||
assert_match unconfigured_domain, response.location
|
||||
end
|
||||
|
||||
# Open Redirect Security Tests - All tests verify SECURE behavior
|
||||
test "should reject malicious redirect URL through session after authentication (SECURE BEHAVIOR)" do
|
||||
# This test shows malicious URLs are filtered out through the auth flow
|
||||
evil_url = "https://evil-site.com/fake-login"
|
||||
@@ -340,7 +262,7 @@ module Api
|
||||
get "/api/verify", headers: {
|
||||
"X-Forwarded-Host" => "test.example.com",
|
||||
"X-Forwarded-Uri" => "/admin"
|
||||
}, params: { rd: evil_url }
|
||||
}, params: {rd: evil_url}
|
||||
|
||||
assert_response 302
|
||||
assert_match %r{/signin}, response.location
|
||||
@@ -364,45 +286,14 @@ module Api
|
||||
assert_match "test.example.com", response.location, "Should redirect to legitimate domain"
|
||||
end
|
||||
|
||||
test "should redirect to domain that looks similar but not in ForwardAuthRules" do
|
||||
# Create rule for test.example.com
|
||||
test_rule = ForwardAuthRule.create!(domain_pattern: "test.example.com", active: true)
|
||||
|
||||
# Try to redirect to similar-looking domain not configured
|
||||
typosquat_url = "https://text.example.com/admin" # Note: 'text' instead of 'test'
|
||||
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" },
|
||||
params: { rd: typosquat_url }
|
||||
|
||||
assert_response 302
|
||||
# Currently redirects to typosquat domain
|
||||
assert_match typosquat_url, response.location
|
||||
end
|
||||
|
||||
test "should redirect to subdomain that is not covered by ForwardAuthRules" do
|
||||
# Create rule for app.example.com
|
||||
app_rule = ForwardAuthRule.create!(domain_pattern: "app.example.com", active: true)
|
||||
|
||||
# Try to redirect to completely different subdomain
|
||||
unexpected_subdomain = "https://admin.example.com/panel"
|
||||
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "app.example.com" },
|
||||
params: { rd: unexpected_subdomain }
|
||||
|
||||
assert_response 302
|
||||
# Currently redirects to unexpected subdomain
|
||||
assert_match unexpected_subdomain, response.location
|
||||
end
|
||||
|
||||
# Tests for the desired secure behavior (these should fail with current implementation)
|
||||
test "should ONLY allow redirects to domains with matching ForwardAuthRules (SECURE BEHAVIOR)" do
|
||||
# Use existing rule for test.example.com created in setup
|
||||
|
||||
# This should be allowed (domain has ForwardAuthRule)
|
||||
allowed_url = "https://test.example.com/dashboard"
|
||||
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" },
|
||||
params: { rd: allowed_url }
|
||||
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"},
|
||||
params: {rd: allowed_url}
|
||||
|
||||
assert_response 302
|
||||
assert_match allowed_url, response.location
|
||||
@@ -414,8 +305,8 @@ module Api
|
||||
# This should be rejected (no ForwardAuthRule for evil-site.com)
|
||||
evil_url = "https://evil-site.com/steal-credentials"
|
||||
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" },
|
||||
params: { rd: evil_url }
|
||||
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"},
|
||||
params: {rd: evil_url}
|
||||
|
||||
assert_response 302
|
||||
# Should redirect to login page or default URL, NOT to evil_url
|
||||
@@ -429,8 +320,8 @@ module Api
|
||||
# This should be rejected (HTTP not HTTPS)
|
||||
http_url = "http://test.example.com/dashboard"
|
||||
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" },
|
||||
params: { rd: http_url }
|
||||
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"},
|
||||
params: {rd: http_url}
|
||||
|
||||
assert_response 302
|
||||
# Should redirect to login page or default URL, NOT to HTTP URL
|
||||
@@ -449,8 +340,8 @@ module Api
|
||||
]
|
||||
|
||||
dangerous_schemes.each do |dangerous_url|
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" },
|
||||
params: { rd: dangerous_url }
|
||||
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"},
|
||||
params: {rd: dangerous_url}
|
||||
|
||||
assert_response 302, "Should reject dangerous URL: #{dangerous_url}"
|
||||
# Should redirect to login page or default URL, NOT to dangerous URL
|
||||
@@ -459,27 +350,15 @@ module Api
|
||||
end
|
||||
end
|
||||
|
||||
# HTTP Method Specific Tests (based on Authelia approach)
|
||||
test "should handle different HTTP methods with appropriate redirect codes" do
|
||||
# HTTP Method Tests
|
||||
test "should handle GET requests with appropriate response codes" do
|
||||
sign_in_as(@user)
|
||||
|
||||
# Test GET requests should return 302 Found
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
||||
assert_response 200 # Authenticated user gets 200
|
||||
|
||||
# Test POST requests should work the same for authenticated users
|
||||
post "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
||||
# Authenticated GET requests should return 200
|
||||
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
|
||||
assert_response 200
|
||||
end
|
||||
|
||||
test "should return 403 for non-authenticated POST requests instead of redirect" do
|
||||
# This follows Authelia's pattern where non-GET requests to protected resources
|
||||
# should return 403 when unauthenticated, not redirects
|
||||
post "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
||||
assert_response 302 # Our implementation still redirects to login
|
||||
# Note: Could be enhanced to return 403 for non-GET methods
|
||||
end
|
||||
|
||||
# XHR/Fetch Request Tests
|
||||
test "should handle XHR requests appropriately" do
|
||||
get "/api/verify", headers: {
|
||||
@@ -549,27 +428,30 @@ module Api
|
||||
"X-Forwarded-Host" => "测试.example.com"
|
||||
}
|
||||
|
||||
assert_response 200
|
||||
assert_response 403 # No rule configured - fail-closed
|
||||
assert_equal "No authentication rule configured for this domain", response.headers["x-auth-reason"]
|
||||
end
|
||||
|
||||
# Protocol and Scheme Tests
|
||||
test "should handle X-Forwarded-Proto header" do
|
||||
sign_in_as(@user)
|
||||
|
||||
get "/api/verify", headers: {
|
||||
"X-Forwarded-Host" => "test.example.com",
|
||||
"X-Forwarded-Proto" => "https"
|
||||
}
|
||||
|
||||
sign_in_as(@user)
|
||||
assert_response 200
|
||||
end
|
||||
|
||||
test "should handle HTTP protocol in X-Forwarded-Proto" do
|
||||
sign_in_as(@user)
|
||||
|
||||
get "/api/verify", headers: {
|
||||
"X-Forwarded-Host" => "test.example.com",
|
||||
"X-Forwarded-Proto" => "http"
|
||||
}
|
||||
|
||||
sign_in_as(@user)
|
||||
assert_response 200
|
||||
# Note: Our implementation doesn't enforce protocol matching
|
||||
end
|
||||
@@ -579,15 +461,15 @@ module Api
|
||||
sign_in_as(@user)
|
||||
|
||||
# First request
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
||||
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
|
||||
assert_response 200
|
||||
|
||||
# Second request with same session
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
||||
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
|
||||
assert_response 200
|
||||
|
||||
# Should maintain user identity across requests
|
||||
assert_equal @user.email_address, response.headers["X-Remote-User"]
|
||||
assert_equal @user.email_address, response.headers["x-remote-user"]
|
||||
end
|
||||
|
||||
test "should handle concurrent requests with same session" do
|
||||
@@ -599,17 +481,16 @@ module Api
|
||||
|
||||
5.times do |i|
|
||||
threads << Thread.new do
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "app#{i}.example.com" }
|
||||
results << { status: response.status, user: response.headers["X-Remote-User"] }
|
||||
get "/api/verify", headers: {"X-Forwarded-Host" => "app#{i}.example.com"}
|
||||
results << {status: response.status}
|
||||
end
|
||||
end
|
||||
|
||||
threads.each(&:join)
|
||||
|
||||
# All requests should succeed
|
||||
# All requests should be denied (no rules configured for these domains)
|
||||
results.each do |result|
|
||||
assert_equal 200, result[:status]
|
||||
assert_equal @user.email_address, result[:user]
|
||||
assert_equal 403, result[:status]
|
||||
end
|
||||
end
|
||||
|
||||
@@ -624,13 +505,15 @@ module Api
|
||||
end
|
||||
|
||||
test "should handle null byte injection in headers" do
|
||||
sign_in_as(@user)
|
||||
|
||||
get "/api/verify", headers: {
|
||||
"X-Forwarded-Host" => "test.example.com\0.evil.com"
|
||||
}
|
||||
|
||||
sign_in_as(@user)
|
||||
# Should handle null bytes safely
|
||||
assert_response 200
|
||||
# Should handle null bytes safely - domain doesn't match any rule
|
||||
assert_response 403
|
||||
assert_equal "No authentication rule configured for this domain", response.headers["x-auth-reason"]
|
||||
end
|
||||
|
||||
# Performance and Load Tests
|
||||
@@ -641,8 +524,8 @@ module Api
|
||||
request_count = 10
|
||||
|
||||
request_count.times do |i|
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "app#{i}.example.com" }
|
||||
assert_response 200
|
||||
get "/api/verify", headers: {"X-Forwarded-Host" => "app#{i}.example.com"}
|
||||
assert_response 403 # No rules configured for these domains
|
||||
end
|
||||
|
||||
total_time = Time.current - start_time
|
||||
@@ -652,4 +535,4 @@ module Api
|
||||
assert average_time < 0.1, "Average request time too slow: #{average_time}s"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -10,10 +10,14 @@ class AuthenticationTest < ActiveSupport::TestCase
|
||||
return nil if host.blank? || host.match?(/^(localhost|127\.0\.0\.1|::1)$/)
|
||||
|
||||
# Strip port number for domain parsing
|
||||
host_without_port = host.split(':').first
|
||||
host_without_port = host.split(":").first
|
||||
|
||||
# Check if it's an IP address (IPv4 or IPv6) - if so, don't set domain cookie
|
||||
return nil if IPAddr.new(host_without_port) rescue false
|
||||
begin
|
||||
return nil if IPAddr.new(host_without_port)
|
||||
rescue
|
||||
false
|
||||
end
|
||||
|
||||
# Use Public Suffix List for accurate domain parsing
|
||||
domain = PublicSuffix.parse(host_without_port)
|
||||
@@ -214,4 +218,4 @@ class AuthenticationTest < ActiveSupport::TestCase
|
||||
assert_equal domain, extract_root_domain("api.example.com")
|
||||
assert_equal domain, extract_root_domain("sub.example.com")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
187
test/controllers/input_validation_test.rb
Normal file
187
test/controllers/input_validation_test.rb
Normal file
@@ -0,0 +1,187 @@
|
||||
require "test_helper"
|
||||
|
||||
class InputValidationTest < ActionDispatch::IntegrationTest
|
||||
# ====================
|
||||
# SQL INJECTION PREVENTION TESTS
|
||||
# ====================
|
||||
|
||||
test "SQL injection is prevented by Rails ORM" do
|
||||
# Rails ActiveRecord prevents SQL injection through parameterized queries
|
||||
# This test verifies the protection is in place
|
||||
|
||||
# Try SQL injection in email field
|
||||
post signin_path, params: {
|
||||
email_address: "admin' OR '1'='1",
|
||||
password: "password123"
|
||||
}
|
||||
|
||||
# Should not authenticate with SQL injection
|
||||
assert_response :redirect
|
||||
assert_redirected_to signin_path
|
||||
assert_match(/invalid/i, flash[:alert].to_s)
|
||||
end
|
||||
|
||||
# ====================
|
||||
# XSS PREVENTION TESTS
|
||||
# ====================
|
||||
|
||||
test "XSS in user input is escaped" do
|
||||
# Create user with XSS payload in name
|
||||
xss_payload = "<script>alert('XSS')</script>"
|
||||
user = User.create!(email_address: "xss_test@example.com", password: "password123", name: xss_payload)
|
||||
|
||||
# Sign in
|
||||
post signin_path, params: {email_address: "xss_test@example.com", password: "password123"}
|
||||
assert_response :redirect
|
||||
|
||||
# Get a page that displays user name
|
||||
get root_path
|
||||
assert_response :success
|
||||
|
||||
# The XSS payload should be escaped, not executed
|
||||
# Rails automatically escapes output in ERB templates
|
||||
|
||||
user.destroy
|
||||
end
|
||||
|
||||
# ====================
|
||||
# PARAMETER TAMPERING TESTS
|
||||
# ====================
|
||||
|
||||
test "parameter tampering in OAuth authorization is prevented" do
|
||||
user = User.create!(email_address: "oauth_tamper_test@example.com", password: "password123")
|
||||
application = Application.create!(
|
||||
name: "OAuth Test App",
|
||||
slug: "oauth-test-app",
|
||||
app_type: "oidc",
|
||||
redirect_uris: ["http://localhost:4000/callback"].to_json,
|
||||
active: true
|
||||
)
|
||||
|
||||
# Sign in
|
||||
post signin_path, params: {email_address: "oauth_tamper_test@example.com", password: "password123"}
|
||||
assert_response :redirect
|
||||
|
||||
# Try to tamper with OAuth authorization parameters
|
||||
get "/oauth/authorize", params: {
|
||||
client_id: application.client_id,
|
||||
redirect_uri: "http://evil.com/callback", # Tampered redirect URI
|
||||
response_type: "code",
|
||||
scope: "openid profile admin", # Tampered scope to request admin access
|
||||
user_id: 1 # Tampered user ID
|
||||
}
|
||||
|
||||
# Should reject the tampered redirect URI
|
||||
assert_response :bad_request
|
||||
|
||||
user.sessions.delete_all
|
||||
user.destroy
|
||||
application.destroy
|
||||
end
|
||||
|
||||
test "parameter tampering in token request is prevented" do
|
||||
user = User.create!(email_address: "token_tamper_test@example.com", password: "password123")
|
||||
application = Application.create!(
|
||||
name: "Token Tamper Test App",
|
||||
slug: "token-tamper-test",
|
||||
app_type: "oidc",
|
||||
redirect_uris: ["http://localhost:4000/callback"].to_json,
|
||||
active: true
|
||||
)
|
||||
|
||||
# Try to tamper with token request parameters
|
||||
post "/oauth/token", params: {
|
||||
grant_type: "authorization_code",
|
||||
code: "fake_code",
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
client_id: "tampered_client_id",
|
||||
user_id: 999 # Tampered user ID
|
||||
}
|
||||
|
||||
# Should reject tampered client_id
|
||||
assert_response :unauthorized
|
||||
|
||||
user.destroy
|
||||
application.destroy
|
||||
end
|
||||
|
||||
# ====================
|
||||
# JSON INPUT VALIDATION TESTS
|
||||
# ====================
|
||||
|
||||
test "JSON input validation prevents malicious payloads" do
|
||||
# Try to send malformed JSON
|
||||
post "/oauth/token", params: '{"grant_type":"authorization_code",}'.to_json,
|
||||
headers: {"CONTENT_TYPE" => "application/json"}
|
||||
|
||||
# Should handle malformed JSON gracefully
|
||||
assert_includes [400, 422], response.status
|
||||
end
|
||||
|
||||
test "JSON input sanitization prevents injection" do
|
||||
# Try JSON injection attacks
|
||||
post "/oauth/token", params: {
|
||||
grant_type: "authorization_code",
|
||||
code: "test_code",
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
nested: {__proto__: "tampered", constructor: {prototype: "tampered"}}
|
||||
}.to_json,
|
||||
headers: {"CONTENT_TYPE" => "application/json"}
|
||||
|
||||
# Should sanitize or reject prototype pollution attempts
|
||||
# The request should be handled (either accept or reject, not crash)
|
||||
assert response.body.present?
|
||||
end
|
||||
|
||||
# ====================
|
||||
# HEADER INJECTION TESTS
|
||||
# ====================
|
||||
|
||||
test "HTTP header injection is prevented" do
|
||||
# Try to inject headers via user input
|
||||
malicious_input = "value\r\nX-Injected-Header: malicious"
|
||||
|
||||
post signin_path, params: {
|
||||
email_address: malicious_input,
|
||||
password: "password123"
|
||||
}
|
||||
|
||||
# Should sanitize or reject header injection attempts
|
||||
assert_nil response.headers["X-Injected-Header"]
|
||||
end
|
||||
|
||||
# ====================
|
||||
# PATH TRAVERSAL TESTS
|
||||
# ====================
|
||||
|
||||
test "path traversal is prevented" do
|
||||
# Try to access files outside intended directory
|
||||
malicious_paths = [
|
||||
"../../../etc/passwd",
|
||||
"..\\..\\..\\windows\\system32\\drivers\\etc\\hosts",
|
||||
"/etc/passwd",
|
||||
"C:\\Windows\\System32\\config\\sam"
|
||||
]
|
||||
|
||||
malicious_paths.each do |malicious_path|
|
||||
# Try to access files with path traversal
|
||||
get root_path, params: {file: malicious_path}
|
||||
|
||||
# Should prevent access to files outside public directory
|
||||
assert_response :redirect, "Should reject path traversal attempt"
|
||||
end
|
||||
end
|
||||
|
||||
test "null byte injection is prevented" do
|
||||
# Try null byte injection
|
||||
malicious_input = "test\x00@example.com"
|
||||
|
||||
post signin_path, params: {
|
||||
email_address: malicious_input,
|
||||
password: "password123"
|
||||
}
|
||||
|
||||
# Should sanitize null bytes
|
||||
assert_response :redirect
|
||||
end
|
||||
end
|
||||
@@ -100,7 +100,7 @@ class InvitationsControllerTest < ActionDispatch::IntegrationTest
|
||||
|
||||
test "should destroy existing sessions when accepting invitation" do
|
||||
# Create an existing session for the user
|
||||
existing_session = @user.sessions.create!
|
||||
@user.sessions.create!
|
||||
|
||||
put invitation_path(@token), params: {
|
||||
password: "newpassword123",
|
||||
@@ -145,4 +145,4 @@ class InvitationsControllerTest < ActionDispatch::IntegrationTest
|
||||
get invitation_path(@token)
|
||||
assert_response :success
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -8,7 +8,8 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
||||
slug: "security-test-app",
|
||||
app_type: "oidc",
|
||||
redirect_uris: ["http://localhost:4000/callback"].to_json,
|
||||
active: true
|
||||
active: true,
|
||||
require_pkce: false
|
||||
)
|
||||
|
||||
# Store the plain text client secret for testing
|
||||
@@ -19,9 +20,11 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
||||
end
|
||||
|
||||
def teardown
|
||||
OidcAuthorizationCode.where(application: @application).delete_all
|
||||
# Use delete_all to avoid triggering callbacks that might have issues with the schema
|
||||
# Delete in correct order to avoid foreign key constraints
|
||||
OidcRefreshToken.where(application: @application).delete_all
|
||||
OidcAccessToken.where(application: @application).delete_all
|
||||
OidcAuthorizationCode.where(application: @application).delete_all
|
||||
OidcUserConsent.where(application: @application).delete_all
|
||||
@user.destroy
|
||||
@application.destroy
|
||||
end
|
||||
@@ -31,11 +34,19 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
||||
# ====================
|
||||
|
||||
test "prevents authorization code reuse - sequential attempts" do
|
||||
# Create consent
|
||||
OidcUserConsent.create!(
|
||||
user: @user,
|
||||
application: @application,
|
||||
scopes_granted: "openid profile",
|
||||
granted_at: Time.current,
|
||||
sid: "test-sid-123"
|
||||
)
|
||||
|
||||
# Create a valid authorization code
|
||||
auth_code = OidcAuthorizationCode.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
code: SecureRandom.urlsafe_base64(32),
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
scope: "openid profile",
|
||||
expires_at: 10.minutes.from_now
|
||||
@@ -43,7 +54,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
||||
|
||||
token_params = {
|
||||
grant_type: "authorization_code",
|
||||
code: auth_code.code,
|
||||
code: auth_code.plaintext_code,
|
||||
redirect_uri: "http://localhost:4000/callback"
|
||||
}
|
||||
|
||||
@@ -69,11 +80,19 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
||||
end
|
||||
|
||||
test "revokes existing tokens when authorization code is reused" do
|
||||
# Create consent
|
||||
OidcUserConsent.create!(
|
||||
user: @user,
|
||||
application: @application,
|
||||
scopes_granted: "openid profile",
|
||||
granted_at: Time.current,
|
||||
sid: "test-sid-123"
|
||||
)
|
||||
|
||||
# Create a valid authorization code
|
||||
auth_code = OidcAuthorizationCode.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
code: SecureRandom.urlsafe_base64(32),
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
scope: "openid profile",
|
||||
expires_at: 10.minutes.from_now
|
||||
@@ -81,7 +100,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
||||
|
||||
token_params = {
|
||||
grant_type: "authorization_code",
|
||||
code: auth_code.code,
|
||||
code: auth_code.plaintext_code,
|
||||
redirect_uri: "http://localhost:4000/callback"
|
||||
}
|
||||
|
||||
@@ -115,11 +134,19 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
||||
end
|
||||
|
||||
test "rejects already used authorization code" do
|
||||
# Create consent
|
||||
OidcUserConsent.create!(
|
||||
user: @user,
|
||||
application: @application,
|
||||
scopes_granted: "openid profile",
|
||||
granted_at: Time.current,
|
||||
sid: "test-sid-123"
|
||||
)
|
||||
|
||||
# Create and mark code as used
|
||||
auth_code = OidcAuthorizationCode.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
code: SecureRandom.urlsafe_base64(32),
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
scope: "openid profile",
|
||||
used: true,
|
||||
@@ -128,7 +155,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
||||
|
||||
token_params = {
|
||||
grant_type: "authorization_code",
|
||||
code: auth_code.code,
|
||||
code: auth_code.plaintext_code,
|
||||
redirect_uri: "http://localhost:4000/callback"
|
||||
}
|
||||
|
||||
@@ -143,11 +170,19 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
||||
end
|
||||
|
||||
test "rejects expired authorization code" do
|
||||
# Create consent
|
||||
OidcUserConsent.create!(
|
||||
user: @user,
|
||||
application: @application,
|
||||
scopes_granted: "openid profile",
|
||||
granted_at: Time.current,
|
||||
sid: "test-sid-123"
|
||||
)
|
||||
|
||||
# Create expired code
|
||||
auth_code = OidcAuthorizationCode.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
code: SecureRandom.urlsafe_base64(32),
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
scope: "openid profile",
|
||||
expires_at: 5.minutes.ago
|
||||
@@ -155,7 +190,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
||||
|
||||
token_params = {
|
||||
grant_type: "authorization_code",
|
||||
code: auth_code.code,
|
||||
code: auth_code.plaintext_code,
|
||||
redirect_uri: "http://localhost:4000/callback"
|
||||
}
|
||||
|
||||
@@ -170,10 +205,18 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
||||
end
|
||||
|
||||
test "rejects authorization code with mismatched redirect_uri" do
|
||||
# Create consent
|
||||
OidcUserConsent.create!(
|
||||
user: @user,
|
||||
application: @application,
|
||||
scopes_granted: "openid profile",
|
||||
granted_at: Time.current,
|
||||
sid: "test-sid-123"
|
||||
)
|
||||
|
||||
auth_code = OidcAuthorizationCode.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
code: SecureRandom.urlsafe_base64(32),
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
scope: "openid profile",
|
||||
expires_at: 10.minutes.from_now
|
||||
@@ -181,7 +224,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
||||
|
||||
token_params = {
|
||||
grant_type: "authorization_code",
|
||||
code: auth_code.code,
|
||||
code: auth_code.plaintext_code,
|
||||
redirect_uri: "http://evil.com/callback" # Wrong redirect URI
|
||||
}
|
||||
|
||||
@@ -212,13 +255,23 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
||||
end
|
||||
|
||||
test "rejects authorization code for different application" do
|
||||
# Create consent for the first application
|
||||
OidcUserConsent.create!(
|
||||
user: @user,
|
||||
application: @application,
|
||||
scopes_granted: "openid profile",
|
||||
granted_at: Time.current,
|
||||
sid: "test-sid-123"
|
||||
)
|
||||
|
||||
# Create another application
|
||||
other_app = Application.create!(
|
||||
name: "Other App",
|
||||
slug: "other-app",
|
||||
app_type: "oidc",
|
||||
redirect_uris: ["http://localhost:5000/callback"].to_json,
|
||||
active: true
|
||||
active: true,
|
||||
require_pkce: false
|
||||
)
|
||||
other_secret = other_app.client_secret
|
||||
|
||||
@@ -226,7 +279,6 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
||||
auth_code = OidcAuthorizationCode.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
code: SecureRandom.urlsafe_base64(32),
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
scope: "openid profile",
|
||||
expires_at: 10.minutes.from_now
|
||||
@@ -235,7 +287,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
||||
# Try to use it with different application credentials
|
||||
token_params = {
|
||||
grant_type: "authorization_code",
|
||||
code: auth_code.code,
|
||||
code: auth_code.plaintext_code,
|
||||
redirect_uri: "http://localhost:4000/callback"
|
||||
}
|
||||
|
||||
@@ -255,10 +307,18 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
||||
# ====================
|
||||
|
||||
test "rejects invalid client_id in Basic auth" do
|
||||
# Create consent
|
||||
OidcUserConsent.create!(
|
||||
user: @user,
|
||||
application: @application,
|
||||
scopes_granted: "openid profile",
|
||||
granted_at: Time.current,
|
||||
sid: "test-sid-123"
|
||||
)
|
||||
|
||||
auth_code = OidcAuthorizationCode.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
code: SecureRandom.urlsafe_base64(32),
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
scope: "openid profile",
|
||||
expires_at: 10.minutes.from_now
|
||||
@@ -266,7 +326,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
||||
|
||||
token_params = {
|
||||
grant_type: "authorization_code",
|
||||
code: auth_code.code,
|
||||
code: auth_code.plaintext_code,
|
||||
redirect_uri: "http://localhost:4000/callback"
|
||||
}
|
||||
|
||||
@@ -280,10 +340,18 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
||||
end
|
||||
|
||||
test "rejects invalid client_secret in Basic auth" do
|
||||
# Create consent
|
||||
OidcUserConsent.create!(
|
||||
user: @user,
|
||||
application: @application,
|
||||
scopes_granted: "openid profile",
|
||||
granted_at: Time.current,
|
||||
sid: "test-sid-123"
|
||||
)
|
||||
|
||||
auth_code = OidcAuthorizationCode.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
code: SecureRandom.urlsafe_base64(32),
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
scope: "openid profile",
|
||||
expires_at: 10.minutes.from_now
|
||||
@@ -291,7 +359,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
||||
|
||||
token_params = {
|
||||
grant_type: "authorization_code",
|
||||
code: auth_code.code,
|
||||
code: auth_code.plaintext_code,
|
||||
redirect_uri: "http://localhost:4000/callback"
|
||||
}
|
||||
|
||||
@@ -305,10 +373,18 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
||||
end
|
||||
|
||||
test "accepts client credentials in POST body" do
|
||||
# Create consent
|
||||
OidcUserConsent.create!(
|
||||
user: @user,
|
||||
application: @application,
|
||||
scopes_granted: "openid profile",
|
||||
granted_at: Time.current,
|
||||
sid: "test-sid-123"
|
||||
)
|
||||
|
||||
auth_code = OidcAuthorizationCode.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
code: SecureRandom.urlsafe_base64(32),
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
scope: "openid profile",
|
||||
expires_at: 10.minutes.from_now
|
||||
@@ -316,7 +392,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
||||
|
||||
token_params = {
|
||||
grant_type: "authorization_code",
|
||||
code: auth_code.code,
|
||||
code: auth_code.plaintext_code,
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
client_id: @application.client_id,
|
||||
client_secret: @plain_client_secret
|
||||
@@ -331,10 +407,18 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
||||
end
|
||||
|
||||
test "rejects request with no client authentication" do
|
||||
# Create consent
|
||||
OidcUserConsent.create!(
|
||||
user: @user,
|
||||
application: @application,
|
||||
scopes_granted: "openid profile",
|
||||
granted_at: Time.current,
|
||||
sid: "test-sid-123"
|
||||
)
|
||||
|
||||
auth_code = OidcAuthorizationCode.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
code: SecureRandom.urlsafe_base64(32),
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
scope: "openid profile",
|
||||
expires_at: 10.minutes.from_now
|
||||
@@ -342,7 +426,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
||||
|
||||
token_params = {
|
||||
grant_type: "authorization_code",
|
||||
code: auth_code.code,
|
||||
code: auth_code.plaintext_code,
|
||||
redirect_uri: "http://localhost:4000/callback"
|
||||
}
|
||||
|
||||
@@ -389,10 +473,18 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
||||
# ====================
|
||||
|
||||
test "client authentication uses constant-time comparison" do
|
||||
# Create consent
|
||||
OidcUserConsent.create!(
|
||||
user: @user,
|
||||
application: @application,
|
||||
scopes_granted: "openid profile",
|
||||
granted_at: Time.current,
|
||||
sid: "test-sid-123"
|
||||
)
|
||||
|
||||
auth_code = OidcAuthorizationCode.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
code: SecureRandom.urlsafe_base64(32),
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
scope: "openid profile",
|
||||
expires_at: 10.minutes.from_now
|
||||
@@ -400,7 +492,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
||||
|
||||
token_params = {
|
||||
grant_type: "authorization_code",
|
||||
code: auth_code.code,
|
||||
code: auth_code.plaintext_code,
|
||||
redirect_uri: "http://localhost:4000/callback"
|
||||
}
|
||||
|
||||
@@ -438,4 +530,320 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
||||
assert timing_difference < 0.05,
|
||||
"Timing difference #{timing_difference}s suggests potential timing attack vulnerability"
|
||||
end
|
||||
|
||||
# ====================
|
||||
# STATE PARAMETER BINDING (CSRF PREVENTION FOR OAUTH)
|
||||
# ====================
|
||||
|
||||
test "state parameter is required and validated in authorization flow" do
|
||||
# Create consent to skip consent page
|
||||
OidcUserConsent.create!(
|
||||
user: @user,
|
||||
application: @application,
|
||||
scopes_granted: "openid profile",
|
||||
granted_at: Time.current,
|
||||
sid: "test-sid-123"
|
||||
)
|
||||
|
||||
# Sign in first
|
||||
post signin_path, params: {email_address: "security_test@example.com", password: "password123"}
|
||||
|
||||
# Test authorization with state parameter
|
||||
get "/oauth/authorize", params: {
|
||||
client_id: @application.client_id,
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
response_type: "code",
|
||||
scope: "openid profile",
|
||||
state: "random_state_123"
|
||||
}
|
||||
|
||||
# Should include state in redirect
|
||||
assert_response :redirect
|
||||
assert_match(/state=random_state_123/, response.location)
|
||||
end
|
||||
|
||||
test "authorization without state parameter still works but is less secure" do
|
||||
# Create consent to skip consent page
|
||||
OidcUserConsent.create!(
|
||||
user: @user,
|
||||
application: @application,
|
||||
scopes_granted: "openid profile",
|
||||
granted_at: Time.current,
|
||||
sid: "test-sid-123"
|
||||
)
|
||||
|
||||
# Sign in first
|
||||
post signin_path, params: {email_address: "security_test@example.com", password: "password123"}
|
||||
|
||||
# Test authorization without state parameter
|
||||
get "/oauth/authorize", params: {
|
||||
client_id: @application.client_id,
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
response_type: "code",
|
||||
scope: "openid profile"
|
||||
}
|
||||
|
||||
# Should work but state is recommended for CSRF protection
|
||||
assert_response :redirect
|
||||
end
|
||||
|
||||
# ====================
|
||||
# NONCE PARAMETER VALIDATION (FOR ID TOKENS)
|
||||
# ====================
|
||||
|
||||
test "nonce parameter is included in ID token" do
|
||||
# Create consent
|
||||
OidcUserConsent.create!(
|
||||
user: @user,
|
||||
application: @application,
|
||||
scopes_granted: "openid profile",
|
||||
granted_at: Time.current,
|
||||
sid: "test-sid-123"
|
||||
)
|
||||
|
||||
# Create authorization code with nonce
|
||||
auth_code = OidcAuthorizationCode.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
scope: "openid profile",
|
||||
nonce: "test_nonce_123",
|
||||
expires_at: 10.minutes.from_now
|
||||
)
|
||||
|
||||
# Exchange code for tokens
|
||||
post "/oauth/token", params: {
|
||||
grant_type: "authorization_code",
|
||||
code: auth_code.plaintext_code,
|
||||
redirect_uri: "http://localhost:4000/callback"
|
||||
}, headers: {
|
||||
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||
}
|
||||
|
||||
assert_response :success
|
||||
response_body = JSON.parse(@response.body)
|
||||
id_token = response_body["id_token"]
|
||||
|
||||
# Decode ID token (without verification for this test)
|
||||
decoded_token = JWT.decode(id_token, nil, false)
|
||||
|
||||
# Verify nonce is included in ID token
|
||||
assert_equal "test_nonce_123", decoded_token[0]["nonce"]
|
||||
end
|
||||
|
||||
# ====================
|
||||
# TOKEN LEAKAGE VIA REFERER HEADER TESTS
|
||||
# ====================
|
||||
|
||||
test "access tokens are not exposed in referer header" do
|
||||
# Create consent and authorization code
|
||||
OidcUserConsent.create!(
|
||||
user: @user,
|
||||
application: @application,
|
||||
scopes_granted: "openid profile",
|
||||
granted_at: Time.current,
|
||||
sid: "test-sid-123"
|
||||
)
|
||||
|
||||
auth_code = OidcAuthorizationCode.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
scope: "openid profile",
|
||||
expires_at: 10.minutes.from_now
|
||||
)
|
||||
|
||||
# Exchange code for tokens
|
||||
post "/oauth/token", params: {
|
||||
grant_type: "authorization_code",
|
||||
code: auth_code.plaintext_code,
|
||||
redirect_uri: "http://localhost:4000/callback"
|
||||
}, headers: {
|
||||
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||
}
|
||||
|
||||
assert_response :success
|
||||
response_body = JSON.parse(@response.body)
|
||||
response_body["access_token"]
|
||||
|
||||
# Verify token is not in response headers (especially Referer)
|
||||
assert_nil response.headers["Referer"], "Access token should not leak in Referer header"
|
||||
assert_nil response.headers["Location"], "Access token should not leak in Location header"
|
||||
end
|
||||
|
||||
# ====================
|
||||
# PKCE ENFORCEMENT FOR PUBLIC CLIENTS TESTS
|
||||
# ====================
|
||||
|
||||
test "PKCE code_verifier is required when code_challenge was provided" do
|
||||
# Create consent
|
||||
OidcUserConsent.create!(
|
||||
user: @user,
|
||||
application: @application,
|
||||
scopes_granted: "openid profile",
|
||||
granted_at: Time.current,
|
||||
sid: "test-sid-123"
|
||||
)
|
||||
|
||||
# Create authorization code with PKCE challenge
|
||||
code_verifier = SecureRandom.urlsafe_base64(32)
|
||||
code_challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false)
|
||||
|
||||
auth_code = OidcAuthorizationCode.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
scope: "openid profile",
|
||||
code_challenge: code_challenge,
|
||||
code_challenge_method: "S256",
|
||||
expires_at: 10.minutes.from_now
|
||||
)
|
||||
|
||||
# Try to exchange code without code_verifier
|
||||
post "/oauth/token", params: {
|
||||
grant_type: "authorization_code",
|
||||
code: auth_code.plaintext_code,
|
||||
redirect_uri: "http://localhost:4000/callback"
|
||||
}, headers: {
|
||||
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||
}
|
||||
|
||||
assert_response :bad_request
|
||||
error = JSON.parse(@response.body)
|
||||
assert_equal "invalid_request", error["error"]
|
||||
assert_match(/code_verifier is required/, error["error_description"])
|
||||
end
|
||||
|
||||
test "PKCE with S256 method validates correctly" do
|
||||
# Create consent
|
||||
OidcUserConsent.create!(
|
||||
user: @user,
|
||||
application: @application,
|
||||
scopes_granted: "openid profile",
|
||||
granted_at: Time.current,
|
||||
sid: "test-sid-123"
|
||||
)
|
||||
|
||||
# Create authorization code with PKCE S256
|
||||
code_verifier = SecureRandom.urlsafe_base64(32)
|
||||
code_challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false)
|
||||
|
||||
auth_code = OidcAuthorizationCode.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
scope: "openid profile",
|
||||
code_challenge: code_challenge,
|
||||
code_challenge_method: "S256",
|
||||
expires_at: 10.minutes.from_now
|
||||
)
|
||||
|
||||
# Exchange code with correct code_verifier
|
||||
post "/oauth/token", params: {
|
||||
grant_type: "authorization_code",
|
||||
code: auth_code.plaintext_code,
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
code_verifier: code_verifier
|
||||
}, headers: {
|
||||
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||
}
|
||||
|
||||
assert_response :success
|
||||
response_body = JSON.parse(@response.body)
|
||||
assert response_body.key?("access_token")
|
||||
end
|
||||
|
||||
test "PKCE rejects invalid code_verifier" do
|
||||
# Create consent
|
||||
OidcUserConsent.create!(
|
||||
user: @user,
|
||||
application: @application,
|
||||
scopes_granted: "openid profile",
|
||||
granted_at: Time.current,
|
||||
sid: "test-sid-123"
|
||||
)
|
||||
|
||||
# Create authorization code with PKCE
|
||||
code_verifier = SecureRandom.urlsafe_base64(32)
|
||||
code_challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false)
|
||||
|
||||
auth_code = OidcAuthorizationCode.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
scope: "openid profile",
|
||||
code_challenge: code_challenge,
|
||||
code_challenge_method: "S256",
|
||||
expires_at: 10.minutes.from_now
|
||||
)
|
||||
|
||||
# Try with wrong code_verifier
|
||||
post "/oauth/token", params: {
|
||||
grant_type: "authorization_code",
|
||||
code: auth_code.plaintext_code,
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
code_verifier: "wrong_code_verifier_12345678901234567890"
|
||||
}, headers: {
|
||||
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||
}
|
||||
|
||||
assert_response :bad_request
|
||||
error = JSON.parse(@response.body)
|
||||
assert_equal "invalid_request", error["error"]
|
||||
end
|
||||
|
||||
# ====================
|
||||
# REFRESH TOKEN ROTATION TESTS
|
||||
# ====================
|
||||
|
||||
test "refresh token rotation is enforced" do
|
||||
# Create consent for the refresh token endpoint
|
||||
OidcUserConsent.create!(
|
||||
user: @user,
|
||||
application: @application,
|
||||
scopes_granted: "openid profile",
|
||||
granted_at: Time.current,
|
||||
sid: "test-sid-123"
|
||||
)
|
||||
|
||||
# Create initial access and refresh tokens
|
||||
access_token = OidcAccessToken.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
scope: "openid profile"
|
||||
)
|
||||
|
||||
refresh_token = OidcRefreshToken.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
oidc_access_token: access_token,
|
||||
scope: "openid profile"
|
||||
)
|
||||
|
||||
original_token_family_id = refresh_token.token_family_id
|
||||
old_refresh_token = refresh_token.token
|
||||
|
||||
# Refresh the token
|
||||
post "/oauth/token", params: {
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: old_refresh_token
|
||||
}, headers: {
|
||||
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||
}
|
||||
|
||||
assert_response :success
|
||||
response_body = JSON.parse(@response.body)
|
||||
new_refresh_token = response_body["refresh_token"]
|
||||
|
||||
# Verify new refresh token is different
|
||||
assert_not_equal old_refresh_token, new_refresh_token
|
||||
|
||||
# Verify token family is preserved
|
||||
new_token_record = OidcRefreshToken.find_by_token(new_refresh_token)
|
||||
assert_equal original_token_family_id, new_token_record.token_family_id
|
||||
|
||||
# Old refresh token should be revoked
|
||||
old_token_record = OidcRefreshToken.find(refresh_token.id)
|
||||
assert old_token_record.revoked?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -17,8 +17,11 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
|
||||
|
||||
def teardown
|
||||
Current.session&.destroy
|
||||
OidcAuthorizationCode.where(application: @application).destroy_all
|
||||
OidcAccessToken.where(application: @application).destroy_all
|
||||
# Delete in correct order to avoid foreign key constraints
|
||||
OidcRefreshToken.where(application: @application).delete_all
|
||||
OidcAccessToken.where(application: @application).delete_all
|
||||
OidcAuthorizationCode.where(application: @application).delete_all
|
||||
OidcUserConsent.where(application: @application).delete_all
|
||||
@user.destroy
|
||||
@application.destroy
|
||||
end
|
||||
@@ -35,7 +38,6 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
|
||||
end
|
||||
|
||||
test "authorization endpoint accepts PKCE parameters (S256)" do
|
||||
code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
||||
code_challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
|
||||
|
||||
auth_params = {
|
||||
@@ -53,7 +55,7 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
|
||||
|
||||
# Should show consent page (user is already authenticated)
|
||||
assert_response :success
|
||||
assert_match /consent/, @response.body.downcase
|
||||
assert_match(/consent/, @response.body.downcase)
|
||||
end
|
||||
|
||||
test "authorization endpoint accepts PKCE parameters (plain)" do
|
||||
@@ -74,7 +76,7 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
|
||||
|
||||
# Should show consent page (user is already authenticated)
|
||||
assert_response :success
|
||||
assert_match /consent/, @response.body.downcase
|
||||
assert_match(/consent/, @response.body.downcase)
|
||||
end
|
||||
|
||||
test "authorization endpoint rejects invalid code_challenge_method" do
|
||||
@@ -111,11 +113,19 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
|
||||
end
|
||||
|
||||
test "token endpoint requires code_verifier when PKCE was used (S256)" do
|
||||
# Create consent for token endpoint
|
||||
OidcUserConsent.create!(
|
||||
user: @user,
|
||||
application: @application,
|
||||
scopes_granted: "openid profile",
|
||||
granted_at: Time.current,
|
||||
sid: "test-sid-123"
|
||||
)
|
||||
|
||||
# Create authorization code with PKCE S256
|
||||
auth_code = OidcAuthorizationCode.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
code: SecureRandom.urlsafe_base64(32),
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
scope: "openid profile",
|
||||
code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
|
||||
@@ -125,7 +135,7 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
|
||||
|
||||
token_params = {
|
||||
grant_type: "authorization_code",
|
||||
code: auth_code.code,
|
||||
code: auth_code.plaintext_code,
|
||||
redirect_uri: "http://localhost:4000/callback"
|
||||
}
|
||||
|
||||
@@ -140,11 +150,19 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
|
||||
end
|
||||
|
||||
test "token endpoint requires code_verifier when PKCE was used (plain)" do
|
||||
# Create consent for token endpoint
|
||||
OidcUserConsent.create!(
|
||||
user: @user,
|
||||
application: @application,
|
||||
scopes_granted: "openid profile",
|
||||
granted_at: Time.current,
|
||||
sid: "test-sid-123"
|
||||
)
|
||||
|
||||
# Create authorization code with PKCE plain
|
||||
auth_code = OidcAuthorizationCode.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
code: SecureRandom.urlsafe_base64(32),
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
scope: "openid profile",
|
||||
code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
|
||||
@@ -154,7 +172,7 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
|
||||
|
||||
token_params = {
|
||||
grant_type: "authorization_code",
|
||||
code: auth_code.code,
|
||||
code: auth_code.plaintext_code,
|
||||
redirect_uri: "http://localhost:4000/callback"
|
||||
}
|
||||
|
||||
@@ -169,11 +187,19 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
|
||||
end
|
||||
|
||||
test "token endpoint rejects invalid code_verifier (S256)" do
|
||||
# Create consent for token endpoint
|
||||
OidcUserConsent.create!(
|
||||
user: @user,
|
||||
application: @application,
|
||||
scopes_granted: "openid profile",
|
||||
granted_at: Time.current,
|
||||
sid: "test-sid-123"
|
||||
)
|
||||
|
||||
# Create authorization code with PKCE S256
|
||||
auth_code = OidcAuthorizationCode.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
code: SecureRandom.urlsafe_base64(32),
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
scope: "openid profile",
|
||||
code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
|
||||
@@ -183,7 +209,7 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
|
||||
|
||||
token_params = {
|
||||
grant_type: "authorization_code",
|
||||
code: auth_code.code,
|
||||
code: auth_code.plaintext_code,
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
# Use a properly formatted but wrong verifier (43+ chars, base64url)
|
||||
code_verifier: "wrongverifier_with_enough_characters_base64url"
|
||||
@@ -200,6 +226,15 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
|
||||
end
|
||||
|
||||
test "token endpoint accepts valid code_verifier (S256)" do
|
||||
# Create consent for token endpoint
|
||||
OidcUserConsent.create!(
|
||||
user: @user,
|
||||
application: @application,
|
||||
scopes_granted: "openid profile",
|
||||
granted_at: Time.current,
|
||||
sid: "test-sid-123"
|
||||
)
|
||||
|
||||
# Generate valid PKCE pair
|
||||
code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
||||
code_challenge = Digest::SHA256.base64digest(code_verifier)
|
||||
@@ -210,7 +245,6 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
|
||||
auth_code = OidcAuthorizationCode.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
code: SecureRandom.urlsafe_base64(32),
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
scope: "openid profile",
|
||||
code_challenge: code_challenge,
|
||||
@@ -220,7 +254,7 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
|
||||
|
||||
token_params = {
|
||||
grant_type: "authorization_code",
|
||||
code: auth_code.code,
|
||||
code: auth_code.plaintext_code,
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
code_verifier: code_verifier
|
||||
}
|
||||
@@ -237,13 +271,21 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
|
||||
end
|
||||
|
||||
test "token endpoint accepts valid code_verifier (plain)" do
|
||||
# Create consent for token endpoint
|
||||
OidcUserConsent.create!(
|
||||
user: @user,
|
||||
application: @application,
|
||||
scopes_granted: "openid profile",
|
||||
granted_at: Time.current,
|
||||
sid: "test-sid-123"
|
||||
)
|
||||
|
||||
code_verifier = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
|
||||
|
||||
# Create authorization code with PKCE plain
|
||||
auth_code = OidcAuthorizationCode.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
code: SecureRandom.urlsafe_base64(32),
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
scope: "openid profile",
|
||||
code_challenge: code_verifier, # Same as verifier for plain method
|
||||
@@ -253,7 +295,7 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
|
||||
|
||||
token_params = {
|
||||
grant_type: "authorization_code",
|
||||
code: auth_code.code,
|
||||
code: auth_code.plaintext_code,
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
code_verifier: code_verifier
|
||||
}
|
||||
@@ -270,19 +312,308 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
|
||||
end
|
||||
|
||||
test "token endpoint works without PKCE (backward compatibility)" do
|
||||
# Create an application with PKCE not required (legacy behavior)
|
||||
legacy_app = Application.create!(
|
||||
name: "Legacy App",
|
||||
slug: "legacy-app",
|
||||
app_type: "oidc",
|
||||
redirect_uris: ["http://localhost:5000/callback"].to_json,
|
||||
active: true,
|
||||
require_pkce: false
|
||||
)
|
||||
legacy_app.generate_new_client_secret!
|
||||
|
||||
# Create consent for token endpoint
|
||||
OidcUserConsent.create!(
|
||||
user: @user,
|
||||
application: legacy_app,
|
||||
scopes_granted: "openid profile",
|
||||
granted_at: Time.current,
|
||||
sid: "test-sid-123"
|
||||
)
|
||||
|
||||
# Create authorization code without PKCE
|
||||
auth_code = OidcAuthorizationCode.create!(
|
||||
application: @application,
|
||||
application: legacy_app,
|
||||
user: @user,
|
||||
code: SecureRandom.urlsafe_base64(32),
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
redirect_uri: "http://localhost:5000/callback",
|
||||
scope: "openid profile",
|
||||
expires_at: 10.minutes.from_now
|
||||
)
|
||||
|
||||
token_params = {
|
||||
grant_type: "authorization_code",
|
||||
code: auth_code.code,
|
||||
code: auth_code.plaintext_code,
|
||||
redirect_uri: "http://localhost:5000/callback"
|
||||
}
|
||||
|
||||
post "/oauth/token", params: token_params, headers: {
|
||||
"Authorization" => "Basic " + Base64.strict_encode64("#{legacy_app.client_id}:#{legacy_app.client_secret}")
|
||||
}
|
||||
|
||||
assert_response :success
|
||||
tokens = JSON.parse(@response.body)
|
||||
assert tokens.key?("access_token")
|
||||
assert tokens.key?("id_token")
|
||||
assert_equal "Bearer", tokens["token_type"]
|
||||
|
||||
# Cleanup
|
||||
OidcRefreshToken.where(application: legacy_app).delete_all
|
||||
OidcAccessToken.where(application: legacy_app).delete_all
|
||||
OidcAuthorizationCode.where(application: legacy_app).delete_all
|
||||
OidcUserConsent.where(application: legacy_app).delete_all
|
||||
legacy_app.destroy
|
||||
end
|
||||
|
||||
# ====================
|
||||
# PUBLIC CLIENT TESTS
|
||||
# ====================
|
||||
|
||||
test "public client can authenticate with PKCE" do
|
||||
# Create a public client (no client_secret)
|
||||
public_app = Application.create!(
|
||||
name: "Public App",
|
||||
slug: "public-app",
|
||||
app_type: "oidc",
|
||||
redirect_uris: ["http://localhost:6000/callback"].to_json,
|
||||
active: true,
|
||||
is_public_client: true
|
||||
)
|
||||
|
||||
assert public_app.public_client?
|
||||
assert public_app.requires_pkce?
|
||||
assert_nil public_app.client_secret_digest
|
||||
|
||||
# Create consent
|
||||
OidcUserConsent.create!(
|
||||
user: @user,
|
||||
application: public_app,
|
||||
scopes_granted: "openid profile",
|
||||
granted_at: Time.current,
|
||||
sid: "test-sid-123"
|
||||
)
|
||||
|
||||
# PKCE parameters
|
||||
code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
||||
code_challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
|
||||
|
||||
# Create authorization code with PKCE
|
||||
auth_code = OidcAuthorizationCode.create!(
|
||||
application: public_app,
|
||||
user: @user,
|
||||
redirect_uri: "http://localhost:6000/callback",
|
||||
scope: "openid profile",
|
||||
expires_at: 10.minutes.from_now,
|
||||
code_challenge: code_challenge,
|
||||
code_challenge_method: "S256"
|
||||
)
|
||||
|
||||
# Token request with PKCE but no client_secret
|
||||
token_params = {
|
||||
grant_type: "authorization_code",
|
||||
code: auth_code.plaintext_code,
|
||||
redirect_uri: "http://localhost:6000/callback",
|
||||
client_id: public_app.client_id,
|
||||
code_verifier: code_verifier
|
||||
}
|
||||
|
||||
post "/oauth/token", params: token_params
|
||||
|
||||
assert_response :success
|
||||
tokens = JSON.parse(@response.body)
|
||||
assert tokens.key?("access_token")
|
||||
assert tokens.key?("id_token")
|
||||
|
||||
# Cleanup
|
||||
OidcRefreshToken.where(application: public_app).delete_all
|
||||
OidcAccessToken.where(application: public_app).delete_all
|
||||
OidcAuthorizationCode.where(application: public_app).delete_all
|
||||
OidcUserConsent.where(application: public_app).delete_all
|
||||
public_app.destroy
|
||||
end
|
||||
|
||||
test "public client fails without PKCE" do
|
||||
# Create a public client (no client_secret)
|
||||
public_app = Application.create!(
|
||||
name: "Public App No PKCE",
|
||||
slug: "public-app-no-pkce",
|
||||
app_type: "oidc",
|
||||
redirect_uris: ["http://localhost:7000/callback"].to_json,
|
||||
active: true,
|
||||
is_public_client: true
|
||||
)
|
||||
|
||||
assert public_app.public_client?
|
||||
assert public_app.requires_pkce?
|
||||
|
||||
# Create consent
|
||||
OidcUserConsent.create!(
|
||||
user: @user,
|
||||
application: public_app,
|
||||
scopes_granted: "openid profile",
|
||||
granted_at: Time.current,
|
||||
sid: "test-sid-123"
|
||||
)
|
||||
|
||||
# Create authorization code WITHOUT PKCE
|
||||
auth_code = OidcAuthorizationCode.create!(
|
||||
application: public_app,
|
||||
user: @user,
|
||||
redirect_uri: "http://localhost:7000/callback",
|
||||
scope: "openid profile",
|
||||
expires_at: 10.minutes.from_now
|
||||
)
|
||||
|
||||
# Token request without PKCE should fail
|
||||
token_params = {
|
||||
grant_type: "authorization_code",
|
||||
code: auth_code.plaintext_code,
|
||||
redirect_uri: "http://localhost:7000/callback",
|
||||
client_id: public_app.client_id
|
||||
}
|
||||
|
||||
post "/oauth/token", params: token_params
|
||||
|
||||
assert_response :bad_request
|
||||
error = JSON.parse(@response.body)
|
||||
assert_equal "invalid_request", error["error"]
|
||||
assert_match(/PKCE is required for public clients/, error["error_description"])
|
||||
|
||||
# Cleanup
|
||||
OidcRefreshToken.where(application: public_app).delete_all
|
||||
OidcAccessToken.where(application: public_app).delete_all
|
||||
OidcAuthorizationCode.where(application: public_app).delete_all
|
||||
OidcUserConsent.where(application: public_app).delete_all
|
||||
public_app.destroy
|
||||
end
|
||||
|
||||
test "confidential client with require_pkce fails without PKCE" do
|
||||
# The default @application has require_pkce: true (default)
|
||||
assert @application.confidential_client?
|
||||
assert @application.requires_pkce?
|
||||
|
||||
# Create consent
|
||||
OidcUserConsent.create!(
|
||||
user: @user,
|
||||
application: @application,
|
||||
scopes_granted: "openid profile",
|
||||
granted_at: Time.current,
|
||||
sid: "test-sid-pkce-required"
|
||||
)
|
||||
|
||||
# Create authorization code WITHOUT PKCE
|
||||
auth_code = OidcAuthorizationCode.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
scope: "openid profile",
|
||||
expires_at: 10.minutes.from_now
|
||||
)
|
||||
|
||||
# Token request without PKCE should fail
|
||||
token_params = {
|
||||
grant_type: "authorization_code",
|
||||
code: auth_code.plaintext_code,
|
||||
redirect_uri: "http://localhost:4000/callback"
|
||||
}
|
||||
|
||||
post "/oauth/token", params: token_params, headers: {
|
||||
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@application.client_secret}")
|
||||
}
|
||||
|
||||
assert_response :bad_request
|
||||
error = JSON.parse(@response.body)
|
||||
assert_equal "invalid_request", error["error"]
|
||||
assert_match(/PKCE is required/, error["error_description"])
|
||||
end
|
||||
|
||||
# ====================
|
||||
# AUTH_TIME CLAIM TESTS
|
||||
# ====================
|
||||
|
||||
test "ID token includes auth_time claim from authorization code" do
|
||||
# Create consent
|
||||
OidcUserConsent.create!(
|
||||
user: @user,
|
||||
application: @application,
|
||||
scopes_granted: "openid profile",
|
||||
granted_at: Time.current,
|
||||
sid: "test-sid-auth-time"
|
||||
)
|
||||
|
||||
# Generate valid PKCE pair
|
||||
code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
||||
code_challenge = Digest::SHA256.base64digest(code_verifier)
|
||||
.tr("+/", "-_")
|
||||
.tr("=", "")
|
||||
|
||||
# Get the expected auth_time from the session's created_at
|
||||
expected_auth_time = Current.session.created_at.to_i
|
||||
|
||||
# Create authorization code with auth_time
|
||||
auth_code = OidcAuthorizationCode.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
scope: "openid profile",
|
||||
code_challenge: code_challenge,
|
||||
code_challenge_method: "S256",
|
||||
auth_time: expected_auth_time,
|
||||
expires_at: 10.minutes.from_now
|
||||
)
|
||||
|
||||
token_params = {
|
||||
grant_type: "authorization_code",
|
||||
code: auth_code.plaintext_code,
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
code_verifier: code_verifier
|
||||
}
|
||||
|
||||
post "/oauth/token", params: token_params, headers: {
|
||||
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@application.client_secret}")
|
||||
}
|
||||
|
||||
assert_response :success
|
||||
tokens = JSON.parse(@response.body)
|
||||
assert tokens.key?("id_token")
|
||||
|
||||
# Decode and verify auth_time is present and matches what we stored
|
||||
decoded = JWT.decode(tokens["id_token"], nil, false).first
|
||||
assert_includes decoded.keys, "auth_time", "ID token should include auth_time"
|
||||
assert_equal expected_auth_time, decoded["auth_time"], "auth_time should match authorization code"
|
||||
end
|
||||
|
||||
test "ID token includes auth_time in refresh token flow" do
|
||||
# Create consent
|
||||
OidcUserConsent.create!(
|
||||
user: @user,
|
||||
application: @application,
|
||||
scopes_granted: "openid profile offline_access",
|
||||
granted_at: Time.current,
|
||||
sid: "test-sid-refresh-auth-time"
|
||||
)
|
||||
|
||||
# Get the expected auth_time from the session's created_at
|
||||
expected_auth_time = Current.session.created_at.to_i
|
||||
|
||||
# Create initial access and refresh tokens with auth_time
|
||||
auth_code = OidcAuthorizationCode.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
scope: "openid profile offline_access",
|
||||
code_challenge: nil,
|
||||
code_challenge_method: nil,
|
||||
auth_time: expected_auth_time,
|
||||
expires_at: 10.minutes.from_now
|
||||
)
|
||||
|
||||
# Update application to not require PKCE for testing
|
||||
@application.update!(require_pkce: false)
|
||||
|
||||
token_params = {
|
||||
grant_type: "authorization_code",
|
||||
code: auth_code.plaintext_code,
|
||||
redirect_uri: "http://localhost:4000/callback"
|
||||
}
|
||||
|
||||
@@ -292,8 +623,77 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
|
||||
|
||||
assert_response :success
|
||||
tokens = JSON.parse(@response.body)
|
||||
assert tokens.key?("access_token")
|
||||
assert tokens.key?("id_token")
|
||||
assert_equal "Bearer", tokens["token_type"]
|
||||
refresh_token = tokens["refresh_token"]
|
||||
|
||||
# Now use the refresh token
|
||||
refresh_params = {
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refresh_token
|
||||
}
|
||||
|
||||
post "/oauth/token", params: refresh_params, headers: {
|
||||
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@application.client_secret}")
|
||||
}
|
||||
|
||||
assert_response :success
|
||||
new_tokens = JSON.parse(@response.body)
|
||||
assert new_tokens.key?("id_token")
|
||||
|
||||
# Decode and verify auth_time is preserved from original authorization
|
||||
decoded = JWT.decode(new_tokens["id_token"], nil, false).first
|
||||
assert_includes decoded.keys, "auth_time", "Refreshed ID token should include auth_time"
|
||||
assert_equal expected_auth_time, decoded["auth_time"], "auth_time should match original authorization code"
|
||||
end
|
||||
end
|
||||
|
||||
test "at_hash is correctly computed and included in ID token" do
|
||||
# Create consent
|
||||
OidcUserConsent.create!(
|
||||
user: @user,
|
||||
application: @application,
|
||||
scopes_granted: "openid profile",
|
||||
granted_at: Time.current,
|
||||
sid: "test-sid-at-hash"
|
||||
)
|
||||
|
||||
# Generate valid PKCE pair
|
||||
code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
||||
code_challenge = Digest::SHA256.base64digest(code_verifier)
|
||||
.tr("+/", "-_")
|
||||
.tr("=", "")
|
||||
|
||||
# Create authorization code
|
||||
auth_code = OidcAuthorizationCode.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
scope: "openid profile",
|
||||
code_challenge: code_challenge,
|
||||
code_challenge_method: "S256",
|
||||
expires_at: 10.minutes.from_now
|
||||
)
|
||||
|
||||
token_params = {
|
||||
grant_type: "authorization_code",
|
||||
code: auth_code.plaintext_code,
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
code_verifier: code_verifier
|
||||
}
|
||||
|
||||
post "/oauth/token", params: token_params, headers: {
|
||||
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@application.client_secret}")
|
||||
}
|
||||
|
||||
assert_response :success
|
||||
tokens = JSON.parse(@response.body)
|
||||
access_token = tokens["access_token"]
|
||||
id_token = tokens["id_token"]
|
||||
|
||||
# Decode ID token
|
||||
decoded = JWT.decode(id_token, nil, false).first
|
||||
assert_includes decoded.keys, "at_hash", "ID token should include at_hash"
|
||||
|
||||
# Verify at_hash matches the access token hash
|
||||
expected_hash = Base64.urlsafe_encode64(Digest::SHA256.digest(access_token)[0..15], padding: false)
|
||||
assert_equal expected_hash, decoded["at_hash"], "at_hash should match SHA-256 hash of access token"
|
||||
end
|
||||
end
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user