61 Commits

Author SHA1 Message Date
Dan Milne
0bb84f08d6 OpenID conformance test: we get a warning for not having a value for every claim. But we can explictly list support claims. Nothing we can do about a warning in the complience.
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / scan_container (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2026-01-02 16:35:12 +11:00
Dan Milne
182682024d OpenID Conformance: Include all required scopes when profile is requested, even if they're empty
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / scan_container (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2026-01-02 15:47:40 +11:00
Dan Milne
b517ebe809 OpenID conformance test: Allow posting the access token in the body for userinfo endpoint
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / scan_container (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2026-01-02 15:41:07 +11:00
Dan Milne
dd8bd15a76 CSRF issue with API endpoint
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / scan_container (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2026-01-02 15:29:34 +11:00
Dan Milne
f67a73821c OpenID Conformance: user info endpoint should support get and post requets, not just get
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / scan_container (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2026-01-02 15:26:39 +11:00
Dan Milne
b09ddf6db5 OpenID Conformance: We need to return to the redirect_uri in the case of errors.
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / scan_container (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2026-01-02 15:12:55 +11:00
Dan Milne
abbb11a41d Return only scopes requested, add tests ( OpenID conformance test )
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / scan_container (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2026-01-02 14:55:06 +11:00
Dan Milne
b2030df8c2 Return only scopes requested ( OpenID conformance test. Update README 2026-01-02 14:05:54 +11:00
Dan Milne
07cddf5823 Version bump
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / scan_container (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2026-01-02 12:57:28 +11:00
Dan Milne
46aa983189 Don't use secret scanner for trivy - github already does it and it's hard to ignore the test key
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / scan_container (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2026-01-02 12:56:03 +11:00
Dan Milne
d0d79ee1da Try ignore capybara's test tripping trivy
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / scan_container (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2026-01-02 12:52:24 +11:00
Dan Milne
2f6a2c7406 Update ruby 3.4.6 -> 3.4.7. Update gems. Add trivy scanning and ignore unfixable Debian CVEs. Ignore a test fixture key for Capybara
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / scan_container (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2026-01-02 12:48:40 +11:00
Dan Milne
5137a25626 Add remainging rate limits. Add docker compose production example. Update beta-checklist.
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2026-01-02 12:14:13 +11:00
Dan Milne
fed7c3cedb Some beta-checklist updates 2026-01-02 11:53:41 +11:00
Dan Milne
e288fcad7c Remove old docs 2026-01-01 21:04:26 +11:00
Dan Milne
c1c6e0112e ADd backup / restore documentation
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2026-01-01 15:40:49 +11:00
Dan Milne
7f834fb7fa Version bump 2026-01-01 15:27:19 +11:00
Dan Milne
ae99d3d9cf Fix webauthn bug. Fix tests. Update docs
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2026-01-01 15:24:56 +11:00
Dan Milne
1afcd041f9 Update README, fix a test 2026-01-01 15:17:28 +11:00
Dan Milne
71198340d0 fix tests and add a Claude.md file
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2026-01-01 15:11:46 +11:00
Dan Milne
d597ca8810 Fix tests 2026-01-01 14:52:24 +11:00
Dan Milne
9b81aee490 Fix linting error
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2026-01-01 13:45:10 +11:00
Dan Milne
265518ab25 Move integration tests into right directory
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2026-01-01 13:43:13 +11:00
Dan Milne
adb789bbea Fix StandardRB
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2026-01-01 13:35:37 +11:00
Dan Milne
93a0edb0a2 StandardRB fixes
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2026-01-01 13:29:44 +11:00
Dan Milne
7d3af2bcec SRB fixes 2026-01-01 13:19:17 +11:00
Dan Milne
c03034c49f Add files to support brakeman and standardrb. Fix some SRB warnings 2026-01-01 13:18:30 +11:00
Dan Milne
9234904e47 Add security-todo and beta-checklists, and some security rake tasks
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2026-01-01 13:06:54 +11:00
Dan Milne
e36a9a781a Add new claims to the discovery endpoint
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2025-12-31 17:27:28 +11:00
Dan Milne
d036e25fef Add auth_time, acr and azp support for OIDC claims 2025-12-31 17:07:54 +11:00
Dan Milne
fcdd2b6de7 Continue adding auth_time - need it in the refresh token too, so we can accurately create new access tokens. 2025-12-31 16:57:28 +11:00
Dan Milne
3939ea773f We already have a login_time stored - the time stamp of the Session instance creation ( created after successful login ). 2025-12-31 16:45:45 +11:00
Dan Milne
4b4afe277e Include auth_time in ID token. Switch from upsert -> find_and_create_by so we actually get sid values for consent on the creation of the record 2025-12-31 16:36:32 +11:00
Dan Milne
364e6e21dd Fixes for tests and AR Encryption
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2025-12-31 16:08:05 +11:00
Dan Milne
9d352ab8ec Fix tests - add missing files 2025-12-31 16:01:31 +11:00
Dan Milne
d1d4ac745f Version bump 2025-12-31 15:48:52 +11:00
Dan Milne
3db466f5a2 Switch Access / Refresh tokens / Auth Code from bcrypt ( and plain ) to hmac. BCrypt is for low entropy passwords and prevents dictionary attacks - HMAC is suitable for 256-bit random data.
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2025-12-31 15:48:32 +11:00
Dan Milne
7c6ae7ab7e Store only HMAC'd Auth codes, rather than plain text auth codes. 2025-12-31 15:00:00 +11:00
Dan Milne
ed7ceedef5 Include the hash of the access token in the JWT / ID Token under the key at_hash as per the requirements. Update the discovery endpoint to describe subject_type as 'pairwise', rather than 'public', since we do pairwise subject ids.
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2025-12-31 14:45:38 +11:00
Dan Milne
40815d3576 Use SolidQueue in production. Use the find_by_token method, rather than iterating over refresh tokens, as we already fixed for tokens 2025-12-31 14:32:34 +11:00
Dan Milne
a17c08c890 Improve the README 2025-12-31 14:31:53 +11:00
Dan Milne
4f31fadc6c Improve the README and remove incorrect claims.
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2025-12-31 12:17:15 +11:00
Dan Milne
29c0981a59 Improve readme and tests
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2025-12-31 11:56:09 +11:00
Dan Milne
9d402fcd92 Clean up and secure web_authn controller
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2025-12-31 11:44:11 +11:00
Dan Milne
9530c8284f Version bump
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2025-12-31 10:35:27 +11:00
Dan Milne
bb5aa2e6d6 Add rails encryption for totp - allow configuration of encryption secrets from env, or derive them from SECRET_KEY_BASE. Don't leak email address via web_authn, rate limit web_authn, escape oidc state value, require password for changing email address, allow settings the hmac secret for token prefix generation 2025-12-31 10:33:56 +11:00
Dan Milne
cc7beba9de PKCE is now default enabled. You can now create public / no-secret apps OIDC apps 2025-12-31 09:22:18 +11:00
Dan Milne
00eca6d8b2 Default deny forward_auth requests 2025-12-30 16:04:01 +11:00
Dan Milne
32235f9647 version bump
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2025-12-30 11:58:31 +11:00
Dan Milne
71d59e7367 Remove plain text token from everywhere
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2025-12-30 11:58:11 +11:00
Dan Milne
99c3ac905f Add a token prefix column, generate the token_prefix and the token_digest, removing the plaintext token from use. 2025-12-30 09:45:16 +11:00
Dan Milne
0761c424c1 Fix tests. Remove tests which test rails functionality
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2025-12-30 00:18:19 +11:00
Dan Milne
2a32d75895 Fix tests - don't test standard rails features 2025-12-29 19:45:01 +11:00
Dan Milne
4c1df53fd5 Fix more tests
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2025-12-29 19:22:08 +11:00
Dan Milne
acab15ce30 Fix more tests 2025-12-29 18:48:41 +11:00
Dan Milne
0361bfe470 Fix forward_auth bugs - including disabled apps still working. Fix forward_auth tests
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2025-12-29 15:37:12 +11:00
Dan Milne
5b9d15584a Add more rate limiting, and more restrictive headers 2025-12-29 13:29:14 +11:00
Dan Milne
898fd69a5d Add permissions initializer and missing image paste controller
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2025-12-29 13:27:30 +11:00
Dan Milne
9cf01f7c7a Bump versoin 2025-12-28 14:43:26 +11:00
Dan Milne
ab362aabac Remove the rate limit for the forward auth system
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2025-12-28 14:40:53 +11:00
Dan Milne
283feea175 Update depenencies, bump versoin 2025-11-30 23:13:25 +11:00
129 changed files with 9224 additions and 2226 deletions

View File

@@ -1,5 +1,21 @@
# Rails Configuration # 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 RAILS_ENV=development
# Database # Database

View File

@@ -19,7 +19,9 @@ jobs:
bundler-cache: true bundler-cache: true
- name: Scan for common Rails security vulnerabilities using static analysis - 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 - name: Scan for known security vulnerabilities in gems used
run: bin/bundler-audit run: bin/bundler-audit
@@ -39,10 +41,36 @@ jobs:
- name: Scan for security vulnerabilities in JavaScript dependencies - name: Scan for security vulnerabilities in JavaScript dependencies
run: bin/importmap audit run: bin/importmap audit
scan_container:
runs-on: ubuntu-latest
permissions:
security-events: write # Required for uploading SARIF results
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Build Docker image
run: docker build -t clinch:${{ github.sha }} .
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
image-ref: clinch:${{ github.sha }}
format: 'sarif'
output: 'trivy-results.sarif'
severity: 'CRITICAL,HIGH'
scanners: 'vuln' # Only scan vulnerabilities, not secrets (avoids false positives in vendored gems)
- name: Upload Trivy results to GitHub Security tab
uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: 'trivy-results.sarif'
lint: lint:
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
RUBOCOP_CACHE_ROOT: tmp/rubocop
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v5 uses: actions/checkout@v5
@@ -52,18 +80,8 @@ jobs:
with: with:
bundler-cache: true bundler-cache: true
- name: Prepare RuboCop cache
uses: actions/cache@v4
env:
DEPENDENCIES_HASH: ${{ hashFiles('.ruby-version', '**/.rubocop.yml', '**/.rubocop_todo.yml', 'Gemfile.lock') }}
with:
path: ${{ env.RUBOCOP_CACHE_ROOT }}
key: rubocop-${{ runner.os }}-${{ env.DEPENDENCIES_HASH }}-${{ github.ref_name == github.event.repository.default_branch && github.run_id || 'default' }}
restore-keys: |
rubocop-${{ runner.os }}-${{ env.DEPENDENCIES_HASH }}-
- name: Lint code for consistent style - name: Lint code for consistent style
run: bin/rubocop -f github run: bin/standardrb
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@@ -1 +1 @@
3.4.6 3.4.8

7
.standard.yml Normal file
View File

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

48
.trivyignore Normal file
View File

@@ -0,0 +1,48 @@
# Trivy ignore file
# This file tells Trivy to skip specific vulnerabilities or files
# See: https://aquasecurity.github.io/trivy/latest/docs/configuration/filtering/
# =============================================================================
# False Positives - Test Fixtures
# =============================================================================
# Capybara test fixture - not a real private key
# Ignore secrets in test fixtures
# Format: secret:<rule-id>:<exact-file-path>
secret:private-key:/usr/local/bundle/ruby/3.4.0/gems/capybara-3.40.0/spec/fixtures/key.pem
# =============================================================================
# Unfixable CVEs - No Patches Available (Status: affected/fix_deferred)
# =============================================================================
# GnuPG vulnerabilities - not used by Clinch at runtime
# Low risk: dirmngr/gpg tools not invoked during normal operation
CVE-2025-68973
# Image processing library vulnerabilities
# Low risk for Clinch: Only admins upload images (app icons), not untrusted users
# Waiting on Debian security team to release patches
# ImageMagick - Integer overflow (32-bit only)
CVE-2025-66628
# glib - Integer overflow in URI escaping
CVE-2025-13601
# HDF5 - Critical vulnerabilities in scientific data format library
CVE-2025-2153
CVE-2025-2308
CVE-2025-2309
CVE-2025-2310
# libmatio - MATLAB file format library
CVE-2025-2338
# OpenEXR - Image format vulnerabilities
CVE-2025-12495
CVE-2025-12839
CVE-2025-12840
CVE-2025-64181
# libvips - Image processing library
CVE-2025-59933

65
Claude.md Normal file
View File

@@ -0,0 +1,65 @@
# Claude Code Guidelines for Clinch
This document provides guidelines for AI assistants (Claude, ChatGPT, etc.) working on this codebase.
## Project Context
Clinch is a lightweight identity provider (IdP) supporting:
- **OIDC/OAuth2** - Standard OAuth flows for modern apps
- **ForwardAuth** - Trusted-header SSO for reverse proxies (Traefik, Caddy, Nginx)
- **WebAuthn/Passkeys** - Passwordless authentication
- Group-based access control
Key characteristics:
- Rails 8 application with SQLite database
- Focus on simplicity and self-hosting
- No external dependencies for core functionality
## Testing Guidelines
### Do Not Test Rails Framework Functionality
When writing tests, focus on testing **our application's specific behavior and logic**, not standard Rails framework functionality.
**Examples of what NOT to test:**
- Session isolation between users (Rails handles this)
- Basic ActiveRecord associations (Rails handles this)
- Standard cookie signing/verification (Rails handles this)
- Default controller rendering behavior (Rails handles this)
- Infrastructure-level error handling (database connection failures, network issues, etc.)
**Examples of what TO test:**
- Forward auth business logic (group-based access control, header configuration, etc.)
- Custom authentication flows
- Application-specific session expiration behavior
- Domain pattern matching logic
- Custom response header generation
**Why:**
Testing Rails framework functionality adds no value and can create maintenance burden. Trust that Rails works correctly and focus tests on verifying our application's unique behavior.
### Integration Test Patterns
**Session handling:**
- Do NOT manually manipulate cookies in integration tests
- Use the session provided by the test framework
- To get the actual session ID, use `Session.last.id` after sign-in, not `cookies[:session_id]` (which is signed)
**Application setup:**
- Always create Application records for the domains you're testing
- Use wildcard patterns (e.g., `*.example.com`) when testing multiple subdomains
- Remember: `*` matches one level only (`*.example.com` matches `app.example.com` but NOT `sub.app.example.com`)
**Header assertions:**
- Always normalize header names to lowercase when asserting (HTTP headers are case-insensitive)
- Use `response.headers["x-remote-user"]` not `response.headers["X-Remote-User"]`
**Avoid threading in integration tests:**
- Rails integration tests use a single cookie jar
- Convert threaded tests to sequential requests instead
### Common Testing Pitfalls
1. **Don't test concurrent users with manual cookie manipulation** - Integration tests can't properly simulate multiple concurrent sessions
2. **Don't expect `cookies[:session_id]` to be the actual ID** - It's a signed cookie value
3. **Don't assume wildcard patterns match multiple levels** - `*.domain.com` only matches one level

View File

@@ -8,14 +8,17 @@
# For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html # For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html
# Make sure RUBY_VERSION matches the Ruby version in .ruby-version # Make sure RUBY_VERSION matches the Ruby version in .ruby-version
ARG RUBY_VERSION=3.4.6 ARG RUBY_VERSION=3.4.8
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
LABEL org.opencontainers.image.source=https://github.com/dkam/clinch
# Rails app lives here # Rails app lives here
WORKDIR /rails WORKDIR /rails
# Install base packages # Install base packages and upgrade to latest security patches
RUN apt-get update -qq && \ RUN apt-get update -qq && \
apt-get upgrade -y && \
apt-get install --no-install-recommends -y curl libjemalloc2 libvips sqlite3 && \ apt-get install --no-install-recommends -y curl libjemalloc2 libvips sqlite3 && \
ln -s /usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2 /usr/local/lib/libjemalloc.so && \ ln -s /usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2 /usr/local/lib/libjemalloc.so && \
rm -rf /var/lib/apt/lists /var/cache/apt/archives rm -rf /var/lib/apt/lists /var/cache/apt/archives

17
Gemfile
View File

@@ -35,11 +35,11 @@ gem "jwt", "~> 3.1"
gem "webauthn", "~> 3.0" gem "webauthn", "~> 3.0"
# Public Suffix List for domain parsing # Public Suffix List for domain parsing
gem "public_suffix", "~> 6.0" gem "public_suffix", "~> 7.0"
# Error tracking and performance monitoring (optional, configured via SENTRY_DSN) # Error tracking and performance monitoring (optional, configured via SENTRY_DSN)
gem "sentry-ruby", "~> 5.18" gem "sentry-ruby", "~> 6.2"
gem "sentry-rails", "~> 5.18" gem "sentry-rails", "~> 6.2"
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem # Windows does not include zoneinfo files, so bundle the tzinfo-data gem
gem "tzinfo-data", platforms: %i[windows jruby] gem "tzinfo-data", platforms: %i[windows jruby]
@@ -47,6 +47,7 @@ gem "tzinfo-data", platforms: %i[ windows jruby ]
# Use the database-backed adapters for Rails.cache and Action Cable # Use the database-backed adapters for Rails.cache and Action Cable
gem "solid_cache" gem "solid_cache"
gem "solid_cable" gem "solid_cable"
gem "solid_queue", "~> 1.2"
# Reduces boot times through caching; required in config/boot.rb # Reduces boot times through caching; required in config/boot.rb
gem "bootsnap", require: false gem "bootsnap", require: false
@@ -70,8 +71,8 @@ group :development, :test do
# Static analysis for security vulnerabilities [https://brakemanscanner.org/] # Static analysis for security vulnerabilities [https://brakemanscanner.org/]
gem "brakeman", require: false gem "brakeman", require: false
# Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/] # Standard Ruby style guide, linter, and formatter [https://github.com/standardrb/standard]
gem "rubocop-rails-omakase", require: false gem "standard", require: false
end end
group :development do group :development do
@@ -86,4 +87,10 @@ group :test do
# Use system testing [https://guides.rubyonrails.org/testing.html#system-testing] # Use system testing [https://guides.rubyonrails.org/testing.html#system-testing]
gem "capybara" gem "capybara"
gem "selenium-webdriver" gem "selenium-webdriver"
# Code coverage analysis
gem "simplecov", require: false
# Pin minitest to < 6.0 until Rails 8.1 supports the new API
gem "minitest", "< 6.0"
end end

View File

@@ -1,7 +1,7 @@
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
action_text-trix (2.1.15) action_text-trix (2.1.16)
railties railties
actioncable (8.1.1) actioncable (8.1.1)
actionpack (= 8.1.1) actionpack (= 8.1.1)
@@ -75,23 +75,23 @@ GEM
securerandom (>= 0.3) securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5) tzinfo (~> 2.0, >= 2.0.5)
uri (>= 0.13.1) uri (>= 0.13.1)
addressable (2.8.7) addressable (2.8.8)
public_suffix (>= 2.0.2, < 7.0) public_suffix (>= 2.0.2, < 8.0)
android_key_attestation (0.3.0) android_key_attestation (0.3.0)
ast (2.4.3) ast (2.4.3)
base64 (0.3.0) base64 (0.3.0)
bcrypt (3.1.20) bcrypt (3.1.21)
bcrypt_pbkdf (1.1.1) bcrypt_pbkdf (1.1.2)
bigdecimal (3.3.1) bigdecimal (4.0.1)
bindata (2.5.1) bindata (2.5.1)
bindex (0.8.1) bindex (0.8.1)
bootsnap (1.18.6) bootsnap (1.20.1)
msgpack (~> 1.2) msgpack (~> 1.2)
brakeman (7.1.0) brakeman (7.1.2)
racc racc
builder (3.3.0) builder (3.3.0)
bundler-audit (0.9.2) bundler-audit (0.9.3)
bundler (>= 1.2.0, < 3) bundler (>= 1.2.0)
thor (~> 1.0) thor (~> 1.0)
capybara (3.40.0) capybara (3.40.0)
addressable addressable
@@ -106,31 +106,37 @@ GEM
childprocess (5.1.0) childprocess (5.1.0)
logger (~> 1.5) logger (~> 1.5)
chunky_png (1.4.0) chunky_png (1.4.0)
concurrent-ruby (1.3.5) concurrent-ruby (1.3.6)
connection_pool (2.5.4) connection_pool (3.0.2)
cose (1.3.1) cose (1.3.1)
cbor (~> 0.5.9) cbor (~> 0.5.9)
openssl-signature_algorithm (~> 1.0) openssl-signature_algorithm (~> 1.0)
crass (1.0.6) crass (1.0.6)
date (3.5.0) date (3.5.1)
debug (1.11.0) debug (1.11.1)
irb (~> 1.10) irb (~> 1.10)
reline (>= 0.3.8) reline (>= 0.3.8)
dotenv (3.1.8) docile (1.4.1)
dotenv (3.2.0)
drb (2.2.3) drb (2.2.3)
ed25519 (1.4.0) ed25519 (1.4.0)
erb (5.1.3) erb (6.0.1)
erubi (1.13.1) erubi (1.13.1)
ffi (1.17.2-aarch64-linux-gnu) et-orbi (1.4.0)
ffi (1.17.2-aarch64-linux-musl) tzinfo
ffi (1.17.2-arm-linux-gnu) ffi (1.17.3-aarch64-linux-gnu)
ffi (1.17.2-arm-linux-musl) ffi (1.17.3-aarch64-linux-musl)
ffi (1.17.2-arm64-darwin) ffi (1.17.3-arm-linux-gnu)
ffi (1.17.2-x86_64-linux-gnu) ffi (1.17.3-arm-linux-musl)
ffi (1.17.2-x86_64-linux-musl) ffi (1.17.3-arm64-darwin)
ffi (1.17.3-x86_64-linux-gnu)
ffi (1.17.3-x86_64-linux-musl)
fugit (1.12.1)
et-orbi (~> 1.4)
raabro (~> 1.4)
globalid (1.3.0) globalid (1.3.0)
activesupport (>= 6.1) activesupport (>= 6.1)
i18n (1.14.7) i18n (1.14.8)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
image_processing (1.14.0) image_processing (1.14.0)
mini_magick (>= 4.9.5, < 6) mini_magick (>= 4.9.5, < 6)
@@ -139,18 +145,18 @@ GEM
actionpack (>= 6.0.0) actionpack (>= 6.0.0)
activesupport (>= 6.0.0) activesupport (>= 6.0.0)
railties (>= 6.0.0) railties (>= 6.0.0)
io-console (0.8.1) io-console (0.8.2)
irb (1.15.3) irb (1.16.0)
pp (>= 0.6.0) pp (>= 0.6.0)
rdoc (>= 4.0.0) rdoc (>= 4.0.0)
reline (>= 0.4.2) reline (>= 0.4.2)
jbuilder (2.14.1) jbuilder (2.14.1)
actionview (>= 7.0.0) actionview (>= 7.0.0)
activesupport (>= 7.0.0) activesupport (>= 7.0.0)
json (2.15.2) json (2.18.0)
jwt (3.1.2) jwt (3.1.2)
base64 base64
kamal (2.8.1) kamal (2.10.1)
activesupport (>= 7.0) activesupport (>= 7.0)
base64 (~> 0.2) base64 (~> 0.2)
bcrypt_pbkdf (~> 1.0) bcrypt_pbkdf (~> 1.0)
@@ -170,7 +176,7 @@ GEM
launchy (>= 2.2, < 4) launchy (>= 2.2, < 4)
lint_roller (1.1.0) lint_roller (1.1.0)
logger (1.7.0) logger (1.7.0)
loofah (2.24.1) loofah (2.25.0)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.12.0) nokogiri (>= 1.12.0)
mail (2.9.0) mail (2.9.0)
@@ -184,9 +190,9 @@ GEM
mini_magick (5.3.1) mini_magick (5.3.1)
logger logger
mini_mime (1.1.5) mini_mime (1.1.5)
minitest (5.26.0) minitest (5.27.0)
msgpack (1.8.0) msgpack (1.8.0)
net-imap (0.5.12) net-imap (0.6.2)
date date
net-protocol net-protocol
net-pop (0.1.2) net-pop (0.1.2)
@@ -201,42 +207,43 @@ GEM
net-protocol net-protocol
net-ssh (7.3.0) net-ssh (7.3.0)
nio4r (2.7.5) nio4r (2.7.5)
nokogiri (1.18.10-aarch64-linux-gnu) nokogiri (1.19.0-aarch64-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.10-aarch64-linux-musl) nokogiri (1.19.0-aarch64-linux-musl)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.10-arm-linux-gnu) nokogiri (1.19.0-arm-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.10-arm-linux-musl) nokogiri (1.19.0-arm-linux-musl)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.10-arm64-darwin) nokogiri (1.19.0-arm64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.10-x86_64-linux-gnu) nokogiri (1.19.0-x86_64-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.10-x86_64-linux-musl) nokogiri (1.19.0-x86_64-linux-musl)
racc (~> 1.4) racc (~> 1.4)
openssl (3.3.2) openssl (4.0.0)
openssl-signature_algorithm (1.3.0) openssl-signature_algorithm (1.3.0)
openssl (> 2.0) openssl (> 2.0)
ostruct (0.6.3) ostruct (0.6.3)
parallel (1.27.0) parallel (1.27.0)
parser (3.3.9.0) parser (3.3.10.0)
ast (~> 2.4.1) ast (~> 2.4.1)
racc racc
pp (0.6.3) pp (0.6.3)
prettyprint prettyprint
prettyprint (0.2.0) prettyprint (0.2.0)
prism (1.6.0) prism (1.7.0)
propshaft (1.3.1) propshaft (1.3.1)
actionpack (>= 7.0.0) actionpack (>= 7.0.0)
activesupport (>= 7.0.0) activesupport (>= 7.0.0)
rack rack
psych (5.2.6) psych (5.3.1)
date date
stringio stringio
public_suffix (6.0.2) public_suffix (7.0.0)
puma (7.1.0) puma (7.1.0)
nio4r (~> 2.0) nio4r (~> 2.0)
raabro (1.4.0)
racc (1.8.1) racc (1.8.1)
rack (3.2.4) rack (3.2.4)
rack-session (2.1.1) rack-session (2.1.1)
@@ -244,7 +251,7 @@ GEM
rack (>= 3.0.0) rack (>= 3.0.0)
rack-test (2.2.0) rack-test (2.2.0)
rack (>= 1.3) rack (>= 1.3)
rackup (2.2.1) rackup (2.3.1)
rack (>= 3) rack (>= 3)
rails (8.1.1) rails (8.1.1)
actioncable (= 8.1.1) actioncable (= 8.1.1)
@@ -278,20 +285,20 @@ GEM
zeitwerk (~> 2.6) zeitwerk (~> 2.6)
rainbow (3.1.1) rainbow (3.1.1)
rake (13.3.1) rake (13.3.1)
rdoc (6.15.1) rdoc (7.0.3)
erb erb
psych (>= 4.0.0) psych (>= 4.0.0)
tsort tsort
regexp_parser (2.11.3) regexp_parser (2.11.3)
reline (0.6.2) reline (0.6.3)
io-console (~> 0.5) io-console (~> 0.5)
rexml (3.4.4) rexml (3.4.4)
rotp (6.3.0) rotp (6.3.0)
rqrcode (3.1.0) rqrcode (3.1.1)
chunky_png (~> 1.0) chunky_png (~> 1.0)
rqrcode_core (~> 2.0) rqrcode_core (~> 2.0)
rqrcode_core (2.0.0) rqrcode_core (2.0.1)
rubocop (1.81.6) rubocop (1.81.7)
json (~> 2.3) json (~> 2.3)
language_server-protocol (~> 3.17.0.2) language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0) lint_roller (~> 1.1.0)
@@ -302,98 +309,113 @@ GEM
rubocop-ast (>= 1.47.1, < 2.0) rubocop-ast (>= 1.47.1, < 2.0)
ruby-progressbar (~> 1.7) ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0) unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.47.1) rubocop-ast (1.49.0)
parser (>= 3.3.7.2) parser (>= 3.3.7.2)
prism (~> 1.4) prism (~> 1.7)
rubocop-performance (1.26.1) rubocop-performance (1.26.1)
lint_roller (~> 1.1) lint_roller (~> 1.1)
rubocop (>= 1.75.0, < 2.0) rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.47.1, < 2.0) rubocop-ast (>= 1.47.1, < 2.0)
rubocop-rails (2.33.4)
activesupport (>= 4.2.0)
lint_roller (~> 1.1)
rack (>= 1.1)
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.44.0, < 2.0)
rubocop-rails-omakase (1.1.0)
rubocop (>= 1.72)
rubocop-performance (>= 1.24)
rubocop-rails (>= 2.30)
ruby-progressbar (1.13.0) ruby-progressbar (1.13.0)
ruby-vips (2.2.5) ruby-vips (2.3.0)
ffi (~> 1.12) ffi (~> 1.12)
logger logger
rubyzip (3.2.1) rubyzip (3.2.2)
safety_net_attestation (0.5.0) safety_net_attestation (0.5.0)
jwt (>= 2.0, < 4.0) jwt (>= 2.0, < 4.0)
securerandom (0.4.1) securerandom (0.4.1)
selenium-webdriver (4.38.0) selenium-webdriver (4.39.0)
base64 (~> 0.2) base64 (~> 0.2)
logger (~> 1.4) logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5) rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 4.0) rubyzip (>= 1.2.2, < 4.0)
websocket (~> 1.0) websocket (~> 1.0)
sentry-rails (5.28.0) sentry-rails (6.2.0)
railties (>= 5.0) railties (>= 5.2.0)
sentry-ruby (~> 5.28.0) sentry-ruby (~> 6.2.0)
sentry-ruby (5.28.0) sentry-ruby (6.2.0)
bigdecimal bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2) 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) solid_cable (3.0.12)
actioncable (>= 7.2) actioncable (>= 7.2)
activejob (>= 7.2) activejob (>= 7.2)
activerecord (>= 7.2) activerecord (>= 7.2)
railties (>= 7.2) railties (>= 7.2)
solid_cache (1.0.8) solid_cache (1.0.10)
activejob (>= 7.2) activejob (>= 7.2)
activerecord (>= 7.2) activerecord (>= 7.2)
railties (>= 7.2) railties (>= 7.2)
sqlite3 (2.7.4-aarch64-linux-gnu) solid_queue (1.2.4)
sqlite3 (2.7.4-aarch64-linux-musl) activejob (>= 7.1)
sqlite3 (2.7.4-arm-linux-gnu) activerecord (>= 7.1)
sqlite3 (2.7.4-arm-linux-musl) concurrent-ruby (>= 1.3.1)
sqlite3 (2.7.4-arm64-darwin) fugit (~> 1.11)
sqlite3 (2.7.4-x86_64-linux-gnu) railties (>= 7.1)
sqlite3 (2.7.4-x86_64-linux-musl) thor (>= 1.3.1)
sshkit (1.24.0) sqlite3 (2.9.0-aarch64-linux-gnu)
sqlite3 (2.9.0-aarch64-linux-musl)
sqlite3 (2.9.0-arm-linux-gnu)
sqlite3 (2.9.0-arm-linux-musl)
sqlite3 (2.9.0-arm64-darwin)
sqlite3 (2.9.0-x86_64-linux-gnu)
sqlite3 (2.9.0-x86_64-linux-musl)
sshkit (1.25.0)
base64 base64
logger logger
net-scp (>= 1.1.2) net-scp (>= 1.1.2)
net-sftp (>= 2.1.2) net-sftp (>= 2.1.2)
net-ssh (>= 2.8.0) net-ssh (>= 2.8.0)
ostruct ostruct
standard (1.52.0)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.0)
rubocop (~> 1.81.7)
standard-custom (~> 1.0.0)
standard-performance (~> 1.8)
standard-custom (1.0.2)
lint_roller (~> 1.0)
rubocop (~> 1.50)
standard-performance (1.9.0)
lint_roller (~> 1.1)
rubocop-performance (~> 1.26.0)
stimulus-rails (1.3.4) stimulus-rails (1.3.4)
railties (>= 6.0.0) railties (>= 6.0.0)
stringio (3.1.7) stringio (3.2.0)
tailwindcss-rails (4.3.0) tailwindcss-rails (4.4.0)
railties (>= 7.0.0) railties (>= 7.0.0)
tailwindcss-ruby (~> 4.0) tailwindcss-ruby (~> 4.0)
tailwindcss-ruby (4.1.13) tailwindcss-ruby (4.1.18)
tailwindcss-ruby (4.1.13-aarch64-linux-gnu) tailwindcss-ruby (4.1.18-aarch64-linux-gnu)
tailwindcss-ruby (4.1.13-aarch64-linux-musl) tailwindcss-ruby (4.1.18-aarch64-linux-musl)
tailwindcss-ruby (4.1.13-arm64-darwin) tailwindcss-ruby (4.1.18-arm64-darwin)
tailwindcss-ruby (4.1.13-x86_64-linux-gnu) tailwindcss-ruby (4.1.18-x86_64-linux-gnu)
tailwindcss-ruby (4.1.13-x86_64-linux-musl) tailwindcss-ruby (4.1.18-x86_64-linux-musl)
thor (1.4.0) thor (1.4.0)
thruster (0.1.16) thruster (0.1.17)
thruster (0.1.16-aarch64-linux) thruster (0.1.17-aarch64-linux)
thruster (0.1.16-arm64-darwin) thruster (0.1.17-arm64-darwin)
thruster (0.1.16-x86_64-linux) thruster (0.1.17-x86_64-linux)
timeout (0.4.4) timeout (0.6.0)
tpm-key_attestation (0.14.1) tpm-key_attestation (0.14.1)
bindata (~> 2.4) bindata (~> 2.4)
openssl (> 2.0) openssl (> 2.0)
openssl-signature_algorithm (~> 1.0) openssl-signature_algorithm (~> 1.0)
tsort (0.2.0) tsort (0.2.0)
turbo-rails (2.0.17) turbo-rails (2.0.20)
actionpack (>= 7.1.0) actionpack (>= 7.1.0)
railties (>= 7.1.0) railties (>= 7.1.0)
tzinfo (2.0.6) tzinfo (2.0.6)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
unicode-display_width (3.2.0) unicode-display_width (3.2.0)
unicode-emoji (~> 4.1) unicode-emoji (~> 4.1)
unicode-emoji (4.1.0) unicode-emoji (4.2.0)
uri (1.1.0) uri (1.1.1)
useragent (0.16.11) useragent (0.16.11)
web-console (4.2.1) web-console (4.2.1)
actionview (>= 6.0.0) actionview (>= 6.0.0)
@@ -415,7 +437,7 @@ GEM
websocket-extensions (0.1.5) websocket-extensions (0.1.5)
xpath (3.2.0) xpath (3.2.0)
nokogiri (~> 1.8) nokogiri (~> 1.8)
zeitwerk (2.7.3) zeitwerk (2.7.4)
PLATFORMS PLATFORMS
aarch64-linux aarch64-linux
@@ -441,19 +463,22 @@ DEPENDENCIES
jwt (~> 3.1) jwt (~> 3.1)
kamal kamal
letter_opener letter_opener
minitest (< 6.0)
propshaft propshaft
public_suffix (~> 6.0) public_suffix (~> 7.0)
puma (>= 5.0) puma (>= 5.0)
rails (~> 8.1.1) rails (~> 8.1.1)
rotp (~> 6.3) rotp (~> 6.3)
rqrcode (~> 3.1) rqrcode (~> 3.1)
rubocop-rails-omakase
selenium-webdriver selenium-webdriver
sentry-rails (~> 5.18) sentry-rails (~> 6.2)
sentry-ruby (~> 5.18) sentry-ruby (~> 6.2)
simplecov
solid_cable solid_cable
solid_cache solid_cache
solid_queue (~> 1.2)
sqlite3 (>= 2.1) sqlite3 (>= 2.1)
standard
stimulus-rails stimulus-rails
tailwindcss-rails tailwindcss-rails
thruster thruster
@@ -463,4 +488,4 @@ DEPENDENCIES
webauthn (~> 3.0) webauthn (~> 3.0)
BUNDLED WITH BUNDLED WITH
2.7.2 4.0.3

590
README.md
View File

@@ -1,32 +1,19 @@
# Clinch # Clinch
## Position and Control for your Authentication
> [!NOTE] > [!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.
We do these things not because they're easy, but because we thought they'd be easy.
**A lightweight, self-hosted identity & SSO / IpD portal** **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. Clinch gives you one place to manage users and lets any web app authenticate against it without managing its own users.
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)
* 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 and 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,
## Why Clinch? ## 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 runs as a single Docker container, using SQLite as the database, the job queue (Solid Queue) and the shared cache (Solid Cache). The webserver, Puma, runs the job queue in-process, avoiding the need for another container.
Clinch sits in a sweet spot between two excellent open-source identity solutions: Clinch sits in a sweet spot between two excellent open-source identity solutions:
@@ -84,6 +71,9 @@ Clinch sits in a sweet spot between two excellent open-source identity solutions
### SSO Protocols ### 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) #### OpenID Connect (OIDC)
Standard OAuth2/OIDC provider with endpoints: Standard OAuth2/OIDC provider with endpoints:
- `/.well-known/openid-configuration` - Discovery endpoint - `/.well-known/openid-configuration` - Discovery endpoint
@@ -96,18 +86,45 @@ Features:
- **Refresh tokens** - Long-lived tokens (30 days default) with automatic rotation and revocation - **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 - **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 - **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 - **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) #### Trusted-Header SSO (ForwardAuth)
Works with reverse proxies (Caddy, Traefik, Nginx): Works with reverse proxies (Caddy, Traefik, Nginx):
1. Proxy sends every request to `/api/verify` 1. Proxy sends every request to `/api/verify`
2. **200 OK** → Proxy injects headers (`Remote-User`, `Remote-Groups`, `Remote-Email`) and forwards to app 2. Response handling:
3. **401/403** → Proxy redirects to Clinch login; after login, user returns to original URL - **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)
Apps that speak OIDC use the OIDC flow; apps that only need "who is it?" headers use ForwardAuth.
**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. **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.
@@ -115,7 +132,6 @@ Apps that speak OIDC use the OIDC flow; apps that only need "who is it?" headers
Send emails for: Send emails for:
- Invitation links (one-time token, 7-day expiry) - Invitation links (one-time token, 7-day expiry)
- Password reset links (one-time token, 1-hour expiry) - Password reset links (one-time token, 1-hour expiry)
- 2FA backup codes
### Session Management ### Session Management
- **Device tracking** - See all active sessions with device names and IPs - **Device tracking** - See all active sessions with device names and IPs
@@ -215,9 +231,9 @@ Configure different claims for different applications on a per-user basis:
- Many-to-many with Groups (allowlist) - Many-to-many with Groups (allowlist)
**OIDC Tokens** **OIDC Tokens**
- Authorization codes (10-minute expiry, one-time use, PKCE support) - Authorization codes (opaque, HMAC-SHA256 hashed, 10-minute expiry, one-time use, PKCE support)
- Access tokens (opaque, BCrypt-hashed, configurable expiry 5min-24hr, revocable) - Access tokens (opaque, HMAC-SHA256 hashed, configurable expiry 5min-24hr, revocable)
- Refresh tokens (opaque, BCrypt-hashed, configurable expiry 1-90 days, single-use with rotation) - 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) - ID tokens (JWT, signed with RS256, configurable expiry 5min-24hr)
--- ---
@@ -245,6 +261,24 @@ Configure different claims for different applications on a per-user basis:
- Proxy redirects to Clinch login page - Proxy redirects to Clinch login page
- After login, redirect back to original URL - After login, redirect back to original URL
#### Race Condition Handling
After successful login, you may notice an `fa_token` query parameter appended to redirect URLs (e.g., `https://app.example.com/dashboard?fa_token=...`). This solves a timing issue:
**The Problem:**
1. User signs in → session cookie is set
2. Browser gets redirected to protected resource
3. Browser may not have processed the `Set-Cookie` header yet
4. Reverse proxy checks `/api/verify` → no cookie yet → auth fails ❌
**The Solution:**
- A one-time token (`fa_token`) is added to the redirect URL as a query parameter
- `/api/verify` checks for this token first, before checking cookies
- Token is cached for 60 seconds and deleted immediately after use
- This gives the browser's cookie handling time to catch up
This is transparent to end users and requires no configuration.
--- ---
## Setup & Installation ## Setup & Installation
@@ -270,56 +304,207 @@ bin/rails db:migrate
bin/dev bin/dev
``` ```
### Docker Deployment ---
## Production Deployment
### Docker Compose (Recommended)
Create a `docker-compose.yml` file:
```yaml
services:
clinch:
image: ghcr.io/dkam/clinch:latest
ports:
- "127.0.0.1:3000:3000" # Bind to localhost only (reverse proxy on same host)
# Use "3000:3000" if reverse proxy is in Docker network or different host
environment:
# Rails Configuration
RAILS_ENV: production
SECRET_KEY_BASE: ${SECRET_KEY_BASE}
# Application Configuration
CLINCH_HOST: ${CLINCH_HOST}
CLINCH_FROM_EMAIL: ${CLINCH_FROM_EMAIL:-noreply@example.com}
# SMTP Configuration
SMTP_ADDRESS: ${SMTP_ADDRESS}
SMTP_PORT: ${SMTP_PORT}
SMTP_DOMAIN: ${SMTP_DOMAIN}
SMTP_USERNAME: ${SMTP_USERNAME}
SMTP_PASSWORD: ${SMTP_PASSWORD}
SMTP_AUTHENTICATION: ${SMTP_AUTHENTICATION:-plain}
SMTP_ENABLE_STARTTLS: ${SMTP_ENABLE_STARTTLS:-true}
# OIDC Configuration (optional - generates temporary key if not provided)
OIDC_PRIVATE_KEY: ${OIDC_PRIVATE_KEY}
# Optional Configuration
FORCE_SSL: ${FORCE_SSL:-false}
volumes:
- ./storage:/rails/storage
restart: unless-stopped
```
Create a `.env` file in the same directory:
**Generate required secrets first:**
```bash ```bash
# Build image # Generate SECRET_KEY_BASE (required)
docker build -t clinch . openssl rand -hex 64
# Run container # Generate OIDC private key (optional - auto-generated if not provided)
docker run -p 3000:3000 \ openssl genpkey -algorithm RSA -out private_key.pem -pkeyopt rsa_keygen_bits:2048
-v clinch-storage:/rails/storage \ cat private_key.pem # Copy the output into OIDC_PRIVATE_KEY below
-e SECRET_KEY_BASE=your-secret-key \
-e SMTP_ADDRESS=smtp.example.com \
-e SMTP_PORT=587 \
-e SMTP_USERNAME=your-username \
-e SMTP_PASSWORD=your-password \
clinch
``` ```
**Then create `.env`:**
```bash
# Rails Secret (REQUIRED)
SECRET_KEY_BASE=paste-output-from-openssl-rand-hex-64-here
# Application URLs (REQUIRED)
CLINCH_HOST=https://auth.yourdomain.com
CLINCH_FROM_EMAIL=noreply@yourdomain.com
# SMTP Settings (REQUIRED for invitations and password resets)
SMTP_ADDRESS=smtp.example.com
SMTP_PORT=587
SMTP_DOMAIN=yourdomain.com
SMTP_USERNAME=your-smtp-username
SMTP_PASSWORD=your-smtp-password
# OIDC Private Key (OPTIONAL - generates temporary key if not provided)
# For production, generate a persistent key and paste the ENTIRE contents here
OIDC_PRIVATE_KEY=
# Optional: Force SSL redirects (only if NOT behind a reverse proxy handling SSL)
FORCE_SSL=false
```
Start Clinch:
```bash
docker compose up -d
```
**First Run:**
1. Visit `http://localhost:3000` (or your configured domain)
2. Complete the first-run wizard to create your admin account
3. Configure applications and invite users
**Upgrading:**
```bash
# Pull latest image
docker compose pull
# Restart with new image (migrations run automatically)
docker compose up -d
```
**Logs:**
```bash
# View logs
docker compose logs -f clinch
# View last 100 lines
docker compose logs --tail=100 clinch
```
### Backup & Restore
Clinch stores all persistent data in the `storage/` directory (or `/rails/storage` in Docker):
- SQLite database (`production.sqlite3`)
- Uploaded files via ActiveStorage (application icons)
**Database Backup:**
Use SQLite's `VACUUM INTO` command for safe, atomic backups of a running database:
```bash
# Local development
sqlite3 storage/production.sqlite3 "VACUUM INTO 'backup.sqlite3';"
```
This creates an optimized copy of the database that's safe to make even while Clinch is running.
**Full Backup (Database + Uploads):**
For complete backups including uploaded files, backup the database and uploads separately:
```bash
# 1. Backup database (safe while running)
sqlite3 storage/production.sqlite3 "VACUUM INTO 'backup-$(date +%Y%m%d).sqlite3';"
# 2. Backup uploaded files (ActiveStorage files are immutable)
tar -czf uploads-backup-$(date +%Y%m%d).tar.gz storage/uploads/
# Docker Compose equivalent
docker compose exec clinch sqlite3 /rails/storage/production.sqlite3 "VACUUM INTO '/rails/storage/backup-$(date +%Y%m%d).sqlite3';"
docker compose exec clinch tar -czf /rails/storage/uploads-backup-$(date +%Y%m%d).tar.gz /rails/storage/uploads/
```
**Restore:**
```bash
# Stop Clinch first
# Then restore database
cp backup-YYYYMMDD.sqlite3 storage/production.sqlite3
# Restore uploads
tar -xzf uploads-backup-YYYYMMDD.tar.gz -C storage/
```
**Docker Volume Backup:**
**Option 1: While Running (Online Backup)**
a) **Mapped volumes** (recommended, e.g., `-v /host/path:/rails/storage`):
```bash
# Database backup (safe while running)
sqlite3 /host/path/production.sqlite3 "VACUUM INTO '/host/path/backup-$(date +%Y%m%d).sqlite3';"
# Then sync to off-server storage
rsync -av /host/path/backup-*.sqlite3 /host/path/uploads/ remote:/backups/clinch/
```
b) **Docker volumes** (e.g., using named volumes in compose):
```bash
# Database backup (safe while running)
docker compose exec clinch sqlite3 /rails/storage/production.sqlite3 "VACUUM INTO '/rails/storage/backup.sqlite3';"
# Copy out of container
docker compose cp clinch:/rails/storage/backup.sqlite3 ./backup-$(date +%Y%m%d).sqlite3
```
**Option 2: While Stopped (Offline Backup)**
If Docker is stopped, you can copy the entire storage:
```bash
docker compose down
# For mapped volumes
tar -czf clinch-backup-$(date +%Y%m%d).tar.gz /host/path/
# For docker volumes
docker run --rm -v clinch_storage:/data -v $(pwd):/backup ubuntu \
tar czf /backup/clinch-backup-$(date +%Y%m%d).tar.gz /data
docker compose up -d
```
**Important:** Do not use tar/snapshots on a running database - use `VACUUM INTO` instead or stop the container first.
--- ---
## Configuration ## Configuration
### Environment Variables All configuration is handled via environment variables (see the `.env` file in the Docker Compose section above).
Create a `.env` file (see `.env.example`):
```bash
# Rails
SECRET_KEY_BASE=generate-with-bin-rails-secret
RAILS_ENV=production
# Database
# SQLite database stored in storage/ directory (Docker volume mount point)
# SMTP (for sending emails)
SMTP_ADDRESS=smtp.example.com
SMTP_PORT=587
SMTP_DOMAIN=example.com
SMTP_USERNAME=your-username
SMTP_PASSWORD=your-password
SMTP_AUTHENTICATION=plain
SMTP_ENABLE_STARTTLS=true
# Application
CLINCH_HOST=https://auth.example.com
CLINCH_FROM_EMAIL=noreply@example.com
# OIDC (optional - generates temporary key in development)
# Generate with: openssl genpkey -algorithm RSA -out private_key.pem -pkeyopt rsa_keygen_bits:2048
OIDC_PRIVATE_KEY=<contents-of-private-key.pem>
```
### First Run ### First Run
1. Visit Clinch at `http://localhost:3000` (or your configured domain) 1. Visit Clinch at `http://localhost:3000` (or your configured domain)
@@ -332,24 +517,255 @@ OIDC_PRIVATE_KEY=<contents-of-private-key.pem>
--- ---
## Roadmap ## Rails Console
### In Progress 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.
- OIDC provider implementation
- ForwardAuth endpoint
- Admin UI for user/group/app management
- First-run wizard
### Planned Features ### Starting the Console
- **Audit logging** - Track all authentication events
- **WebAuthn/Passkeys** - Hardware key support
#### Maybe ```bash
- **SAML support** - SAML 2.0 identity provider # Docker / Docker Compose
- **Policy engine** - Rule-based access control docker exec -it clinch bin/rails console
- Example: `IF user.email =~ "*@gmail.com" AND app.slug == "kavita" THEN DENY` # or
- Stored as JSON, evaluated after auth but before consent docker compose exec -it clinch bin/rails console
- **LDAP sync** - Import users from LDAP/Active Directory
# 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
```
**Container Image Scanning:**
```bash
# Install Trivy
brew install trivy # macOS
# or use Docker: alias trivy='docker run --rm -v /var/run/docker.sock:/var/run/docker.sock aquasec/trivy'
# Build and scan image (CRITICAL and HIGH severity only, like CI)
docker build -t clinch:local .
trivy image --severity CRITICAL,HIGH --scanners vuln clinch:local
# Scan only for fixable vulnerabilities
trivy image --severity CRITICAL,HIGH --scanners vuln --ignore-unfixed clinch:local
```
**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
- **Trivy** - Container image vulnerability scanning (OS/system packages)
- **Dependabot** - Automated dependency updates
- **GitHub Secret Scanning** - Detects leaked credentials with push protection
- **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
--- ---

View File

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

View File

@@ -26,18 +26,17 @@ module Admin
@application.allowed_groups = Group.where(id: group_ids) @application.allowed_groups = Group.where(id: group_ids)
end 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 client_secret = nil
if @application.oidc? if @application.oidc? && @application.confidential_client?
client_secret = @application.generate_new_client_secret! client_secret = @application.generate_new_client_secret!
end 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_id] = @application.client_id
flash[:client_secret] = client_secret flash[:client_secret] = client_secret if client_secret
else flash[:public_client] = true if @application.public_client?
flash[:notice] = "Application created successfully."
end end
redirect_to admin_application_path(@application) redirect_to admin_application_path(@application)
@@ -74,15 +73,20 @@ module Admin
def regenerate_credentials def regenerate_credentials
if @application.oidc? if @application.oidc?
# Generate new client ID and secret # Generate new client ID (always)
new_client_id = SecureRandom.urlsafe_base64(32) new_client_id = SecureRandom.urlsafe_base64(32)
client_secret = @application.generate_new_client_secret!
@application.update!(client_id: new_client_id) @application.update!(client_id: new_client_id)
flash[:notice] = "Credentials regenerated successfully." flash[:notice] = "Credentials regenerated successfully."
flash[:client_id] = @application.client_id flash[:client_id] = @application.client_id
# Generate new client secret only for confidential clients
if @application.confidential_client?
client_secret = @application.generate_new_client_secret!
flash[:client_secret] = client_secret flash[:client_secret] = client_secret
else
flash[:public_client] = true
end
redirect_to admin_application_path(@application) redirect_to admin_application_path(@application)
else else
@@ -97,15 +101,24 @@ module Admin
end end
def application_params def application_params
params.require(:application).permit( permitted = params.require(:application).permit(
:name, :slug, :app_type, :active, :redirect_uris, :description, :metadata, :name, :slug, :app_type, :active, :redirect_uris, :description, :metadata,
:domain_pattern, :landing_url, :access_token_ttl, :refresh_token_ttl, :id_token_ttl, :domain_pattern, :landing_url, :access_token_ttl, :refresh_token_ttl, :id_token_ttl,
:icon, :backchannel_logout_uri, :icon, :backchannel_logout_uri, :is_public_client, :require_pkce
headers_config: {} )
).tap do |whitelisted|
# 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) # Remove client_secret from params if present (shouldn't be updated via form)
whitelisted.delete(:client_secret) permitted.delete(:client_secret)
end permitted
end end
end end
end end

View File

@@ -8,7 +8,7 @@ module Api
def violation_report def violation_report
# Parse CSP violation report # Parse CSP violation report
report_data = JSON.parse(request.body.read) report_data = JSON.parse(request.body.read)
csp_report = report_data['csp-report'] csp_report = report_data["csp-report"]
# Validate that we have a proper CSP report # Validate that we have a proper CSP report
unless csp_report.is_a?(Hash) && csp_report.present? unless csp_report.is_a?(Hash) && csp_report.present?
@@ -19,28 +19,28 @@ module Api
# Log the violation for security monitoring # Log the violation for security monitoring
Rails.logger.warn "CSP Violation Report:" Rails.logger.warn "CSP Violation Report:"
Rails.logger.warn " Blocked URI: #{csp_report['blocked-uri']}" Rails.logger.warn " Blocked URI: #{csp_report["blocked-uri"]}"
Rails.logger.warn " Document URI: #{csp_report['document-uri']}" Rails.logger.warn " Document URI: #{csp_report["document-uri"]}"
Rails.logger.warn " Referrer: #{csp_report['referrer']}" Rails.logger.warn " Referrer: #{csp_report["referrer"]}"
Rails.logger.warn " Violated Directive: #{csp_report['violated-directive']}" Rails.logger.warn " Violated Directive: #{csp_report["violated-directive"]}"
Rails.logger.warn " Original Policy: #{csp_report['original-policy']}" Rails.logger.warn " Original Policy: #{csp_report["original-policy"]}"
Rails.logger.warn " User Agent: #{request.user_agent}" Rails.logger.warn " User Agent: #{request.user_agent}"
Rails.logger.warn " IP Address: #{request.remote_ip}" Rails.logger.warn " IP Address: #{request.remote_ip}"
# Emit structured event for CSP violation # Emit structured event for CSP violation
# This allows multiple subscribers to process the event (Sentry, local logging, etc.) # This allows multiple subscribers to process the event (Sentry, local logging, etc.)
Rails.event.notify("csp.violation", { Rails.event.notify("csp.violation", {
blocked_uri: csp_report['blocked-uri'], blocked_uri: csp_report["blocked-uri"],
document_uri: csp_report['document-uri'], document_uri: csp_report["document-uri"],
referrer: csp_report['referrer'], referrer: csp_report["referrer"],
violated_directive: csp_report['violated-directive'], violated_directive: csp_report["violated-directive"],
original_policy: csp_report['original-policy'], original_policy: csp_report["original-policy"],
disposition: csp_report['disposition'], disposition: csp_report["disposition"],
effective_directive: csp_report['effective-directive'], effective_directive: csp_report["effective-directive"],
source_file: csp_report['source-file'], source_file: csp_report["source-file"],
line_number: csp_report['line-number'], line_number: csp_report["line-number"],
column_number: csp_report['column-number'], column_number: csp_report["column-number"],
status_code: csp_report['status-code'], status_code: csp_report["status-code"],
user_agent: request.user_agent, user_agent: request.user_agent,
ip_address: request.remote_ip, ip_address: request.remote_ip,
current_user_id: Current.user&.id, current_user_id: Current.user&.id,

View File

@@ -3,7 +3,7 @@ module Api
# ForwardAuth endpoints need session storage for return URL # ForwardAuth endpoints need session storage for return URL
allow_unauthenticated_access allow_unauthenticated_access
skip_before_action :verify_authenticity_token skip_before_action :verify_authenticity_token
rate_limit to: 100, within: 1.minute, only: :verify, with: -> { head :too_many_requests } # No rate limiting on forward_auth endpoint - proxy middleware hits this frequently
# GET /api/verify # GET /api/verify
# This endpoint is called by reverse proxies (Traefik, Caddy, nginx) # This endpoint is called by reverse proxies (Traefik, Caddy, nginx)
@@ -49,14 +49,20 @@ module Api
forwarded_host = request.headers["X-Forwarded-Host"] || request.headers["Host"] forwarded_host = request.headers["X-Forwarded-Host"] || request.headers["Host"]
if forwarded_host.present? 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 # 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 # Find matching forward auth application for this domain
app = apps.find { |a| a.matches_domain?(forwarded_host) } app = apps.find { |a| a.matches_domain?(forwarded_host) }
if app 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 # Check if user is allowed by this application
unless app.user_allowed?(user) unless app.user_allowed?(user)
Rails.logger.info "ForwardAuth: User #{user.email_address} denied access to #{forwarded_host} by app #{app.domain_pattern}" 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)})" 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 else
# No application found - allow access with default headers (original behavior) # No application found - DENY by default (fail-closed security)
Rails.logger.info "ForwardAuth: No application found for domain: #{forwarded_host}, allowing with default headers" Rails.logger.info "ForwardAuth: Access denied to #{forwarded_host} - no authentication rule configured"
return render_forbidden("No authentication rule configured for this domain")
end end
else else
Rails.logger.info "ForwardAuth: User #{user.email_address} authenticated (no domain specified)" Rails.logger.info "ForwardAuth: User #{user.email_address} authenticated (no domain specified)"
@@ -74,7 +81,10 @@ module Api
# User is authenticated and authorized # User is authenticated and authorized
# Return 200 with user information headers using app-specific configuration # Return 200 with user information headers using app-specific configuration
headers = app ? app.headers_for_user(user) : Application::DEFAULT_HEADERS.map { |key, header_name| headers = if app
app.headers_for_user(user)
else
Application::DEFAULT_HEADERS.map { |key, header_name|
case key case key
when :user, :email, :name when :user, :email, :name
[header_name, user.email_address] [header_name, user.email_address]
@@ -84,12 +94,13 @@ module Api
[header_name, user.admin? ? "true" : "false"] [header_name, user.admin? ? "true" : "false"]
end end
}.compact.to_h }.compact.to_h
end
headers.each { |key, value| response.headers[key] = value } headers.each { |key, value| response.headers[key] = value }
# Log what headers we're sending (helpful for debugging) # Log what headers we're sending (helpful for debugging)
if headers.any? if headers.any?
Rails.logger.debug "ForwardAuth: Headers sent: #{headers.keys.join(', ')}" Rails.logger.debug "ForwardAuth: Headers sent: #{headers.keys.join(", ")}"
else else
Rails.logger.debug "ForwardAuth: No headers sent (access only)" Rails.logger.debug "ForwardAuth: No headers sent (access only)"
end end
@@ -122,8 +133,7 @@ module Api
def extract_session_id def extract_session_id
# Extract session ID from cookie # Extract session ID from cookie
# Rails uses signed cookies by default # Rails uses signed cookies by default
session_id = cookies.signed[:session_id] cookies.signed[:session_id]
session_id
end end
def extract_app_from_headers def extract_app_from_headers
@@ -135,6 +145,9 @@ module Api
def render_unauthorized(reason = nil) def render_unauthorized(reason = nil)
Rails.logger.info "ForwardAuth: Unauthorized - #{reason}" 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 # Get the redirect URL from query params or construct default
redirect_url = validate_redirect_url(params[:rd]) redirect_url = validate_redirect_url(params[:rd])
base_url = determine_base_url(redirect_url) 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"] || "/" original_uri = request.headers["X-Forwarded-Uri"] || request.headers["X-Forwarded-Path"] || "/"
# Debug logging to see what headers we're getting # Debug logging to see what headers we're getting
Rails.logger.info "ForwardAuth Headers: Host=#{request.headers['Host']}, X-Forwarded-Host=#{original_host}, X-Forwarded-Uri=#{request.headers['X-Forwarded-Uri']}, X-Forwarded-Path=#{request.headers['X-Forwarded-Path']}" Rails.logger.info "ForwardAuth Headers: Host=#{request.headers["Host"]}, X-Forwarded-Host=#{original_host}, X-Forwarded-Uri=#{request.headers["X-Forwarded-Uri"]}, X-Forwarded-Path=#{request.headers["X-Forwarded-Path"]}"
original_url = if original_host original_url = if original_host
# Use the forwarded host and URI (original behavior) # Use the forwarded host and URI (original behavior)
@@ -176,6 +189,9 @@ module Api
def render_forbidden(reason = nil) def render_forbidden(reason = nil)
Rails.logger.info "ForwardAuth: Forbidden - #{reason}" 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 # Return 403 Forbidden
head :forbidden head :forbidden
end end
@@ -190,7 +206,7 @@ module Api
return nil unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS) return nil unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
# Only allow HTTPS in production # Only allow HTTPS in production
return nil unless Rails.env.development? || uri.scheme == 'https' return nil unless Rails.env.development? || uri.scheme == "https"
redirect_domain = uri.host.downcase redirect_domain = uri.host.downcase
return nil unless redirect_domain.present? return nil unless redirect_domain.present?
@@ -201,7 +217,6 @@ module Api
end end
matching_app ? url : nil matching_app ? url : nil
rescue URI::InvalidURIError rescue URI::InvalidURIError
nil nil
end end
@@ -220,13 +235,13 @@ module Api
return redirect_url if redirect_url.present? return redirect_url if redirect_url.present?
# Try CLINCH_HOST environment variable first # Try CLINCH_HOST environment variable first
if ENV['CLINCH_HOST'].present? if ENV["CLINCH_HOST"].present?
host = ENV['CLINCH_HOST'] host = ENV["CLINCH_HOST"]
# Ensure URL has https:// protocol # Ensure URL has https:// protocol
host.match?(/^https?:\/\//) ? host : "https://#{host}" host.match?(/^https?:\/\//) ? host : "https://#{host}"
else else
# Fallback to the request host # Fallback to the request host
request_host = request.host || request.headers['X-Forwarded-Host'] request_host = request.host || request.headers["X-Forwarded-Host"]
if request_host.present? if request_host.present?
Rails.logger.warn "ForwardAuth: CLINCH_HOST not set, using request host: #{request_host}" Rails.logger.warn "ForwardAuth: CLINCH_HOST not set, using request host: #{request_host}"
"https://#{request_host}" "https://#{request_host}"

View File

@@ -1,5 +1,6 @@
class ApplicationController < ActionController::Base class ApplicationController < ActionController::Base
include Authentication include Authentication
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has. # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
allow_browser versions: :modern allow_browser versions: :modern

View File

@@ -1,6 +1,6 @@
require 'uri' require "uri"
require 'public_suffix' require "public_suffix"
require 'ipaddr' require "ipaddr"
module Authentication module Authentication
extend ActiveSupport::Concern extend ActiveSupport::Concern
@@ -17,6 +17,7 @@ module Authentication
end end
private private
def authenticated? def authenticated?
resume_session resume_session
end end
@@ -39,14 +40,13 @@ module Authentication
end end
def after_authentication_url def after_authentication_url
return_url = session[:return_to_after_authenticating] session[:return_to_after_authenticating]
final_url = session.delete(:return_to_after_authenticating) || root_url session.delete(:return_to_after_authenticating) || root_url
final_url
end end
def start_new_session_for(user) def start_new_session_for(user, acr: "1")
user.update!(last_sign_in_at: Time.current) user.update!(last_sign_in_at: Time.current)
user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session| user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip, acr: acr).tap do |session|
Current.session = session Current.session = session
# Extract root domain for cross-subdomain cookies (required for forward auth) # Extract root domain for cross-subdomain cookies (required for forward auth)
@@ -101,10 +101,14 @@ module Authentication
return nil if host.blank? || host.match?(/^(localhost|127\.0\.0\.1|::1)$/) return nil if host.blank? || host.match?(/^(localhost|127\.0\.0\.1|::1)$/)
# Strip port number for domain parsing # Strip port number for domain parsing
host_without_port = host.split(':').first host_without_port = host.split(":").first
# Check if it's an IP address (IPv4 or IPv6) - if so, don't set domain cookie # Check if it's an IP address (IPv4 or IPv6) - if so, don't set domain cookie
return nil if IPAddr.new(host_without_port) rescue false begin
return nil if IPAddr.new(host_without_port)
rescue
false
end
# Use Public Suffix List for accurate domain parsing # Use Public Suffix List for accurate domain parsing
domain = PublicSuffix.parse(host_without_port) domain = PublicSuffix.parse(host_without_port)
@@ -138,7 +142,7 @@ module Authentication
unless uri.path&.start_with?("/oauth/") unless uri.path&.start_with?("/oauth/")
# Add token as query parameter # Add token as query parameter
query_params = URI.decode_www_form(uri.query || "").to_h query_params = URI.decode_www_form(uri.query || "").to_h
query_params['fa_token'] = token query_params["fa_token"] = token
uri.query = URI.encode_www_form(query_params) uri.query = URI.encode_www_form(query_params)
# Update the session with the tokenized URL # Update the session with the tokenized URL

View File

@@ -1,7 +1,9 @@
class InvitationsController < ApplicationController class InvitationsController < ApplicationController
include Authentication include Authentication
allow_unauthenticated_access allow_unauthenticated_access
before_action :set_user_by_invitation_token, only: %i[show update] before_action :set_user_by_invitation_token, only: %i[show update]
rate_limit to: 10, within: 10.minutes, only: :update, with: -> { redirect_to signin_path, alert: "Too many attempts. Try again later." }
def show def show
# Show the password setup form # Show the password setup form
@@ -35,16 +37,16 @@ class InvitationsController < ApplicationController
# Check if user is still pending invitation # Check if user is still pending invitation
if @user.nil? if @user.nil?
redirect_to signin_path, alert: "Invitation link is invalid or has expired." redirect_to signin_path, alert: "Invitation link is invalid or has expired."
return false false
elsif @user.pending_invitation? elsif @user.pending_invitation?
# User is valid and pending - proceed # User is valid and pending - proceed
return true true
else else
redirect_to signin_path, alert: "This invitation has already been used or is no longer valid." redirect_to signin_path, alert: "This invitation has already been used or is no longer valid."
return false false
end end
rescue ActiveSupport::MessageVerifier::InvalidSignature rescue ActiveSupport::MessageVerifier::InvalidSignature
redirect_to signin_path, alert: "Invitation link is invalid or has expired." redirect_to signin_path, alert: "Invitation link is invalid or has expired."
return false false
end end
end end

View File

@@ -1,7 +1,15 @@
class OidcController < ApplicationController class OidcController < ApplicationController
# Discovery and JWKS endpoints are public # Discovery and JWKS endpoints are public
allow_unauthenticated_access only: [:discovery, :jwks, :token, :revoke, :userinfo, :logout] allow_unauthenticated_access only: [:discovery, :jwks, :token, :revoke, :userinfo, :logout]
skip_before_action :verify_authenticity_token, only: [:token, :revoke, :logout] skip_before_action :verify_authenticity_token, only: [:token, :revoke, :userinfo, :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 # GET /.well-known/openid-configuration
def discovery def discovery
@@ -18,11 +26,21 @@ class OidcController < ApplicationController
response_types_supported: ["code"], response_types_supported: ["code"],
response_modes_supported: ["query"], response_modes_supported: ["query"],
grant_types_supported: ["authorization_code", "refresh_token"], grant_types_supported: ["authorization_code", "refresh_token"],
subject_types_supported: ["public"], subject_types_supported: ["pairwise"],
id_token_signing_alg_values_supported: ["RS256"], id_token_signing_alg_values_supported: ["RS256"],
scopes_supported: ["openid", "profile", "email", "groups", "offline_access"], scopes_supported: ["openid", "profile", "email", "groups", "offline_access"],
token_endpoint_auth_methods_supported: ["client_secret_post", "client_secret_basic"], 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", # Always included
"email", # email scope
"email_verified", # email scope
"name", # profile scope
"preferred_username", # profile scope
"updated_at", # profile scope
"groups" # groups scope
# Note: Custom claims are also supported but not listed here
# ID-token-only claims (auth_time, acr, azp, at_hash, nonce) are not listed
],
code_challenge_methods_supported: ["plain", "S256"], code_challenge_methods_supported: ["plain", "S256"],
backchannel_logout_supported: true, backchannel_logout_supported: true,
backchannel_logout_session_supported: true backchannel_logout_session_supported: true
@@ -48,32 +66,14 @@ class OidcController < ApplicationController
code_challenge = params[:code_challenge] code_challenge = params[:code_challenge]
code_challenge_method = params[:code_challenge_method] || "plain" code_challenge_method = params[:code_challenge_method] || "plain"
# Validate required parameters # Validate client_id first (required before we can look up the application)
unless client_id.present? && redirect_uri.present? && response_type == "code" # OAuth2 RFC 6749 Section 4.1.2.1: If client_id is missing/invalid, show error page (don't redirect)
error_details = [] unless client_id.present?
error_details << "client_id is required" unless client_id.present? render plain: "Invalid request: client_id is required", status: :bad_request
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
return return
end end
# Validate PKCE parameters if present # Find the application by client_id
if code_challenge.present?
unless %w[plain S256].include?(code_challenge_method)
render plain: "Invalid code_challenge_method: must be 'plain' or 'S256'", status: :bad_request
return
end
# Validate code challenge format (base64url-encoded, 43-128 characters)
unless code_challenge.match?(/\A[A-Za-z0-9\-_]{43,128}\z/)
render plain: "Invalid code_challenge format: must be 43-128 characters of base64url encoding", status: :bad_request
return
end
end
# Find the application
@application = Application.find_by(client_id: client_id, app_type: "oidc") @application = Application.find_by(client_id: client_id, app_type: "oidc")
unless @application unless @application
# Log all OIDC applications for debugging # Log all OIDC applications for debugging
@@ -82,7 +82,7 @@ class OidcController < ApplicationController
Rails.logger.error "OAuth: Available OIDC applications: #{all_oidc_apps.pluck(:id, :client_id, :name)}" Rails.logger.error "OAuth: Available OIDC applications: #{all_oidc_apps.pluck(:id, :client_id, :name)}"
error_msg = if Rails.env.development? error_msg = if Rails.env.development?
"Invalid request: Application not found for client_id '#{client_id}'. Available OIDC applications: #{all_oidc_apps.pluck(:name, :client_id).map { |name, id| "#{name} (#{id})" }.join(', ')}" "Invalid request: Application not found for client_id '#{client_id}'. Available OIDC applications: #{all_oidc_apps.pluck(:name, :client_id).map { |name, id| "#{name} (#{id})" }.join(", ")}"
else else
"Invalid request: Application not found" "Invalid request: Application not found"
end end
@@ -91,13 +91,20 @@ class OidcController < ApplicationController
return return
end end
# Validate redirect URI # Validate redirect_uri presence and format
# OAuth2 RFC 6749 Section 4.1.2.1: If redirect_uri is missing/invalid, show error page (don't redirect)
unless redirect_uri.present?
render plain: "Invalid request: redirect_uri is required", status: :bad_request
return
end
# Validate redirect URI matches one of the registered URIs
unless @application.parsed_redirect_uris.include?(redirect_uri) 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}" Rails.logger.error "OAuth: Invalid request - redirect URI mismatch. Expected: #{@application.parsed_redirect_uris}, Got: #{redirect_uri}"
# For development, show detailed error # For development, show detailed error
error_msg = if Rails.env.development? error_msg = if Rails.env.development?
"Invalid request: Redirect URI mismatch. Application is configured for: #{@application.parsed_redirect_uris.join(', ')}, but received: #{redirect_uri}" "Invalid request: Redirect URI mismatch. Application is configured for: #{@application.parsed_redirect_uris.join(", ")}, but received: #{redirect_uri}"
else else
"Invalid request: Redirect URI not registered for this application" "Invalid request: Redirect URI not registered for this application"
end end
@@ -106,6 +113,53 @@ class OidcController < ApplicationController
return return
end end
# ============================================================================
# At this point we have a valid client_id and redirect_uri
# All subsequent errors should redirect back to the client with error parameters
# per OAuth2 RFC 6749 Section 4.1.2.1
# ============================================================================
# Validate response_type (now we can safely redirect with error)
unless response_type == "code"
Rails.logger.error "OAuth: Invalid response_type: #{response_type}"
error_uri = "#{redirect_uri}?error=unsupported_response_type"
error_uri += "&error_description=#{CGI.escape("Only 'code' response_type is supported")}"
error_uri += "&state=#{CGI.escape(state)}" if state.present?
redirect_to error_uri, allow_other_host: true
return
end
# Validate PKCE parameters if present (now we can safely redirect with error)
if code_challenge.present?
unless %w[plain S256].include?(code_challenge_method)
Rails.logger.error "OAuth: Invalid code_challenge_method: #{code_challenge_method}"
error_uri = "#{redirect_uri}?error=invalid_request"
error_uri += "&error_description=#{CGI.escape("Invalid code_challenge_method: must be 'plain' or 'S256'")}"
error_uri += "&state=#{CGI.escape(state)}" if state.present?
redirect_to error_uri, allow_other_host: true
return
end
# Validate code challenge format (base64url-encoded, 43-128 characters)
unless code_challenge.match?(/\A[A-Za-z0-9\-_]{43,128}\z/)
Rails.logger.error "OAuth: Invalid code_challenge format"
error_uri = "#{redirect_uri}?error=invalid_request"
error_uri += "&error_description=#{CGI.escape("Invalid code_challenge format: must be 43-128 characters of base64url encoding")}"
error_uri += "&state=#{CGI.escape(state)}" if state.present?
redirect_to error_uri, allow_other_host: true
return
end
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 # Check if user is authenticated
unless authenticated? unless authenticated?
# Store OAuth parameters in session and redirect to sign in # Store OAuth parameters in session and redirect to sign in
@@ -137,22 +191,22 @@ class OidcController < ApplicationController
existing_consent = user.has_oidc_consent?(@application, requested_scopes) existing_consent = user.has_oidc_consent?(@application, requested_scopes)
if existing_consent if existing_consent
# User has already consented, generate authorization code directly # User has already consented, generate authorization code directly
code = SecureRandom.urlsafe_base64(32)
auth_code = OidcAuthorizationCode.create!( auth_code = OidcAuthorizationCode.create!(
application: @application, application: @application,
user: user, user: user,
code: code,
redirect_uri: redirect_uri, redirect_uri: redirect_uri,
scope: scope, scope: scope,
nonce: nonce, nonce: nonce,
code_challenge: code_challenge, code_challenge: code_challenge,
code_challenge_method: code_challenge_method, code_challenge_method: code_challenge_method,
auth_time: Current.session.created_at.to_i,
acr: Current.session.acr,
expires_at: 10.minutes.from_now expires_at: 10.minutes.from_now
) )
# Redirect back to client with authorization code # Redirect back to client with authorization code (plaintext)
redirect_uri = "#{redirect_uri}?code=#{code}" redirect_uri = "#{redirect_uri}?code=#{auth_code.plaintext_code}"
redirect_uri += "&state=#{state}" if state.present? redirect_uri += "&state=#{CGI.escape(state)}" if state.present?
redirect_to redirect_uri, allow_other_host: true redirect_to redirect_uri, allow_other_host: true
return return
end end
@@ -206,49 +260,55 @@ class OidcController < ApplicationController
# User denied consent # User denied consent
if params[:deny].present? if params[:deny].present?
session.delete(:oauth_params) session.delete(:oauth_params)
error_uri = "#{oauth_params['redirect_uri']}?error=access_denied" error_uri = "#{oauth_params["redirect_uri"]}?error=access_denied"
error_uri += "&state=#{oauth_params['state']}" if oauth_params['state'] error_uri += "&state=#{CGI.escape(oauth_params["state"])}" if oauth_params["state"]
redirect_to error_uri, allow_other_host: true redirect_to error_uri, allow_other_host: true
return return
end end
# Find the application # Find the application
client_id = oauth_params['client_id'] client_id = oauth_params["client_id"]
application = Application.find_by(client_id: client_id, app_type: "oidc") application = Application.find_by(client_id: client_id, app_type: "oidc")
# Check if application is active (redirect with OAuth error)
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 user = Current.session.user
# Record user consent # Record user consent
requested_scopes = oauth_params['scope'].split(' ') requested_scopes = oauth_params["scope"].split(" ")
OidcUserConsent.upsert( consent = OidcUserConsent.find_or_initialize_by(user: user, application: application)
{ consent.scopes_granted = requested_scopes.join(" ")
user_id: user.id, consent.granted_at = Time.current
application_id: application.id, consent.save!
scopes_granted: requested_scopes.join(' '),
granted_at: Time.current
},
unique_by: [:user_id, :application_id]
)
# Generate authorization code # Generate authorization code
code = SecureRandom.urlsafe_base64(32)
auth_code = OidcAuthorizationCode.create!( auth_code = OidcAuthorizationCode.create!(
application: application, application: application,
user: user, user: user,
code: code, redirect_uri: oauth_params["redirect_uri"],
redirect_uri: oauth_params['redirect_uri'], scope: oauth_params["scope"],
scope: oauth_params['scope'], nonce: oauth_params["nonce"],
nonce: oauth_params['nonce'], code_challenge: oauth_params["code_challenge"],
code_challenge: oauth_params['code_challenge'], code_challenge_method: oauth_params["code_challenge_method"],
code_challenge_method: oauth_params['code_challenge_method'], auth_time: Current.session.created_at.to_i,
acr: Current.session.acr,
expires_at: 10.minutes.from_now expires_at: 10.minutes.from_now
) )
# Clear OAuth params from session # Clear OAuth params from session
session.delete(:oauth_params) session.delete(:oauth_params)
# Redirect back to client with authorization code # Redirect back to client with authorization code (plaintext)
redirect_uri = "#{oauth_params['redirect_uri']}?code=#{code}" redirect_uri = "#{oauth_params["redirect_uri"]}?code=#{auth_code.plaintext_code}"
redirect_uri += "&state=#{oauth_params['state']}" if oauth_params['state'] redirect_uri += "&state=#{CGI.escape(oauth_params["state"])}" if oauth_params["state"]
redirect_to redirect_uri, allow_other_host: true redirect_to redirect_uri, allow_other_host: true
end end
@@ -268,19 +328,37 @@ class OidcController < ApplicationController
end end
def handle_authorization_code_grant def handle_authorization_code_grant
# Get client credentials from Authorization header or params # Get client credentials from Authorization header or params
client_id, client_secret = extract_client_credentials client_id, client_secret = extract_client_credentials
unless client_id && client_secret unless client_id
render json: { error: "invalid_client" }, status: :unauthorized render json: {error: "invalid_client", error_description: "client_id is required"}, status: :unauthorized
return return
end end
# Find and validate the application # Find the application
application = Application.find_by(client_id: client_id) application = Application.find_by(client_id: client_id)
unless application && application.authenticate_client_secret(client_secret) unless application
render json: { error: "invalid_client" }, status: :unauthorized 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 return
end end
@@ -289,12 +367,10 @@ class OidcController < ApplicationController
redirect_uri = params[:redirect_uri] redirect_uri = params[:redirect_uri]
code_verifier = params[:code_verifier] code_verifier = params[:code_verifier]
auth_code = OidcAuthorizationCode.find_by( # Find authorization code using HMAC verification
application: application, auth_code = OidcAuthorizationCode.find_by_plaintext(code)
code: code
)
unless auth_code unless auth_code && auth_code.application == application
render json: {error: "invalid_grant"}, status: :bad_request render json: {error: "invalid_grant"}, status: :bad_request
return return
end end
@@ -336,8 +412,8 @@ class OidcController < ApplicationController
return return
end end
# Validate PKCE if code challenge is present # Validate PKCE - required for public clients and optionally for confidential clients
pkce_result = validate_pkce(auth_code, code_verifier) pkce_result = validate_pkce(application, auth_code, code_verifier)
unless pkce_result[:valid] unless pkce_result[:valid]
render json: { render json: {
error: pkce_result[:error], error: pkce_result[:error],
@@ -364,7 +440,9 @@ class OidcController < ApplicationController
application: application, application: application,
user: user, user: user,
oidc_access_token: access_token_record, 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 # Find user consent for this application
@@ -376,8 +454,19 @@ class OidcController < ApplicationController
return return
end end
# Generate ID token (JWT) with pairwise SID # Generate ID token (JWT) with pairwise SID, at_hash, auth_time, and acr
id_token = OidcJwtService.generate_id_token(user, application, consent: consent, nonce: auth_code.nonce) # auth_time and acr come from the authorization code (captured at /authorize time)
# scopes determine which claims are included (per OIDC Core spec)
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,
scopes: auth_code.scope
)
# Return tokens # Return tokens
render json: { render json: {
@@ -398,15 +487,34 @@ class OidcController < ApplicationController
# Get client credentials from Authorization header or params # Get client credentials from Authorization header or params
client_id, client_secret = extract_client_credentials client_id, client_secret = extract_client_credentials
unless client_id && client_secret unless client_id
render json: { error: "invalid_client" }, status: :unauthorized render json: {error: "invalid_client", error_description: "client_id is required"}, status: :unauthorized
return return
end end
# Find and validate the application # Find the application
application = Application.find_by(client_id: client_id) application = Application.find_by(client_id: client_id)
unless application && application.authenticate_client_secret(client_secret) unless application
render json: { error: "invalid_client" }, status: :unauthorized 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 return
end end
@@ -417,14 +525,11 @@ class OidcController < ApplicationController
return return
end end
# Find the refresh token record # Find the refresh token record using indexed token prefix lookup
# Note: This is inefficient with BCrypt hashing, but necessary for security refresh_token_record = OidcRefreshToken.find_by_token(refresh_token)
# 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
unless refresh_token_record # Verify the token belongs to the correct application
unless refresh_token_record && refresh_token_record.application == application
render json: {error: "invalid_grant", error_description: "Invalid refresh token"}, status: :bad_request render json: {error: "invalid_grant", error_description: "Invalid refresh token"}, status: :bad_request
return return
end end
@@ -465,7 +570,9 @@ class OidcController < ApplicationController
user: user, user: user,
oidc_access_token: new_access_token, oidc_access_token: new_access_token,
scope: refresh_token_record.scope, 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 # Find user consent for this application
@@ -477,8 +584,18 @@ class OidcController < ApplicationController
return return
end end
# Generate new ID token (JWT with pairwise SID, no nonce for refresh grants) # Generate new ID token (JWT with pairwise SID, at_hash, auth_time, acr; no nonce for refresh grants)
id_token = OidcJwtService.generate_id_token(user, application, consent: consent) # auth_time and acr come from the original refresh token (carried over from initial auth)
# scopes determine which claims are included (per OIDC Core spec)
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,
scopes: refresh_token_record.scope
)
# Return new tokens # Return new tokens
render json: { render json: {
@@ -493,17 +610,22 @@ class OidcController < ApplicationController
render json: {error: "invalid_grant"}, status: :bad_request render json: {error: "invalid_grant"}, status: :bad_request
end end
# GET /oauth/userinfo # GET/POST /oauth/userinfo
# OIDC Core spec: UserInfo endpoint MUST support GET, SHOULD support POST
def userinfo def userinfo
# Extract access token from Authorization header # Extract access token from Authorization header or POST body
auth_header = request.headers["Authorization"] # RFC 6750: Bearer token can be in Authorization header, request body, or query string
unless auth_header&.start_with?("Bearer ") token = if request.headers["Authorization"]&.start_with?("Bearer ")
request.headers["Authorization"].sub("Bearer ", "")
elsif request.params["access_token"].present?
request.params["access_token"]
end
unless token
head :unauthorized head :unauthorized
return return
end end
token = auth_header.sub("Bearer ", "")
# Find and validate access token (opaque token with BCrypt hashing) # Find and validate access token (opaque token with BCrypt hashing)
access_token = OidcAccessToken.find_by_token(token) access_token = OidcAccessToken.find_by_token(token)
unless access_token&.active? unless access_token&.active?
@@ -511,6 +633,13 @@ class OidcController < ApplicationController
return return
end 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) # Get the user (with fresh data from database)
user = access_token.user user = access_token.user
unless user unless user
@@ -522,17 +651,35 @@ class OidcController < ApplicationController
consent = OidcUserConsent.find_by(user: user, application: access_token.application) consent = OidcUserConsent.find_by(user: user, application: access_token.application)
subject = consent&.sid || user.id.to_s subject = consent&.sid || user.id.to_s
# Return user claims # Parse scopes from access token (space-separated string)
requested_scopes = access_token.scope.to_s.split
# Return user claims (filter by scope per OIDC Core spec)
# Required claims (always included)
claims = { claims = {
sub: subject, sub: subject
email: user.email_address,
email_verified: true,
preferred_username: user.email_address,
name: user.name.presence || user.email_address
} }
# Add groups if user has any # Email claims (only if 'email' scope requested)
if user.groups.any? if requested_scopes.include?("email")
claims[:email] = user.email_address
claims[:email_verified] = true
end
# Profile claims (only if 'profile' scope requested)
# Per OIDC Core spec section 5.4, include available profile claims
# Only include claims we have data for - omit unknown claims rather than returning null
if requested_scopes.include?("profile")
# Use username if available, otherwise email as preferred_username
claims[:preferred_username] = user.username.presence || user.email_address
# Name: use stored name or fall back to email
claims[:name] = user.name.presence || user.email_address
# Time the user's information was last updated
claims[:updated_at] = user.updated_at.to_i
end
# Groups claim (only if 'groups' scope requested)
if requested_scopes.include?("groups") && user.groups.any?
claims[:groups] = user.groups.pluck(:name) claims[:groups] = user.groups.pluck(:name)
end end
@@ -567,12 +714,19 @@ class OidcController < ApplicationController
# Find and validate the application # Find and validate the application
application = Application.find_by(client_id: client_id) application = Application.find_by(client_id: client_id)
unless application && application.authenticate_client_secret(client_secret) unless application&.authenticate_client_secret(client_secret)
Rails.logger.warn "OAuth: Token revocation attempted for invalid application: #{client_id}" Rails.logger.warn "OAuth: Token revocation attempted for invalid application: #{client_id}"
head :ok head :ok
return return
end 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 # Get the token to revoke
token = params[:token] token = params[:token]
token_type_hint = params[:token_type_hint] # Optional hint: "access_token" or "refresh_token" token_type_hint = params[:token_type_hint] # Optional hint: "access_token" or "refresh_token"
@@ -589,9 +743,7 @@ class OidcController < ApplicationController
if token_type_hint == "refresh_token" || token_type_hint.nil? if token_type_hint == "refresh_token" || token_type_hint.nil?
# Try to find as refresh token # Try to find as refresh token
refresh_token_record = OidcRefreshToken.where(application: application).find do |rt| refresh_token_record = OidcRefreshToken.find_by_token(token)
rt.token_matches?(token)
end
if refresh_token_record if refresh_token_record
refresh_token_record.revoke! refresh_token_record.revoke!
@@ -602,14 +754,12 @@ class OidcController < ApplicationController
if !revoked && (token_type_hint == "access_token" || token_type_hint.nil?) if !revoked && (token_type_hint == "access_token" || token_type_hint.nil?)
# Try to find as access token # Try to find as access token
access_token_record = OidcAccessToken.where(application: application).find do |at| access_token_record = OidcAccessToken.find_by_token(token)
at.token_matches?(token)
end
if access_token_record if access_token_record
access_token_record.revoke! access_token_record.revoke!
Rails.logger.info "OAuth: Access token revoked for application #{application.name}" Rails.logger.info "OAuth: Access token revoked for application #{application.name}"
revoked = true true
end end
end end
@@ -623,7 +773,7 @@ class OidcController < ApplicationController
# OpenID Connect RP-Initiated Logout # OpenID Connect RP-Initiated Logout
# Handle id_token_hint and post_logout_redirect_uri parameters # Handle id_token_hint and post_logout_redirect_uri parameters
id_token_hint = params[:id_token_hint] params[:id_token_hint]
post_logout_redirect_uri = params[:post_logout_redirect_uri] post_logout_redirect_uri = params[:post_logout_redirect_uri]
state = params[:state] state = params[:state]
@@ -645,7 +795,7 @@ class OidcController < ApplicationController
if validated_uri if validated_uri
redirect_uri = 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 redirect_to redirect_uri, allow_other_host: true
else else
# Invalid redirect URI - log warning and go to default # Invalid redirect URI - log warning and go to default
@@ -660,11 +810,26 @@ class OidcController < ApplicationController
private private
def validate_pkce(auth_code, code_verifier) def validate_pkce(application, auth_code, code_verifier)
# Skip PKCE validation if no code challenge was stored (legacy clients) # Check if PKCE is required for this application
return { valid: true } unless auth_code.code_challenge.present? 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? unless code_verifier.present?
return { return {
valid: false, valid: false,
@@ -734,7 +899,7 @@ class OidcController < ApplicationController
return nil unless parsed_uri.is_a?(URI::HTTP) || parsed_uri.is_a?(URI::HTTPS) return nil unless parsed_uri.is_a?(URI::HTTP) || parsed_uri.is_a?(URI::HTTPS)
# Only allow HTTPS in production # Only allow HTTPS in production
return nil if Rails.env.production? && parsed_uri.scheme != 'https' return nil if Rails.env.production? && parsed_uri.scheme != "https"
# Check if URI matches any registered OIDC application's redirect URIs # Check if URI matches any registered OIDC application's redirect URIs
# According to OIDC spec, post_logout_redirect_uri should be pre-registered # According to OIDC spec, post_logout_redirect_uri should be pre-registered

View File

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

View File

@@ -19,13 +19,21 @@ class ProfilesController < ApplicationController
else else
render :show, status: :unprocessable_entity render :show, status: :unprocessable_entity
end end
else elsif params[:user][:email_address].present?
# Updating email # 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) if @user.update(email_params)
redirect_to profile_path, notice: "Email updated successfully." redirect_to profile_path, notice: "Email updated successfully."
else else
render :show, status: :unprocessable_entity render :show, status: :unprocessable_entity
end end
else
render :show, status: :unprocessable_entity
end end
end end

View File

@@ -71,8 +71,8 @@ class SessionsController < ApplicationController
return return
end end
# Sign in successful # Sign in successful (password only)
start_new_session_for user start_new_session_for user, acr: "1"
redirect_to after_authentication_url, notice: "Signed in successfully.", allow_other_host: true redirect_to after_authentication_url, notice: "Signed in successfully.", allow_other_host: true
end end
@@ -101,33 +101,33 @@ class SessionsController < ApplicationController
return return
end end
# Try TOTP verification first # Try TOTP verification first (password + TOTP = 2FA)
if user.verify_totp(code) if user.verify_totp(code)
session.delete(:pending_totp_user_id) session.delete(:pending_totp_user_id)
# Restore redirect URL if it was preserved # Restore redirect URL if it was preserved
if session[:totp_redirect_url].present? if session[:totp_redirect_url].present?
session[:return_to_after_authenticating] = session.delete(:totp_redirect_url) session[:return_to_after_authenticating] = session.delete(:totp_redirect_url)
end 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 redirect_to after_authentication_url, notice: "Signed in successfully.", allow_other_host: true
return return
end end
# Try backup code verification # Try backup code verification (password + backup code = 2FA)
if user.verify_backup_code(code) if user.verify_backup_code(code)
session.delete(:pending_totp_user_id) session.delete(:pending_totp_user_id)
# Restore redirect URL if it was preserved # Restore redirect URL if it was preserved
if session[:totp_redirect_url].present? if session[:totp_redirect_url].present?
session[:return_to_after_authenticating] = session.delete(:totp_redirect_url) session[:return_to_after_authenticating] = session.delete(:totp_redirect_url)
end 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 redirect_to after_authentication_url, notice: "Signed in successfully using backup code.", allow_other_host: true
return return
end end
# Invalid code # Invalid code
redirect_to totp_verification_path, alert: "Invalid verification code. Please try again." redirect_to totp_verification_path, alert: "Invalid verification code. Please try again."
return nil
end end
# Just render the form # Just render the form
@@ -191,7 +191,6 @@ class SessionsController < ApplicationController
session[:webauthn_challenge] = options.challenge session[:webauthn_challenge] = options.challenge
render json: options render json: options
rescue => e rescue => e
Rails.logger.error "WebAuthn challenge generation error: #{e.message}" Rails.logger.error "WebAuthn challenge generation error: #{e.message}"
render json: {error: "Failed to generate WebAuthn challenge"}, status: :internal_server_error render json: {error: "Failed to generate WebAuthn challenge"}, status: :internal_server_error
@@ -268,15 +267,14 @@ class SessionsController < ApplicationController
session[:return_to_after_authenticating] = session.delete(:webauthn_redirect_url) session[:return_to_after_authenticating] = session.delete(:webauthn_redirect_url)
end end
# Create session # Create session (WebAuthn/passkey = phishing-resistant, ACR = "2")
start_new_session_for user start_new_session_for user, acr: "2"
render json: { render json: {
success: true, success: true,
redirect_to: after_authentication_url, redirect_to: after_authentication_url,
message: "Signed in successfully with passkey" message: "Signed in successfully with passkey"
} }
rescue WebAuthn::Error => e rescue WebAuthn::Error => e
Rails.logger.error "WebAuthn verification error: #{e.message}" Rails.logger.error "WebAuthn verification error: #{e.message}"
render json: {error: "Authentication failed: #{e.message}"}, status: :unprocessable_entity render json: {error: "Authentication failed: #{e.message}"}, status: :unprocessable_entity
@@ -301,7 +299,7 @@ class SessionsController < ApplicationController
return nil unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS) return nil unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
# Only allow HTTPS in production # Only allow HTTPS in production
return nil unless Rails.env.development? || uri.scheme == 'https' return nil unless Rails.env.development? || uri.scheme == "https"
redirect_domain = uri.host.downcase redirect_domain = uri.host.downcase
return nil unless redirect_domain.present? return nil unless redirect_domain.present?
@@ -312,7 +310,6 @@ class SessionsController < ApplicationController
end end
matching_app ? url : nil matching_app ? url : nil
rescue URI::InvalidURIError rescue URI::InvalidURIError
nil nil
end end

View File

@@ -2,6 +2,11 @@ class WebauthnController < ApplicationController
before_action :set_webauthn_credential, only: [:destroy] before_action :set_webauthn_credential, only: [:destroy]
skip_before_action :require_authentication, only: [:check] 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 # GET /webauthn/new
def new def new
@webauthn_credential = WebauthnCredential.new @webauthn_credential = WebauthnCredential.new
@@ -91,7 +96,6 @@ class WebauthnController < ApplicationController
message: "Passkey '#{nickname}' registered successfully", message: "Passkey '#{nickname}' registered successfully",
credential_id: @webauthn_credential.id credential_id: @webauthn_credential.id
} }
rescue WebAuthn::Error => e rescue WebAuthn::Error => e
Rails.logger.error "WebAuthn registration error: #{e.message}" Rails.logger.error "WebAuthn registration error: #{e.message}"
render json: {error: "Failed to register passkey: #{e.message}"}, status: :unprocessable_entity render json: {error: "Failed to register passkey: #{e.message}"}, status: :unprocessable_entity
@@ -104,14 +108,6 @@ class WebauthnController < ApplicationController
# DELETE /webauthn/:id # DELETE /webauthn/:id
# Remove a passkey # Remove a passkey
def destroy 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 nickname = @webauthn_credential.nickname
@webauthn_credential.destroy @webauthn_credential.destroy
@@ -131,25 +127,27 @@ class WebauthnController < ApplicationController
# GET /webauthn/check # GET /webauthn/check
# Check if user has WebAuthn credentials (for login page detection) # Check if user has WebAuthn credentials (for login page detection)
# Security: Returns identical responses for non-existent users to prevent enumeration
def check def check
email = params[:email]&.strip&.downcase email = params[:email]&.strip&.downcase
if email.blank? if email.blank?
render json: { has_webauthn: false, error: "Email is required" } render json: {has_webauthn: false, requires_webauthn: false}
return return
end end
user = User.find_by(email_address: email) 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? if user.nil?
render json: { has_webauthn: false, message: "User not found" } render json: {has_webauthn: false, requires_webauthn: false}
return return
end end
# Only return minimal necessary info - no user_id or preferred_method
render json: { render json: {
has_webauthn: user.can_authenticate_with_webauthn?, has_webauthn: user.can_authenticate_with_webauthn?,
user_id: user.id,
preferred_method: user.preferred_authentication_method,
requires_webauthn: user.require_webauthn? requires_webauthn: user.require_webauthn?
} }
end end
@@ -159,7 +157,7 @@ class WebauthnController < ApplicationController
def extract_credential_params def extract_credential_params
# Use require.permit which is working and reliable # Use require.permit which is working and reliable
# The JavaScript sends params both directly and wrapped in webauthn key # The JavaScript sends params both directly and wrapped in webauthn key
begin
# Try direct parameters first # Try direct parameters first
credential_params = params.require(:credential).permit(:id, :rawId, :type, response: {}, clientExtensionResults: {}) credential_params = params.require(:credential).permit(:id, :rawId, :type, response: {}, clientExtensionResults: {})
nickname = params.require(:nickname) nickname = params.require(:nickname)
@@ -170,29 +168,25 @@ class WebauthnController < ApplicationController
webauthn_params = params.require(:webauthn).permit(:nickname, credential: [:id, :rawId, :type, response: {}, clientExtensionResults: {}]) webauthn_params = params.require(:webauthn).permit(:nickname, credential: [:id, :rawId, :type, response: {}, clientExtensionResults: {}])
[webauthn_params[:credential], webauthn_params[:nickname]] [webauthn_params[:credential], webauthn_params[:nickname]]
end end
end
def set_webauthn_credential def set_webauthn_credential
@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 rescue ActiveRecord::RecordNotFound
respond_to do |format| respond_to do |format|
format.html { format.html { redirect_to profile_path, alert: "Passkey not found" }
redirect_to profile_path, format.json { render json: {error: "Passkey not found"}, status: :not_found }
alert: "Passkey not found"
}
format.json {
render json: { error: "Passkey not found" }, status: :not_found
}
end end
end end
# Helper method to convert Base64 to Base64URL if needed # Helper method to convert Base64 to Base64URL if needed
def base64_to_base64url(str) def base64_to_base64url(str)
str.gsub('+', '-').gsub('/', '_').gsub(/=+$/, '') str.tr("+", "-").tr("/", "_").gsub(/=+$/, "")
end end
# Helper method to convert Base64URL to Base64 if needed # Helper method to convert Base64URL to Base64 if needed
def base64url_to_base64(str) def base64url_to_base64(str)
str.gsub('-', '+').gsub('_', '/') + '=' * (4 - str.length % 4) % 4 str.tr("-", "+").tr("_", "/") + "=" * (4 - str.length % 4) % 4
end end
end end

View File

@@ -22,11 +22,11 @@ module ApplicationHelper
def border_class_for(type) def border_class_for(type)
case type.to_s case type.to_s
when 'notice' then 'border-green-200' when "notice" then "border-green-200"
when 'alert', 'error' then 'border-red-200' when "alert", "error" then "border-red-200"
when 'warning' then 'border-yellow-200' when "warning" then "border-yellow-200"
when 'info' then 'border-blue-200' when "info" then "border-blue-200"
else 'border-gray-200' else "border-gray-200"
end end
end end
end end

View File

@@ -25,9 +25,7 @@ module ClaimsHelper
claims = deep_merge_claims(claims, user.parsed_custom_claims) claims = deep_merge_claims(claims, user.parsed_custom_claims)
# Merge app-specific claims (arrays are combined) # Merge app-specific claims (arrays are combined)
claims = deep_merge_claims(claims, application.custom_claims_for_user(user)) deep_merge_claims(claims, application.custom_claims_for_user(user))
claims
end end
# Get claim sources breakdown for display # Get claim sources breakdown for display

View File

@@ -1,7 +1,7 @@
import { Controller } from "@hotwired/stimulus" import { Controller } from "@hotwired/stimulus"
export default class extends Controller { export default class extends Controller {
static targets = ["appTypeSelect", "oidcFields", "forwardAuthFields"] static targets = ["appTypeSelect", "oidcFields", "forwardAuthFields", "pkceOptions"]
connect() { connect() {
this.updateFieldVisibility() this.updateFieldVisibility()
@@ -21,4 +21,17 @@ export default class extends Controller {
this.forwardAuthFieldsTarget.classList.add('hidden') 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')
}
}
}
} }

View 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"
}
}

View File

@@ -29,9 +29,9 @@ class BackchannelLogoutJob < ApplicationJob
uri = URI.parse(application.backchannel_logout_uri) uri = URI.parse(application.backchannel_logout_uri)
begin begin
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https', open_timeout: 5, read_timeout: 5) do |http| response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 5, read_timeout: 5) do |http|
request = Net::HTTP::Post.new(uri.path.presence || '/') request = Net::HTTP::Post.new(uri.path.presence || "/")
request['Content-Type'] = 'application/x-www-form-urlencoded' request["Content-Type"] = "application/x-www-form-urlencoded"
request.set_form_data({logout_token: logout_token}) request.set_form_data({logout_token: logout_token})
http.request(request) http.request(request)
end end
@@ -44,7 +44,7 @@ class BackchannelLogoutJob < ApplicationJob
rescue Net::OpenTimeout, Net::ReadTimeout => e rescue Net::OpenTimeout, Net::ReadTimeout => e
Rails.logger.warn "BackchannelLogout: Timeout sending logout to #{application.name} (#{application.backchannel_logout_uri}): #{e.message}" Rails.logger.warn "BackchannelLogout: Timeout sending logout to #{application.name} (#{application.backchannel_logout_uri}): #{e.message}"
raise # Retry on timeout raise # Retry on timeout
rescue StandardError => e rescue => e
Rails.logger.error "BackchannelLogout: Failed to send logout to #{application.name} (#{application.backchannel_logout_uri}): #{e.class} - #{e.message}" Rails.logger.error "BackchannelLogout: Failed to send logout to #{application.name} (#{application.backchannel_logout_uri}): #{e.class} - #{e.message}"
raise # Retry on error raise # Retry on error
end end

View File

@@ -1,4 +1,4 @@
class ApplicationMailer < ActionMailer::Base class ApplicationMailer < ActionMailer::Base
default from: ENV.fetch('CLINCH_FROM_EMAIL', 'clinch@example.com') default from: ENV.fetch("CLINCH_FROM_EMAIL", "clinch@example.com")
layout "mailer" layout "mailer"
end end

View File

@@ -1,6 +1,10 @@
class Application < ApplicationRecord class Application < ApplicationRecord
has_secure_password :client_secret, validations: false 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 has_one_attached :icon
# Fix SVG content type after attachment # Fix SVG content type after attachment
@@ -16,15 +20,15 @@ class Application < ApplicationRecord
validates :name, presence: true validates :name, presence: true
validates :slug, presence: true, uniqueness: {case_sensitive: false}, validates :slug, presence: true, uniqueness: {case_sensitive: false},
format: { with: /\A[a-z0-9\-]+\z/, message: "only lowercase letters, numbers, and hyphens" } format: {with: /\A[a-z0-9-]+\z/, message: "only lowercase letters, numbers, and hyphens"}
validates :app_type, presence: true, validates :app_type, presence: true,
inclusion: {in: %w[oidc forward_auth]} inclusion: {in: %w[oidc forward_auth]}
validates :client_id, uniqueness: {allow_nil: true} validates :client_id, uniqueness: {allow_nil: true}
validates :client_secret, presence: true, on: :create, if: -> { oidc? } validates :client_secret, presence: true, on: :create, if: -> { oidc? && confidential_client? }
validates :domain_pattern, presence: true, uniqueness: {case_sensitive: false}, if: :forward_auth? validates :domain_pattern, presence: true, uniqueness: {case_sensitive: false}, if: :forward_auth?
validates :landing_url, format: { with: URI::regexp(%w[http https]), allow_nil: true, message: "must be a valid URL" } validates :landing_url, format: {with: URI::RFC2396_PARSER.make_regexp(%w[http https]), allow_nil: true, message: "must be a valid URL"}
validates :backchannel_logout_uri, format: { validates :backchannel_logout_uri, format: {
with: URI::regexp(%w[http https]), with: URI::RFC2396_PARSER.make_regexp(%w[http https]),
allow_nil: true, allow_nil: true,
message: "must be a valid HTTP or HTTPS URL" message: "must be a valid HTTP or HTTPS URL"
} }
@@ -52,11 +56,11 @@ class Application < ApplicationRecord
# Default header configuration for ForwardAuth # Default header configuration for ForwardAuth
DEFAULT_HEADERS = { DEFAULT_HEADERS = {
user: 'X-Remote-User', user: "X-Remote-User",
email: 'X-Remote-Email', email: "X-Remote-Email",
name: 'X-Remote-Name', name: "X-Remote-Name",
groups: 'X-Remote-Groups', groups: "X-Remote-Groups",
admin: 'X-Remote-Admin' admin: "X-Remote-Admin"
}.freeze }.freeze
# Scopes # Scopes
@@ -74,6 +78,24 @@ class Application < ApplicationRecord
app_type == "forward_auth" app_type == "forward_auth"
end 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 # Access control
def user_allowed?(user) def user_allowed?(user)
return false unless active? return false unless active?
@@ -113,8 +135,8 @@ class Application < ApplicationRecord
def matches_domain?(domain) def matches_domain?(domain)
return false if domain.blank? || !forward_auth? return false if domain.blank? || !forward_auth?
pattern = domain_pattern.gsub('.', '\.') pattern = domain_pattern.gsub(".", '\.')
pattern = pattern.gsub('*', '[^.]*') pattern = pattern.gsub("*", "[^.]*")
regex = Regexp.new("^#{pattern}$", Regexp::IGNORECASE) regex = Regexp.new("^#{pattern}$", Regexp::IGNORECASE)
regex.match?(domain.downcase) regex.match?(domain.downcase)
@@ -122,18 +144,18 @@ class Application < ApplicationRecord
# Policy determination based on user status (for ForwardAuth) # Policy determination based on user status (for ForwardAuth)
def policy_for_user(user) def policy_for_user(user)
return 'deny' unless active? return "deny" unless active?
return 'deny' unless user.active? return "deny" unless user.active?
# If no groups specified, bypass authentication # If no groups specified, bypass authentication
return 'bypass' if allowed_groups.empty? return "bypass" if allowed_groups.empty?
# If user is in allowed groups, determine auth level # If user is in allowed groups, determine auth level
if user_allowed?(user) if user_allowed?(user)
# Require 2FA if user has TOTP configured, otherwise one factor # Require 2FA if user has TOTP configured, otherwise one factor
user.totp_enabled? ? 'two_factor' : 'one_factor' user.totp_enabled? ? "two_factor" : "one_factor"
else else
'deny' "deny"
end end
end end
@@ -175,7 +197,7 @@ class Application < ApplicationRecord
def generate_new_client_secret! def generate_new_client_secret!
secret = SecureRandom.urlsafe_base64(48) secret = SecureRandom.urlsafe_base64(48)
self.client_secret = secret self.client_secret = secret
self.save! save!
secret secret
end end
@@ -238,14 +260,14 @@ class Application < ApplicationRecord
return unless icon.attached? return unless icon.attached?
# Check content type # Check content type
allowed_types = ['image/png', 'image/jpg', 'image/jpeg', 'image/gif', 'image/svg+xml'] allowed_types = ["image/png", "image/jpg", "image/jpeg", "image/gif", "image/svg+xml"]
unless allowed_types.include?(icon.content_type) unless allowed_types.include?(icon.content_type)
errors.add(:icon, 'must be a PNG, JPG, GIF, or SVG image') errors.add(:icon, "must be a PNG, JPG, GIF, or SVG image")
end end
# Check file size (2MB limit) # Check file size (2MB limit)
if icon.blob.byte_size > 2.megabytes if icon.blob.byte_size > 2.megabytes
errors.add(:icon, 'must be less than 2MB') errors.add(:icon, "must be less than 2MB")
end end
end end
@@ -261,21 +283,27 @@ class Application < ApplicationRecord
def generate_client_credentials def generate_client_credentials
self.client_id ||= SecureRandom.urlsafe_base64(32) self.client_id ||= SecureRandom.urlsafe_base64(32)
# Generate and hash the client secret # Generate client secret only for confidential clients
if new_record? && client_secret.blank? # 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) secret = SecureRandom.urlsafe_base64(48)
self.client_secret = secret self.client_secret = secret
end end
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 def backchannel_logout_uri_must_be_https_in_production
return unless Rails.env.production? return unless Rails.env.production?
return unless backchannel_logout_uri.present? return unless backchannel_logout_uri.present?
begin begin
uri = URI.parse(backchannel_logout_uri) uri = URI.parse(backchannel_logout_uri)
unless uri.scheme == 'https' unless uri.scheme == "https"
errors.add(:backchannel_logout_uri, 'must use HTTPS in production') errors.add(:backchannel_logout_uri, "must use HTTPS in production")
end end
rescue URI::InvalidURIError rescue URI::InvalidURIError
# Let the format validator handle invalid URIs # Let the format validator handle invalid URIs

View File

@@ -25,7 +25,7 @@ class ApplicationUserClaim < ApplicationRecord
reserved_used = parsed_custom_claims.keys.map(&:to_s) & RESERVED_CLAIMS reserved_used = parsed_custom_claims.keys.map(&:to_s) & RESERVED_CLAIMS
if reserved_used.any? if reserved_used.any?
errors.add(:custom_claims, "cannot override reserved OIDC claims: #{reserved_used.join(', ')}") errors.add(:custom_claims, "cannot override reserved OIDC claims: #{reserved_used.join(", ")}")
end end
end end
end end

View File

@@ -28,7 +28,7 @@ class Group < ApplicationRecord
reserved_used = parsed_custom_claims.keys.map(&:to_s) & RESERVED_CLAIMS reserved_used = parsed_custom_claims.keys.map(&:to_s) & RESERVED_CLAIMS
if reserved_used.any? if reserved_used.any?
errors.add(:custom_claims, "cannot override reserved OIDC claims: #{reserved_used.join(', ')}") errors.add(:custom_claims, "cannot override reserved OIDC claims: #{reserved_used.join(", ")}")
end end
end end
end end

View File

@@ -6,7 +6,7 @@ class OidcAccessToken < ApplicationRecord
before_validation :generate_token, on: :create before_validation :generate_token, on: :create
before_validation :set_expiry, 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 :valid, -> { where("expires_at > ?", Time.current).where(revoked_at: nil) }
scope :expired, -> { where("expires_at <= ?", Time.current) } 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 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? def expired?
expires_at <= Time.current expires_at <= Time.current
end end
@@ -33,48 +46,13 @@ class OidcAccessToken < ApplicationRecord
oidc_refresh_tokens.each(&:revoke!) oidc_refresh_tokens.each(&:revoke!)
end 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 private
def generate_token def generate_token
return if token.present? # Generate random plaintext token
self.plaintext_token ||= SecureRandom.urlsafe_base64(48)
# Generate opaque access token # Store HMAC in database (not plaintext)
plaintext = SecureRandom.urlsafe_base64(48) self.token_hmac ||= self.class.compute_token_hmac(plaintext_token)
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
end end
def set_expiry def set_expiry

View File

@@ -2,10 +2,12 @@ class OidcAuthorizationCode < ApplicationRecord
belongs_to :application belongs_to :application
belongs_to :user belongs_to :user
attr_accessor :plaintext_code
before_validation :generate_code, on: :create before_validation :generate_code, on: :create
before_validation :set_expiry, 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 :redirect_uri, presence: true
validates :code_challenge_method, inclusion: {in: %w[plain S256], allow_nil: true} validates :code_challenge_method, inclusion: {in: %w[plain S256], allow_nil: true}
validate :validate_code_challenge_format, if: -> { code_challenge.present? } validate :validate_code_challenge_format, if: -> { code_challenge.present? }
@@ -13,6 +15,19 @@ class OidcAuthorizationCode < ApplicationRecord
scope :valid, -> { where(used: false).where("expires_at > ?", Time.current) } scope :valid, -> { where(used: false).where("expires_at > ?", Time.current) }
scope :expired, -> { 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? def expired?
expires_at <= Time.current expires_at <= Time.current
end end
@@ -32,7 +47,10 @@ class OidcAuthorizationCode < ApplicationRecord
private private
def generate_code 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 end
def set_expiry def set_expiry

View File

@@ -2,13 +2,12 @@ class OidcRefreshToken < ApplicationRecord
belongs_to :application belongs_to :application
belongs_to :user belongs_to :user
belongs_to :oidc_access_token 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 :generate_token, on: :create
before_validation :set_expiry, on: :create before_validation :set_expiry, on: :create
before_validation :set_token_family_id, 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 :valid, -> { where("expires_at > ?", Time.current).where(revoked_at: nil) }
scope :expired, -> { where("expires_at <= ?", Time.current) } 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 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? def expired?
expires_at <= Time.current expires_at <= Time.current
end end
@@ -43,35 +55,13 @@ class OidcRefreshToken < ApplicationRecord
OidcRefreshToken.in_family(token_family_id).update_all(revoked_at: Time.current) OidcRefreshToken.in_family(token_family_id).update_all(revoked_at: Time.current)
end 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 private
def generate_token def generate_token
# Generate a secure random token # Generate random plaintext token
plaintext = SecureRandom.urlsafe_base64(48) self.token ||= SecureRandom.urlsafe_base64(48)
self.token = plaintext # Store temporarily for returning to client # Store HMAC in database (not plaintext)
self.token_hmac ||= self.class.compute_token_hmac(token)
# Hash it with BCrypt for storage
self.token_digest = BCrypt::Password.create(plaintext)
end end
def set_expiry def set_expiry

View File

@@ -10,12 +10,12 @@ class OidcUserConsent < ApplicationRecord
# Parse scopes_granted into an array # Parse scopes_granted into an array
def scopes def scopes
scopes_granted.split(' ') scopes_granted.split(" ")
end end
# Set scopes from an array # Set scopes from an array
def scopes=(scope_array) def scopes=(scope_array)
self.scopes_granted = Array(scope_array).uniq.join(' ') self.scopes_granted = Array(scope_array).uniq.join(" ")
end end
# Check if this consent covers the requested scopes # Check if this consent covers the requested scopes
@@ -31,18 +31,18 @@ class OidcUserConsent < ApplicationRecord
def formatted_scopes def formatted_scopes
scopes.map do |scope| scopes.map do |scope|
case scope case scope
when 'openid' when "openid"
'Basic authentication' "Basic authentication"
when 'profile' when "profile"
'Profile information' "Profile information"
when 'email' when "email"
'Email address' "Email address"
when 'groups' when "groups"
'Group membership' "Group membership"
else else
scope.humanize scope.humanize
end end
end.join(', ') end.join(", ")
end end
# Find consent by SID # Find consent by SID

View File

@@ -1,4 +1,7 @@
class User < ApplicationRecord class User < ApplicationRecord
# Encrypt TOTP secrets at rest (key derived from SECRET_KEY_BASE)
encrypts :totp_secret
has_secure_password has_secure_password
has_many :sessions, dependent: :destroy has_many :sessions, dependent: :destroy
has_many :user_groups, dependent: :destroy has_many :user_groups, dependent: :destroy
@@ -16,10 +19,6 @@ class User < ApplicationRecord
updated_at updated_at
end 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 :email_address, with: ->(e) { e.strip.downcase }
normalizes :username, with: ->(u) { u.strip.downcase if u.present? } normalizes :username, with: ->(u) { u.strip.downcase if u.present? }
@@ -78,6 +77,14 @@ class User < ApplicationRecord
totp.verify(code, drift_behind: 30, drift_ahead: 30) totp.verify(code, drift_behind: 30, drift_ahead: 30)
end 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) def verify_backup_code(code)
return false unless backup_codes.present? return false unless backup_codes.present?
@@ -115,12 +122,7 @@ class User < ApplicationRecord
cache_key = "backup_code_failed_attempts_#{id}" cache_key = "backup_code_failed_attempts_#{id}"
attempts = Rails.cache.read(cache_key) || 0 attempts = Rails.cache.read(cache_key) || 0
if attempts >= 5 # Allow max 5 failed attempts per hour attempts >= 5
true
else
# Don't increment here - increment only on failed attempts
false
end
end end
# Increment failed attempt counter # Increment failed attempt counter
@@ -224,7 +226,7 @@ class User < ApplicationRecord
reserved_used = parsed_custom_claims.keys.map(&:to_s) & RESERVED_CLAIMS reserved_used = parsed_custom_claims.keys.map(&:to_s) & RESERVED_CLAIMS
if reserved_used.any? if reserved_used.any?
errors.add(:custom_claims, "cannot override reserved OIDC claims: #{reserved_used.join(', ')}") errors.add(:custom_claims, "cannot override reserved OIDC claims: #{reserved_used.join(", ")}")
end end
end end

View File

@@ -1,6 +1,9 @@
class WebauthnCredential < ApplicationRecord class WebauthnCredential < ApplicationRecord
belongs_to :user belongs_to :user
# Set default authenticator_type if not provided
after_initialize :set_default_authenticator_type, if: :new_record?
# Validations # Validations
validates :external_id, presence: true, uniqueness: true validates :external_id, presence: true, uniqueness: true
validates :public_key, presence: true validates :public_key, presence: true
@@ -77,6 +80,10 @@ class WebauthnCredential < ApplicationRecord
private private
def set_default_authenticator_type
self.authenticator_type ||= "cross-platform"
end
def time_ago_in_words(time) def time_ago_in_words(time)
seconds = Time.current - time seconds = Time.current - time
minutes = seconds / 60 minutes = seconds / 60
@@ -84,11 +91,11 @@ class WebauthnCredential < ApplicationRecord
days = hours / 24 days = hours / 24
if days > 0 if days > 0
"#{days.floor} day#{'s' if days > 1} ago" "#{days.floor} day#{"s" if days > 1} ago"
elsif hours > 0 elsif hours > 0
"#{hours.floor} hour#{'s' if hours > 1} ago" "#{hours.floor} hour#{"s" if hours > 1} ago"
elsif minutes > 0 elsif minutes > 0
"#{minutes.floor} minute#{'s' if minutes > 1} ago" "#{minutes.floor} minute#{"s" if minutes > 1} ago"
else else
"Just now" "Just now"
end end

View File

@@ -13,20 +13,20 @@ module ClaimsMerger
result = base.dup result = base.dup
incoming.each do |key, value| incoming.each do |key, value|
if result.key?(key) result[key] = if result.key?(key)
# If both values are arrays, combine them (union to avoid duplicates) # If both values are arrays, combine them (union to avoid duplicates)
if result[key].is_a?(Array) && value.is_a?(Array) if result[key].is_a?(Array) && value.is_a?(Array)
result[key] = (result[key] + value).uniq (result[key] + value).uniq
# If both values are hashes, recursively merge them # If both values are hashes, recursively merge them
elsif result[key].is_a?(Hash) && value.is_a?(Hash) elsif result[key].is_a?(Hash) && value.is_a?(Hash)
result[key] = deep_merge_claims(result[key], value) deep_merge_claims(result[key], value)
else else
# Otherwise, incoming value wins (override) # Otherwise, incoming value wins (override)
result[key] = value value
end end
else else
# New key, just add it # New key, just add it
result[key] = value value
end end
end end

View File

@@ -3,7 +3,7 @@ class OidcJwtService
class << self class << self
# Generate an ID token (JWT) for the user # 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, scopes: "openid")
now = Time.current.to_i now = Time.current.to_i
# Use application's configured ID token TTL (defaults to 1 hour) # Use application's configured ID token TTL (defaults to 1 hour)
ttl = application.id_token_expiry_seconds ttl = application.id_token_expiry_seconds
@@ -11,27 +11,51 @@ class OidcJwtService
# Use pairwise SID from consent if available, fallback to user ID # Use pairwise SID from consent if available, fallback to user ID
subject = consent&.sid || user.id.to_s subject = consent&.sid || user.id.to_s
# Parse scopes (space-separated string)
requested_scopes = scopes.to_s.split
# Required claims (always included per OIDC Core spec)
payload = { payload = {
iss: issuer_url, iss: issuer_url,
sub: subject, sub: subject,
aud: application.client_id, aud: application.client_id,
exp: now + ttl, exp: now + ttl,
iat: now, iat: now
email: user.email_address,
email_verified: true,
preferred_username: user.username.presence || user.email_address,
name: user.name.presence || user.email_address
} }
# NOTE: Email and profile claims are NOT included in the ID token for authorization code flow
# Per OIDC Core spec §5.4, these claims should only be returned via the UserInfo endpoint
# For implicit flow (response_type=id_token), claims would be included here, but we only
# support authorization code flow, so these claims are omitted from the ID token.
# Add nonce if provided (OIDC requires this for implicit flow) # Add nonce if provided (OIDC requires this for implicit flow)
payload[:nonce] = nonce if nonce.present? payload[:nonce] = nonce if nonce.present?
# Add groups if user has any # Add auth_time if provided (OIDC Core §2 - required when max_age is used)
if user.groups.any? 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
# Groups claims (only if 'groups' scope requested)
if requested_scopes.include?("groups") && user.groups.any?
payload[:groups] = user.groups.pluck(:name) payload[:groups] = user.groups.pluck(:name)
end end
# Merge custom claims from groups (arrays are combined, not overwritten) # Merge custom claims from groups (arrays are combined, not overwritten)
# Note: Custom claims from groups are always merged (not scope-dependent)
user.groups.each do |group| user.groups.each do |group|
payload = deep_merge_claims(payload, group.parsed_custom_claims) payload = deep_merge_claims(payload, group.parsed_custom_claims)
end end

View File

@@ -18,9 +18,19 @@
</div> </div>
<div> <div>
<div class="flex items-center justify-between">
<%= form.label :icon, "Application Icon", class: "block text-sm font-medium text-gray-700" %> <%= form.label :icon, "Application Icon", class: "block text-sm font-medium text-gray-700" %>
<a href="https://dashboardicons.com" target="_blank" rel="noopener noreferrer" class="text-xs text-blue-600 hover:text-blue-800 flex items-center gap-1">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path>
</svg>
Browse icons at dashboardicons.com
</a>
</div>
<% if application.icon.attached? && application.persisted? %>
<% begin %> <% begin %>
<% if application.icon.attached? && application.persisted? && application.icon.blob&.persisted? %> <%# Only show icon if we can successfully get its URL (blob is persisted) %>
<% if application.icon.blob&.persisted? && application.icon.blob.key.present? %>
<div class="mt-2 mb-3 flex items-center gap-4"> <div class="mt-2 mb-3 flex items-center gap-4">
<%= image_tag application.icon, class: "h-16 w-16 rounded-lg object-cover border border-gray-200", alt: "Current icon" %> <%= image_tag application.icon, class: "h-16 w-16 rounded-lg object-cover border border-gray-200", alt: "Current icon" %>
<div class="text-sm text-gray-600"> <div class="text-sm text-gray-600">
@@ -41,11 +51,14 @@
<% raise e %> <% raise e %>
<% end %> <% end %>
<% end %> <% end %>
<% end %>
<div class="mt-2" data-controller="file-drop"> <div class="mt-2" data-controller="file-drop image-paste">
<div class="flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 border-dashed rounded-md hover:border-blue-400 transition-colors" <div class="flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 border-dashed rounded-md hover:border-blue-400 transition-colors"
data-file-drop-target="dropzone" data-file-drop-target="dropzone"
data-action="dragover->file-drop#dragover dragleave->file-drop#dragleave drop->file-drop#drop"> data-image-paste-target="dropzone"
data-action="dragover->file-drop#dragover dragleave->file-drop#dragleave drop->file-drop#drop paste->image-paste#handlePaste"
tabindex="0">
<div class="space-y-1 text-center"> <div class="space-y-1 text-center">
<svg class="mx-auto h-12 w-12 text-gray-400" stroke="currentColor" fill="none" viewBox="0 0 48 48"> <svg class="mx-auto h-12 w-12 text-gray-400" stroke="currentColor" fill="none" viewBox="0 0 48 48">
<path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /> <path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
@@ -56,11 +69,16 @@
<%= form.file_field :icon, <%= form.file_field :icon,
accept: "image/png,image/jpg,image/jpeg,image/gif,image/svg+xml", accept: "image/png,image/jpg,image/jpeg,image/gif,image/svg+xml",
class: "sr-only", class: "sr-only",
data: { file_drop_target: "input", action: "change->file-drop#handleFiles" } %> data: {
file_drop_target: "input",
image_paste_target: "input",
action: "change->file-drop#handleFiles"
} %>
</label> </label>
<p class="pl-1">or drag and drop</p> <p class="pl-1">or drag and drop</p>
</div> </div>
<p class="text-xs text-gray-500">PNG, JPG, GIF, or SVG up to 2MB</p> <p class="text-xs text-gray-500">PNG, JPG, GIF, or SVG up to 2MB</p>
<p class="text-xs text-blue-600 font-medium mt-2">💡 Tip: Click here and press Ctrl+V (or Cmd+V) to paste an image from your clipboard</p>
</div> </div>
</div> </div>
<div data-file-drop-target="preview" class="mt-3 hidden"> <div data-file-drop-target="preview" class="mt-3 hidden">
@@ -102,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"> <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> <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> <div>
<%= form.label :redirect_uris, "Redirect URIs", class: "block text-sm font-medium text-gray-700" %> <%= 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" %> <%= 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" %>

View File

@@ -1,17 +1,30 @@
<div class="mb-6"> <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"> <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> <h4 class="text-sm font-medium text-yellow-800 mb-2">🔐 OIDC Client Credentials</h4>
<% 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> <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 class="space-y-2">
<div> <div>
<span class="text-xs font-medium text-yellow-700">Client ID:</span> <span class="text-xs font-medium text-yellow-700">Client ID:</span>
</div> </div>
<code class="block bg-yellow-100 px-3 py-2 rounded font-mono text-xs break-all"><%= flash[:client_id] %></code> <code class="block bg-yellow-100 px-3 py-2 rounded font-mono text-xs break-all"><%= flash[:client_id] %></code>
<% if flash[:client_secret] %>
<div class="mt-3"> <div class="mt-3">
<span class="text-xs font-medium text-yellow-700">Client Secret:</span> <span class="text-xs font-medium text-yellow-700">Client Secret:</span>
</div> </div>
<code class="block bg-yellow-100 px-3 py-2 rounded font-mono text-xs break-all"><%= flash[:client_secret] %></code> <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>
</div> </div>
<% end %> <% end %>
@@ -93,13 +106,36 @@
<%= 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" %> <%= 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> </div>
<dl class="space-y-4"> <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> <div>
<dt class="text-sm font-medium text-gray-500">Client ID</dt> <dt class="text-sm font-medium text-gray-500">Client ID</dt>
<dd class="mt-1 text-sm text-gray-900"> <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> <code class="block bg-gray-100 px-3 py-2 rounded font-mono text-xs break-all"><%= @application.client_id %></code>
</dd> </dd>
</div> </div>
<% if @application.confidential_client? %>
<div> <div>
<dt class="text-sm font-medium text-gray-500">Client Secret</dt> <dt class="text-sm font-medium text-gray-500">Client Secret</dt>
<dd class="mt-1 text-sm text-gray-900"> <dd class="mt-1 text-sm text-gray-900">
@@ -111,6 +147,16 @@
</p> </p>
</dd> </dd>
</div> </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 %> <% end %>
<div> <div>
<dt class="text-sm font-medium text-gray-500">Redirect URIs</dt> <dt class="text-sm font-medium text-gray-500">Redirect URIs</dt>

View File

@@ -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" %> 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>
<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> <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" %> <%= 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> </div>

View File

@@ -1,6 +1,8 @@
<%# Enhanced Flash Messages with Support for Multiple Types and Auto-Dismiss %> <%# Enhanced Flash Messages with Support for Multiple Types and Auto-Dismiss %>
<% flash.each do |type, message| %> <% flash.each do |type, message| %>
<% next if message.blank? %> <% 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 # Map flash types to styling

View File

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

5
bin/standardrb Executable file
View File

@@ -0,0 +1,5 @@
#!/usr/bin/env ruby
require "rubygems"
require "bundler/setup"
load Gem.bin_path("standard", "standardrb")

View File

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

View File

@@ -62,7 +62,6 @@ Rails.application.configure do
# Use async processor for background jobs in development # Use async processor for background jobs in development
config.active_job.queue_adapter = :async config.active_job.queue_adapter = :async
# Highlight code that triggered redirect in logs. # Highlight code that triggered redirect in logs.
config.action_dispatch.verbose_redirect_logs = true config.action_dispatch.verbose_redirect_logs = true

View File

@@ -30,12 +30,20 @@ Rails.application.configure do
# Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies.
config.force_ssl = true 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. # Skip http-to-https redirect for the default health check endpoint.
# config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } } # config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } }
# Log to STDOUT with the current request id as a default log tag. # Log to STDOUT with the current request id as a default log tag.
config.log_tags = [:request_id] config.log_tags = [:request_id]
config.logger = ActiveSupport::TaggedLogging.logger(STDOUT) config.logger = ActiveSupport::TaggedLogging.logger($stdout)
# Change to "debug" to log everything (including potentially personally-identifiable information!). # Change to "debug" to log everything (including potentially personally-identifiable information!).
config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info") config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info")
@@ -49,8 +57,8 @@ Rails.application.configure do
# Replace the default in-process memory cache store with a durable alternative. # Replace the default in-process memory cache store with a durable alternative.
config.cache_store = :solid_cache_store config.cache_store = :solid_cache_store
# Use async processor for background jobs (modify as needed for production) # Use Solid Queue for background jobs
config.active_job.queue_adapter = :async config.active_job.queue_adapter = :solid_queue
# Ignore bad email addresses and do not raise email delivery errors. # 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. # 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. # Set host to be used by links generated in mailer templates.
config.action_mailer.default_url_options = { config.action_mailer.default_url_options = {
host: ENV.fetch('CLINCH_HOST', 'example.com') host: ENV.fetch("CLINCH_HOST", "example.com")
} }
# Specify outgoing SMTP server. Remember to add smtp/* credentials via bin/rails credentials:edit. # Specify outgoing SMTP server. Remember to add smtp/* credentials via bin/rails credentials:edit.
@@ -84,7 +92,7 @@ Rails.application.configure do
def self.extract_domain(host) def self.extract_domain(host)
return host if host.blank? return host if host.blank?
# Remove protocol (http:// or https://) if present # Remove protocol (http:// or https://) if present
host.gsub(/^https?:\/\//, '') host.gsub(/^https?:\/\//, "")
end end
# Helper method to ensure URL has https:// protocol # Helper method to ensure URL has https:// protocol
@@ -97,11 +105,11 @@ Rails.application.configure do
# Enable DNS rebinding protection and other `Host` header attacks. # Enable DNS rebinding protection and other `Host` header attacks.
# Configure allowed hosts based on deployment scenario # Configure allowed hosts based on deployment scenario
allowed_hosts = [ allowed_hosts = [
extract_domain(ENV.fetch('CLINCH_HOST', 'auth.example.com')), # External domain (auth service itself) extract_domain(ENV.fetch("CLINCH_HOST", "auth.example.com")) # External domain (auth service itself)
] ]
# Use PublicSuffix to extract registrable domain and allow all subdomains # Use PublicSuffix to extract registrable domain and allow all subdomains
host_domain = extract_domain(ENV.fetch('CLINCH_HOST', 'auth.example.com')) host_domain = extract_domain(ENV.fetch("CLINCH_HOST", "auth.example.com"))
if host_domain.present? if host_domain.present?
begin begin
# Use PublicSuffix to properly extract the domain # Use PublicSuffix to properly extract the domain
@@ -115,20 +123,20 @@ Rails.application.configure do
rescue PublicSuffix::DomainInvalid rescue PublicSuffix::DomainInvalid
# Fallback to simple domain extraction if PublicSuffix fails # Fallback to simple domain extraction if PublicSuffix fails
Rails.logger.warn "Could not parse domain '#{host_domain}' with PublicSuffix, using fallback" Rails.logger.warn "Could not parse domain '#{host_domain}' with PublicSuffix, using fallback"
base_domain = host_domain.split('.').last(2).join('.') base_domain = host_domain.split(".").last(2).join(".")
allowed_hosts << /.*#{Regexp.escape(base_domain)}/ allowed_hosts << /.*#{Regexp.escape(base_domain)}/
end end
end end
# Allow Docker service names if running in same compose # Allow Docker service names if running in same compose
if ENV['CLINCH_DOCKER_SERVICE_NAME'] if ENV["CLINCH_DOCKER_SERVICE_NAME"]
allowed_hosts << ENV['CLINCH_DOCKER_SERVICE_NAME'] allowed_hosts << ENV["CLINCH_DOCKER_SERVICE_NAME"]
end end
# Allow internal IP access for cross-compose or host networking # Allow internal IP access for cross-compose or host networking
if ENV['CLINCH_ALLOW_INTERNAL_IPS'] == 'true' if ENV["CLINCH_ALLOW_INTERNAL_IPS"] == "true"
# Specific host IP # Specific host IP
allowed_hosts << '192.168.2.246' allowed_hosts << "192.168.2.246"
# Private IP ranges for internal network access # Private IP ranges for internal network access
allowed_hosts += [ allowed_hosts += [
@@ -139,8 +147,8 @@ Rails.application.configure do
end end
# Local development fallbacks # Local development fallbacks
if ENV['CLINCH_ALLOW_LOCALHOST'] == 'true' if ENV["CLINCH_ALLOW_LOCALHOST"] == "true"
allowed_hosts += ['localhost', '127.0.0.1', '0.0.0.0'] allowed_hosts += ["localhost", "127.0.0.1", "0.0.0.0"]
end end
config.hosts = allowed_hosts config.hosts = allowed_hosts

View 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

View File

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

View File

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

View File

@@ -4,5 +4,5 @@
# Use this to limit dissemination of sensitive information. # Use this to limit dissemination of sensitive information.
# See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors. # See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors.
Rails.application.config.filter_parameters += [ Rails.application.config.filter_parameters += [
:passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :cvv, :cvc :passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :cvv, :cvc, :backup
] ]

View 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

View File

@@ -69,10 +69,10 @@ Rails.application.config.after_initialize do
parsed.host parsed.host
rescue URI::InvalidURIError rescue URI::InvalidURIError
# Handle cases where URI might be malformed or just a path # Handle cases where URI might be malformed or just a path
if uri.start_with?('/') if uri.start_with?("/")
nil # It's a relative path, no domain nil # It's a relative path, no domain
else else
uri.split('/').first # Best effort extraction uri.split("/").first # Best effort extraction
end end
end end
end end

View 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

View File

@@ -1,5 +1,5 @@
# frozen_string_literal: true # frozen_string_literal: true
module Clinch module Clinch
VERSION = "0.6.1" VERSION = "0.8.4"
end end

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,6 @@ class CreateApplicationUserClaims < ActiveRecord::Migration[8.1]
t.timestamps t.timestamps
end end
add_index :application_user_claims, [:application_id, :user_id], unique: true, name: 'index_app_user_claims_unique' add_index :application_user_claims, [:application_id, :user_id], unique: true, name: "index_app_user_claims_unique"
end end
end end

View File

@@ -47,6 +47,7 @@ class CreateActiveStorageTables < ActiveRecord::Migration[7.0]
end end
private private
def primary_and_foreign_key_types def primary_and_foreign_key_types
config = Rails.configuration.generators config = Rails.configuration.generators
setting = config.options[config.orm][:primary_key_type] setting = config.options[config.orm][:primary_key_type]

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # 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| create_table "active_storage_attachments", force: :cascade do |t|
t.bigint "blob_id", null: false t.bigint "blob_id", null: false
t.datetime "created_at", 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.string "name", null: false
t.text "redirect_uris" t.text "redirect_uris"
t.integer "refresh_token_ttl", default: 2592000 t.integer "refresh_token_ttl", default: 2592000
t.boolean "require_pkce", default: true, null: false
t.string "slug", null: false t.string "slug", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.index ["active"], name: "index_applications_on_active" 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 "expires_at", null: false
t.datetime "revoked_at" t.datetime "revoked_at"
t.string "scope" t.string "scope"
t.string "token" t.string "token_hmac"
t.string "token_digest"
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.integer "user_id", 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", "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 ["application_id"], name: "index_oidc_access_tokens_on_application_id"
t.index ["expires_at"], name: "index_oidc_access_tokens_on_expires_at" 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 ["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_hmac"], name: "index_oidc_access_tokens_on_token_hmac", unique: true
t.index ["token_digest"], name: "index_oidc_access_tokens_on_token_digest", unique: true
t.index ["user_id"], name: "index_oidc_access_tokens_on_user_id" t.index ["user_id"], name: "index_oidc_access_tokens_on_user_id"
end end
create_table "oidc_authorization_codes", force: :cascade do |t| create_table "oidc_authorization_codes", force: :cascade do |t|
t.string "acr"
t.integer "application_id", null: false t.integer "application_id", null: false
t.string "code", null: false t.integer "auth_time"
t.string "code_challenge" t.string "code_challenge"
t.string "code_challenge_method" t.string "code_challenge_method"
t.string "code_hmac", null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "expires_at", null: false t.datetime "expires_at", null: false
t.string "nonce" 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.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", "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 ["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_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 ["expires_at"], name: "index_oidc_authorization_codes_on_expires_at"
t.index ["user_id"], name: "index_oidc_authorization_codes_on_user_id" t.index ["user_id"], name: "index_oidc_authorization_codes_on_user_id"
end end
create_table "oidc_refresh_tokens", force: :cascade do |t| create_table "oidc_refresh_tokens", force: :cascade do |t|
t.string "acr"
t.integer "application_id", null: false t.integer "application_id", null: false
t.integer "auth_time"
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "expires_at", null: false t.datetime "expires_at", null: false
t.integer "oidc_access_token_id", null: false t.integer "oidc_access_token_id", null: false
t.datetime "revoked_at" t.datetime "revoked_at"
t.string "scope" t.string "scope"
t.string "token_digest", null: false
t.integer "token_family_id" t.integer "token_family_id"
t.string "token_hmac"
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.integer "user_id", 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" 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 ["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 ["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 ["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_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" t.index ["user_id"], name: "index_oidc_refresh_tokens_on_user_id"
end end
@@ -171,6 +174,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_25_081147) do
end end
create_table "sessions", force: :cascade do |t| create_table "sessions", force: :cascade do |t|
t.string "acr"
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.string "device_name" t.string "device_name"
t.datetime "expires_at" t.datetime "expires_at"

316
docs/backchannel-logout.md Normal file
View 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)

304
docs/beta-checklist.md Normal file
View File

@@ -0,0 +1,304 @@
# 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] **Trivy** - Container image vulnerability scanning
- Scans Docker images for OS and system package vulnerabilities
- CI: Builds and scans image on every PR and push to main
- Results uploaded to GitHub Security tab
- [x] **Dependabot** - Automated dependency updates
- Creates PRs for outdated dependencies
- Enabled for Ruby gems and GitHub Actions
- [x] **GitHub Secret Scanning** - Detects leaked credentials
- Push protection enabled to block commits with secrets
- [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
- [x] 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
- [x] Production deployment guide (Docker Compose with .env configuration, upgrading, logs)
- [x] Backup and restore documentation
## Security Hardening
### Headers & CSP
- [x] Content Security Policy (comprehensive policy in config/initializers/content_security_policy.rb)
- [x] X-Frame-Options (DENY in production config)
- [x] X-Content-Type-Options (nosniff - Rails default)
- [x] Referrer-Policy (strict-origin-when-cross-origin in production config)
### Rate Limiting
- [x] Login attempt rate limiting (20/3min on sessions#create)
- [x] TOTP verification rate limiting (10/3min on sessions#verify_totp)
- [x] WebAuthn rate limiting (10/1min on webauthn endpoints, 10/3min on session endpoints)
- [x] Password reset rate limiting (10/3min on request, 10/10min on completion)
- [x] Invitation acceptance rate limiting (10/10min)
- [x] OAuth token endpoint rate limiting (60/1min on token, 30/1min on authorize)
- [x] Backup code rate limiting (5 failed attempts per hour, model-level)
### 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)
- [x] Parameter filtering configured (passwords, tokens, secrets, backup codes, emails filtered from logs)
- [ ] Audit log for admin actions
## Known Limitations & Risks
### Documented Risks
- [x] 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 (Post-Beta)
- [x] Rate limiting on authentication endpoints (comprehensive coverage implemented)
- [ ] Account lockout after N failed attempts (rate limiting provides similar protection)
- [ ] Admin audit logging
- [ ] Security event notifications (email/webhook alerts for suspicious activity)
- [ ] Advanced brute force detection (pattern analysis beyond rate limiting)
- [ ] Suspicious login detection (geolocation, device fingerprinting)
- [ ] IP allowlist/blocklist
## Protocol Conformance & Security Review
**Protocol Conformance (Completed):**
- [x] **OpenID Connect Conformance Testing** - [48/48 tests passed](https://www.certification.openid.net/log-detail.html?log=TZ8vOG0kf35lUiD)
- OIDC authorization code flow ✅
- PKCE flow ✅
- Token security (ID tokens, access tokens, refresh tokens) ✅
- Scope-based claim filtering ✅
- Standard OIDC claims and metadata ✅
- Proper OAuth2 error handling (redirect vs. error page) ✅
**External Security Review (Optional for Post-Beta):**
- [ ] Traditional security audit or penetration test
- Note: OIDC conformance tests protocol compliance, not security vulnerabilities
- A dedicated security audit would test for injection, XSS, auth bypasses, etc.
- [ ] Bug bounty program
- [ ] WebAuthn implementation security review
## Documentation for Users
- [ ] Security best practices guide
- [ ] Incident response guide
- [x] 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
- [x] Backup/restore documentation
- [x] Production deployment guide
- [x] Protocol conformance validation
- [OpenID Connect Conformance Testing](https://www.certification.openid.net/log-detail.html?log=TZ8vOG0kf35lUiD) - **48 tests PASSED**, 0 failures, 0 warnings
**Important (Should have for Beta):**
- [x] Rate limiting on auth endpoints
- [x] Security headers configuration documented (CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy)
- [x] Known limitations documented (ForwardAuth same-domain requirement in README)
- [ ] Admin audit logging
**Nice to have (Can defer to post-Beta):**
- [ ] Bug bounty program
- [ ] Advanced monitoring/alerting
- [x] Automated security testing in CI beyond brakeman/bundler-audit
- [x] Dependabot (automated dependency updates)
- [x] GitHub Secret Scanning (automatic with push protection enabled)
- [x] Container image scanning (Trivy scans Docker images for OS/system vulnerabilities)
- [ ] DAST/Dynamic testing (OWASP ZAP) - optional for post-Beta
## Status Summary
**Current Status:** Ready for Beta Release 🎉
**Strengths:**
- ✅ Comprehensive security tooling in place
- ✅ Strong test coverage (374 tests, 1538 assertions)
- ✅ Modern security features (PKCE, token rotation, WebAuthn)
- ✅ Clean security scans (brakeman, bundler-audit, Trivy)
- ✅ Well-documented codebase
-**OpenID Connect Conformance certified** - 48/48 tests passed
**All Critical Requirements Met:**
- All automated security scans passing ✅
- All tests passing (374 tests, 1542 assertions) ✅
- Core features implemented and tested ✅
- Documentation complete ✅
- Production deployment guide ✅
- Protocol conformance validation complete ✅
**Optional for Post-Beta:**
- Admin audit logging
- Traditional security audit/penetration test
- Bug bounty program
- Advanced monitoring/alerting
**Recommendation:**
Clinch meets all critical requirements for Beta release. The OIDC implementation is protocol-compliant (48/48 conformance tests passed), security scans are clean, and the codebase has strong test coverage.
For production use in security-sensitive environments, consider a traditional security audit or penetration test post-Beta to validate against common vulnerabilities (injection, XSS, auth bypasses, etc.) beyond protocol conformance.
---
Last updated: 2026-01-02

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

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

315
docs/claude-review.md Normal file
View 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.*

View File

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

View File

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

154
docs/security-todo.md Normal file
View 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
View File

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

View File

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

View File

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

31
lib/tasks/security.rake Normal file
View 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"

View File

@@ -5,10 +5,10 @@ module Api
setup do setup do
@user = users(:bob) @user = users(:bob)
@admin_user = users(:alice) @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) @group = groups(:admin_group)
@rule = ForwardAuthRule.create!(domain_pattern: "test.example.com", active: true) @rule = Application.create!(name: "Test App", slug: "test-app", app_type: "forward_auth", domain_pattern: "test.example.com", active: true)
@inactive_rule = ForwardAuthRule.create!(domain_pattern: "inactive.example.com", active: false) @inactive_rule = Application.create!(name: "Inactive App", slug: "inactive-app", app_type: "forward_auth", domain_pattern: "inactive.example.com", active: false)
end end
# Authentication Tests # Authentication Tests
@@ -17,31 +17,7 @@ module Api
assert_response 302 assert_response 302
assert_match %r{/signin}, response.location assert_match %r{/signin}, response.location
assert_equal "No session cookie", response.headers["X-Auth-Reason"] assert_equal "No session cookie", response.headers["x-auth-reason"]
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"]
end end
test "should redirect when user is inactive" do test "should redirect when user is inactive" do
@@ -50,7 +26,7 @@ module Api
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"} get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
assert_response 302 assert_response 302
assert_equal "User account is not active", response.headers["X-Auth-Reason"] assert_equal "User account is not active", response.headers["x-auth-reason"]
end end
test "should return 200 when user is authenticated" do test "should return 200 when user is authenticated" do
@@ -70,14 +46,13 @@ module Api
assert_response 200 assert_response 200
end 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) 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_response 403
assert_equal @user.email_address, response.headers["X-Remote-User"] assert_equal "No authentication rule configured for this domain", response.headers["x-auth-reason"]
assert_equal @user.email_address, response.headers["X-Remote-Email"]
end end
test "should return 403 when rule exists but is inactive" do test "should return 403 when rule exists but is inactive" do
@@ -86,7 +61,7 @@ module Api
get "/api/verify", headers: {"X-Forwarded-Host" => "inactive.example.com"} get "/api/verify", headers: {"X-Forwarded-Host" => "inactive.example.com"}
assert_response 403 assert_response 403
assert_equal "No authentication rule configured for this domain", response.headers["X-Auth-Reason"] assert_equal "No authentication rule configured for this domain", response.headers["x-auth-reason"]
end end
test "should return 403 when rule exists but user not in allowed groups" do test "should return 403 when rule exists but user not in allowed groups" do
@@ -96,7 +71,7 @@ module Api
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"} get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
assert_response 403 assert_response 403
assert_match %r{permission to access this domain}, response.headers["X-Auth-Reason"] assert_match %r{permission to access this domain}, response.headers["x-auth-reason"]
end end
test "should return 200 when user is in allowed groups" do test "should return 200 when user is in allowed groups" do
@@ -111,7 +86,7 @@ module Api
# Domain Pattern Tests # Domain Pattern Tests
test "should match wildcard domains correctly" do 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) sign_in_as(@user)
get "/api/verify", headers: {"X-Forwarded-Host" => "app.example.com"} get "/api/verify", headers: {"X-Forwarded-Host" => "app.example.com"}
@@ -121,18 +96,20 @@ module Api
assert_response 200 assert_response 200
get "/api/verify", headers: {"X-Forwarded-Host" => "other.com"} get "/api/verify", headers: {"X-Forwarded-Host" => "other.com"}
assert_response 200 # Falls 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 end
test "should match exact domains correctly" do 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) sign_in_as(@user)
get "/api/verify", headers: {"X-Forwarded-Host" => "api.example.com"} get "/api/verify", headers: {"X-Forwarded-Host" => "api.example.com"}
assert_response 200 assert_response 200
get "/api/verify", headers: {"X-Forwarded-Host" => "app.api.example.com"} get "/api/verify", headers: {"X-Forwarded-Host" => "app.api.example.com"}
assert_response 200 # Falls 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 end
# Header Configuration Tests # Header Configuration Tests
@@ -142,14 +119,17 @@ module Api
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"} get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
assert_response 200 assert_response 200
assert_equal "X-Remote-User", response.headers.keys.find { |k| k.include?("User") } assert_equal @user.email_address, response.headers["x-remote-user"]
assert_equal "X-Remote-Email", response.headers.keys.find { |k| k.include?("Email") } assert_equal @user.email_address, response.headers["x-remote-email"]
assert_equal "X-Remote-Name", response.headers.keys.find { |k| k.include?("Name") } assert response.headers["x-remote-name"].present?
assert_equal @user.email_address, response.headers["X-Remote-User"] assert_equal (@user.admin? ? "true" : "false"), response.headers["x-remote-admin"]
end end
test "should return custom headers when configured" do 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", domain_pattern: "custom.example.com",
active: true, active: true,
headers_config: { headers_config: {
@@ -163,13 +143,18 @@ module Api
get "/api/verify", headers: {"X-Forwarded-Host" => "custom.example.com"} get "/api/verify", headers: {"X-Forwarded-Host" => "custom.example.com"}
assert_response 200 assert_response 200
assert_equal "X-WEBAUTH-USER", response.headers.keys.find { |k| k.include?("USER") } assert_equal @user.email_address, response.headers["x-webauth-user"]
assert_equal "X-WEBAUTH-EMAIL", response.headers.keys.find { |k| k.include?("EMAIL") } assert_equal @user.email_address, response.headers["x-webauth-email"]
assert_equal @user.email_address, response.headers["X-WEBAUTH-USER"] # Default headers should NOT be present
assert_nil response.headers["x-remote-user"]
assert_nil response.headers["x-remote-email"]
end end
test "should return no headers when all headers disabled" do test "should return no headers when all headers disabled" do
no_headers_rule = ForwardAuthRule.create!( Application.create!(
name: "No Headers App",
slug: "no-headers-app",
app_type: "forward_auth",
domain_pattern: "noheaders.example.com", domain_pattern: "noheaders.example.com",
active: true, active: true,
headers_config: {user: "", email: "", name: "", groups: "", admin: ""} headers_config: {user: "", email: "", name: "", groups: "", admin: ""}
@@ -179,8 +164,9 @@ module Api
get "/api/verify", headers: {"X-Forwarded-Host" => "noheaders.example.com"} get "/api/verify", headers: {"X-Forwarded-Host" => "noheaders.example.com"}
assert_response 200 assert_response 200
auth_headers = response.headers.select { |k, v| k.match?(/^(X-|Remote-)/i) } # Check that auth-specific headers are not present (exclude Rails security headers)
assert_empty auth_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 end
test "should include groups header when user has groups" do test "should include groups header when user has groups" do
@@ -190,16 +176,20 @@ module Api
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"} get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
assert_response 200 assert_response 200
assert_equal @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 end
test "should not include groups header when user has no groups" do test "should not include groups header when user has no groups" do
@user.groups.clear # Remove fixture groups
sign_in_as(@user) sign_in_as(@user)
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"} get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
assert_response 200 assert_response 200
assert_nil response.headers["X-Remote-Groups"] assert_nil response.headers["x-remote-groups"]
end end
test "should include admin header correctly" do test "should include admin header correctly" do
@@ -208,7 +198,7 @@ module Api
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"} get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
assert_response 200 assert_response 200
assert_equal "true", response.headers["X-Remote-Admin"] assert_equal "true", response.headers["x-remote-admin"]
end end
test "should include multiple groups when user has multiple groups" do test "should include multiple groups when user has multiple groups" do
@@ -220,7 +210,7 @@ module Api
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"} get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
assert_response 200 assert_response 200
groups_header = response.headers["X-Remote-Groups"] groups_header = response.headers["x-remote-groups"]
assert_includes groups_header, @group.name assert_includes groups_header, @group.name
assert_includes groups_header, group2.name assert_includes groups_header, group2.name
end end
@@ -239,29 +229,20 @@ module Api
get "/api/verify" get "/api/verify"
assert_response 200 # User is authenticated but no domain rule matches (default test host)
assert_equal "User #{@user.email_address} authenticated (no domain specified)", assert_response 403
request.env["action_dispatch.instance"].instance_variable_get(:@logged_messages)&.last assert_equal "No authentication rule configured for this domain", response.headers["x-auth-reason"]
end end
# Security Tests # 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 test "should handle very long domain names" do
long_domain = "a" * 250 + ".example.com" long_domain = "a" * 250 + ".example.com"
sign_in_as(@user) sign_in_as(@user)
get "/api/verify", headers: {"X-Forwarded-Host" => long_domain} get "/api/verify", headers: {"X-Forwarded-Host" => long_domain}
assert_response 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 end
test "should handle case insensitive domain matching" do test "should handle case insensitive domain matching" do
@@ -272,66 +253,7 @@ module Api
assert_response 200 assert_response 200
end end
# Open Redirect Security Tests # Open Redirect Security Tests - All tests verify SECURE behavior
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
test "should reject malicious redirect URL through session after authentication (SECURE BEHAVIOR)" do 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 # This test shows malicious URLs are filtered out through the auth flow
evil_url = "https://evil-site.com/fake-login" evil_url = "https://evil-site.com/fake-login"
@@ -364,37 +286,6 @@ module Api
assert_match "test.example.com", response.location, "Should redirect to legitimate domain" assert_match "test.example.com", response.location, "Should redirect to legitimate domain"
end 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 test "should ONLY allow redirects to domains with matching ForwardAuthRules (SECURE BEHAVIOR)" do
# Use existing rule for test.example.com created in setup # Use existing rule for test.example.com created in setup
@@ -459,27 +350,15 @@ module Api
end end
end end
# HTTP Method Specific Tests (based on Authelia approach) # HTTP Method Tests
test "should handle different HTTP methods with appropriate redirect codes" do test "should handle GET requests with appropriate response codes" do
sign_in_as(@user) sign_in_as(@user)
# Test GET requests should return 302 Found # Authenticated GET requests should return 200
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"} get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
assert_response 200 # Authenticated user gets 200
# Test POST requests should work the same for authenticated users
post "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
assert_response 200 assert_response 200
end 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 # XHR/Fetch Request Tests
test "should handle XHR requests appropriately" do test "should handle XHR requests appropriately" do
get "/api/verify", headers: { get "/api/verify", headers: {
@@ -549,27 +428,30 @@ module Api
"X-Forwarded-Host" => "测试.example.com" "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 end
# Protocol and Scheme Tests # Protocol and Scheme Tests
test "should handle X-Forwarded-Proto header" do test "should handle X-Forwarded-Proto header" do
sign_in_as(@user)
get "/api/verify", headers: { get "/api/verify", headers: {
"X-Forwarded-Host" => "test.example.com", "X-Forwarded-Host" => "test.example.com",
"X-Forwarded-Proto" => "https" "X-Forwarded-Proto" => "https"
} }
sign_in_as(@user)
assert_response 200 assert_response 200
end end
test "should handle HTTP protocol in X-Forwarded-Proto" do test "should handle HTTP protocol in X-Forwarded-Proto" do
sign_in_as(@user)
get "/api/verify", headers: { get "/api/verify", headers: {
"X-Forwarded-Host" => "test.example.com", "X-Forwarded-Host" => "test.example.com",
"X-Forwarded-Proto" => "http" "X-Forwarded-Proto" => "http"
} }
sign_in_as(@user)
assert_response 200 assert_response 200
# Note: Our implementation doesn't enforce protocol matching # Note: Our implementation doesn't enforce protocol matching
end end
@@ -587,7 +469,7 @@ module Api
assert_response 200 assert_response 200
# Should maintain user identity across requests # 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 end
test "should handle concurrent requests with same session" do test "should handle concurrent requests with same session" do
@@ -600,16 +482,15 @@ module Api
5.times do |i| 5.times do |i|
threads << Thread.new do threads << Thread.new do
get "/api/verify", headers: {"X-Forwarded-Host" => "app#{i}.example.com"} get "/api/verify", headers: {"X-Forwarded-Host" => "app#{i}.example.com"}
results << { status: response.status, user: response.headers["X-Remote-User"] } results << {status: response.status}
end end
end end
threads.each(&:join) threads.each(&:join)
# All requests should succeed # All requests should be denied (no rules configured for these domains)
results.each do |result| results.each do |result|
assert_equal 200, result[:status] assert_equal 403, result[:status]
assert_equal @user.email_address, result[:user]
end end
end end
@@ -624,13 +505,15 @@ module Api
end end
test "should handle null byte injection in headers" do test "should handle null byte injection in headers" do
sign_in_as(@user)
get "/api/verify", headers: { get "/api/verify", headers: {
"X-Forwarded-Host" => "test.example.com\0.evil.com" "X-Forwarded-Host" => "test.example.com\0.evil.com"
} }
sign_in_as(@user) # Should handle null bytes safely - domain doesn't match any rule
# Should handle null bytes safely assert_response 403
assert_response 200 assert_equal "No authentication rule configured for this domain", response.headers["x-auth-reason"]
end end
# Performance and Load Tests # Performance and Load Tests
@@ -642,7 +525,7 @@ module Api
request_count.times do |i| request_count.times do |i|
get "/api/verify", headers: {"X-Forwarded-Host" => "app#{i}.example.com"} get "/api/verify", headers: {"X-Forwarded-Host" => "app#{i}.example.com"}
assert_response 200 assert_response 403 # No rules configured for these domains
end end
total_time = Time.current - start_time total_time = Time.current - start_time

View File

@@ -10,10 +10,14 @@ class AuthenticationTest < ActiveSupport::TestCase
return nil if host.blank? || host.match?(/^(localhost|127\.0\.0\.1|::1)$/) return nil if host.blank? || host.match?(/^(localhost|127\.0\.0\.1|::1)$/)
# Strip port number for domain parsing # Strip port number for domain parsing
host_without_port = host.split(':').first host_without_port = host.split(":").first
# Check if it's an IP address (IPv4 or IPv6) - if so, don't set domain cookie # Check if it's an IP address (IPv4 or IPv6) - if so, don't set domain cookie
return nil if IPAddr.new(host_without_port) rescue false begin
return nil if IPAddr.new(host_without_port)
rescue
false
end
# Use Public Suffix List for accurate domain parsing # Use Public Suffix List for accurate domain parsing
domain = PublicSuffix.parse(host_without_port) domain = PublicSuffix.parse(host_without_port)

View 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

View File

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

View File

@@ -8,7 +8,8 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
slug: "security-test-app", slug: "security-test-app",
app_type: "oidc", app_type: "oidc",
redirect_uris: ["http://localhost:4000/callback"].to_json, redirect_uris: ["http://localhost:4000/callback"].to_json,
active: true active: true,
require_pkce: false
) )
# Store the plain text client secret for testing # Store the plain text client secret for testing
@@ -19,9 +20,11 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
end end
def teardown def teardown
OidcAuthorizationCode.where(application: @application).delete_all # Delete in correct order to avoid foreign key constraints
# Use delete_all to avoid triggering callbacks that might have issues with the schema OidcRefreshToken.where(application: @application).delete_all
OidcAccessToken.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 @user.destroy
@application.destroy @application.destroy
end end
@@ -31,11 +34,19 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
# ==================== # ====================
test "prevents authorization code reuse - sequential attempts" do 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 # Create a valid authorization code
auth_code = OidcAuthorizationCode.create!( auth_code = OidcAuthorizationCode.create!(
application: @application, application: @application,
user: @user, user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback", redirect_uri: "http://localhost:4000/callback",
scope: "openid profile", scope: "openid profile",
expires_at: 10.minutes.from_now expires_at: 10.minutes.from_now
@@ -43,7 +54,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
token_params = { token_params = {
grant_type: "authorization_code", grant_type: "authorization_code",
code: auth_code.code, code: auth_code.plaintext_code,
redirect_uri: "http://localhost:4000/callback" redirect_uri: "http://localhost:4000/callback"
} }
@@ -69,11 +80,19 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
end end
test "revokes existing tokens when authorization code is reused" do 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 # Create a valid authorization code
auth_code = OidcAuthorizationCode.create!( auth_code = OidcAuthorizationCode.create!(
application: @application, application: @application,
user: @user, user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback", redirect_uri: "http://localhost:4000/callback",
scope: "openid profile", scope: "openid profile",
expires_at: 10.minutes.from_now expires_at: 10.minutes.from_now
@@ -81,7 +100,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
token_params = { token_params = {
grant_type: "authorization_code", grant_type: "authorization_code",
code: auth_code.code, code: auth_code.plaintext_code,
redirect_uri: "http://localhost:4000/callback" redirect_uri: "http://localhost:4000/callback"
} }
@@ -115,11 +134,19 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
end end
test "rejects already used authorization code" do 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 # Create and mark code as used
auth_code = OidcAuthorizationCode.create!( auth_code = OidcAuthorizationCode.create!(
application: @application, application: @application,
user: @user, user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback", redirect_uri: "http://localhost:4000/callback",
scope: "openid profile", scope: "openid profile",
used: true, used: true,
@@ -128,7 +155,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
token_params = { token_params = {
grant_type: "authorization_code", grant_type: "authorization_code",
code: auth_code.code, code: auth_code.plaintext_code,
redirect_uri: "http://localhost:4000/callback" redirect_uri: "http://localhost:4000/callback"
} }
@@ -143,11 +170,19 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
end end
test "rejects expired authorization code" do 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 # Create expired code
auth_code = OidcAuthorizationCode.create!( auth_code = OidcAuthorizationCode.create!(
application: @application, application: @application,
user: @user, user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback", redirect_uri: "http://localhost:4000/callback",
scope: "openid profile", scope: "openid profile",
expires_at: 5.minutes.ago expires_at: 5.minutes.ago
@@ -155,7 +190,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
token_params = { token_params = {
grant_type: "authorization_code", grant_type: "authorization_code",
code: auth_code.code, code: auth_code.plaintext_code,
redirect_uri: "http://localhost:4000/callback" redirect_uri: "http://localhost:4000/callback"
} }
@@ -170,10 +205,18 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
end end
test "rejects authorization code with mismatched redirect_uri" do 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!( auth_code = OidcAuthorizationCode.create!(
application: @application, application: @application,
user: @user, user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback", redirect_uri: "http://localhost:4000/callback",
scope: "openid profile", scope: "openid profile",
expires_at: 10.minutes.from_now expires_at: 10.minutes.from_now
@@ -181,7 +224,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
token_params = { token_params = {
grant_type: "authorization_code", grant_type: "authorization_code",
code: auth_code.code, code: auth_code.plaintext_code,
redirect_uri: "http://evil.com/callback" # Wrong redirect URI redirect_uri: "http://evil.com/callback" # Wrong redirect URI
} }
@@ -212,13 +255,23 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
end end
test "rejects authorization code for different application" do 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 # Create another application
other_app = Application.create!( other_app = Application.create!(
name: "Other App", name: "Other App",
slug: "other-app", slug: "other-app",
app_type: "oidc", app_type: "oidc",
redirect_uris: ["http://localhost:5000/callback"].to_json, redirect_uris: ["http://localhost:5000/callback"].to_json,
active: true active: true,
require_pkce: false
) )
other_secret = other_app.client_secret other_secret = other_app.client_secret
@@ -226,7 +279,6 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
auth_code = OidcAuthorizationCode.create!( auth_code = OidcAuthorizationCode.create!(
application: @application, application: @application,
user: @user, user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback", redirect_uri: "http://localhost:4000/callback",
scope: "openid profile", scope: "openid profile",
expires_at: 10.minutes.from_now expires_at: 10.minutes.from_now
@@ -235,7 +287,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
# Try to use it with different application credentials # Try to use it with different application credentials
token_params = { token_params = {
grant_type: "authorization_code", grant_type: "authorization_code",
code: auth_code.code, code: auth_code.plaintext_code,
redirect_uri: "http://localhost:4000/callback" redirect_uri: "http://localhost:4000/callback"
} }
@@ -255,10 +307,18 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
# ==================== # ====================
test "rejects invalid client_id in Basic auth" do 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!( auth_code = OidcAuthorizationCode.create!(
application: @application, application: @application,
user: @user, user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback", redirect_uri: "http://localhost:4000/callback",
scope: "openid profile", scope: "openid profile",
expires_at: 10.minutes.from_now expires_at: 10.minutes.from_now
@@ -266,7 +326,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
token_params = { token_params = {
grant_type: "authorization_code", grant_type: "authorization_code",
code: auth_code.code, code: auth_code.plaintext_code,
redirect_uri: "http://localhost:4000/callback" redirect_uri: "http://localhost:4000/callback"
} }
@@ -280,10 +340,18 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
end end
test "rejects invalid client_secret in Basic auth" do 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!( auth_code = OidcAuthorizationCode.create!(
application: @application, application: @application,
user: @user, user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback", redirect_uri: "http://localhost:4000/callback",
scope: "openid profile", scope: "openid profile",
expires_at: 10.minutes.from_now expires_at: 10.minutes.from_now
@@ -291,7 +359,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
token_params = { token_params = {
grant_type: "authorization_code", grant_type: "authorization_code",
code: auth_code.code, code: auth_code.plaintext_code,
redirect_uri: "http://localhost:4000/callback" redirect_uri: "http://localhost:4000/callback"
} }
@@ -305,10 +373,18 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
end end
test "accepts client credentials in POST body" do 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!( auth_code = OidcAuthorizationCode.create!(
application: @application, application: @application,
user: @user, user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback", redirect_uri: "http://localhost:4000/callback",
scope: "openid profile", scope: "openid profile",
expires_at: 10.minutes.from_now expires_at: 10.minutes.from_now
@@ -316,7 +392,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
token_params = { token_params = {
grant_type: "authorization_code", grant_type: "authorization_code",
code: auth_code.code, code: auth_code.plaintext_code,
redirect_uri: "http://localhost:4000/callback", redirect_uri: "http://localhost:4000/callback",
client_id: @application.client_id, client_id: @application.client_id,
client_secret: @plain_client_secret client_secret: @plain_client_secret
@@ -331,10 +407,18 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
end end
test "rejects request with no client authentication" do 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!( auth_code = OidcAuthorizationCode.create!(
application: @application, application: @application,
user: @user, user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback", redirect_uri: "http://localhost:4000/callback",
scope: "openid profile", scope: "openid profile",
expires_at: 10.minutes.from_now expires_at: 10.minutes.from_now
@@ -342,7 +426,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
token_params = { token_params = {
grant_type: "authorization_code", grant_type: "authorization_code",
code: auth_code.code, code: auth_code.plaintext_code,
redirect_uri: "http://localhost:4000/callback" redirect_uri: "http://localhost:4000/callback"
} }
@@ -389,10 +473,18 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
# ==================== # ====================
test "client authentication uses constant-time comparison" do 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!( auth_code = OidcAuthorizationCode.create!(
application: @application, application: @application,
user: @user, user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback", redirect_uri: "http://localhost:4000/callback",
scope: "openid profile", scope: "openid profile",
expires_at: 10.minutes.from_now expires_at: 10.minutes.from_now
@@ -400,7 +492,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
token_params = { token_params = {
grant_type: "authorization_code", grant_type: "authorization_code",
code: auth_code.code, code: auth_code.plaintext_code,
redirect_uri: "http://localhost:4000/callback" redirect_uri: "http://localhost:4000/callback"
} }
@@ -438,4 +530,320 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
assert timing_difference < 0.05, assert timing_difference < 0.05,
"Timing difference #{timing_difference}s suggests potential timing attack vulnerability" "Timing difference #{timing_difference}s suggests potential timing attack vulnerability"
end 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 end

View File

@@ -17,8 +17,11 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
def teardown def teardown
Current.session&.destroy Current.session&.destroy
OidcAuthorizationCode.where(application: @application).destroy_all # Delete in correct order to avoid foreign key constraints
OidcAccessToken.where(application: @application).destroy_all 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 @user.destroy
@application.destroy @application.destroy
end end
@@ -35,7 +38,6 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
end end
test "authorization endpoint accepts PKCE parameters (S256)" do test "authorization endpoint accepts PKCE parameters (S256)" do
code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
code_challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" code_challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
auth_params = { auth_params = {
@@ -53,7 +55,7 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
# Should show consent page (user is already authenticated) # Should show consent page (user is already authenticated)
assert_response :success assert_response :success
assert_match /consent/, @response.body.downcase assert_match(/consent/, @response.body.downcase)
end end
test "authorization endpoint accepts PKCE parameters (plain)" do test "authorization endpoint accepts PKCE parameters (plain)" do
@@ -74,7 +76,7 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
# Should show consent page (user is already authenticated) # Should show consent page (user is already authenticated)
assert_response :success assert_response :success
assert_match /consent/, @response.body.downcase assert_match(/consent/, @response.body.downcase)
end end
test "authorization endpoint rejects invalid code_challenge_method" do test "authorization endpoint rejects invalid code_challenge_method" do
@@ -89,8 +91,10 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
get "/oauth/authorize", params: auth_params get "/oauth/authorize", params: auth_params
assert_response :bad_request # Should redirect back to client with error parameters (OAuth2 spec)
assert_match(/Invalid code_challenge_method/, @response.body) assert_response :redirect
assert_match(/error=invalid_request/, @response.location)
assert_match(/error_description=.*code_challenge_method/, @response.location)
end end
test "authorization endpoint rejects invalid code_challenge format" do test "authorization endpoint rejects invalid code_challenge format" do
@@ -106,16 +110,26 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
get "/oauth/authorize", params: auth_params get "/oauth/authorize", params: auth_params
assert_response :bad_request # Should redirect back to client with error parameters (OAuth2 spec)
assert_match(/Invalid code_challenge format/, @response.body) assert_response :redirect
assert_match(/error=invalid_request/, @response.location)
assert_match(/error_description=.*code_challenge.*format/, @response.location)
end end
test "token endpoint requires code_verifier when PKCE was used (S256)" do 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 # Create authorization code with PKCE S256
auth_code = OidcAuthorizationCode.create!( auth_code = OidcAuthorizationCode.create!(
application: @application, application: @application,
user: @user, user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback", redirect_uri: "http://localhost:4000/callback",
scope: "openid profile", scope: "openid profile",
code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
@@ -125,7 +139,7 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
token_params = { token_params = {
grant_type: "authorization_code", grant_type: "authorization_code",
code: auth_code.code, code: auth_code.plaintext_code,
redirect_uri: "http://localhost:4000/callback" redirect_uri: "http://localhost:4000/callback"
} }
@@ -140,11 +154,19 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
end end
test "token endpoint requires code_verifier when PKCE was used (plain)" do 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 # Create authorization code with PKCE plain
auth_code = OidcAuthorizationCode.create!( auth_code = OidcAuthorizationCode.create!(
application: @application, application: @application,
user: @user, user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback", redirect_uri: "http://localhost:4000/callback",
scope: "openid profile", scope: "openid profile",
code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
@@ -154,7 +176,7 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
token_params = { token_params = {
grant_type: "authorization_code", grant_type: "authorization_code",
code: auth_code.code, code: auth_code.plaintext_code,
redirect_uri: "http://localhost:4000/callback" redirect_uri: "http://localhost:4000/callback"
} }
@@ -169,11 +191,19 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
end end
test "token endpoint rejects invalid code_verifier (S256)" do 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 # Create authorization code with PKCE S256
auth_code = OidcAuthorizationCode.create!( auth_code = OidcAuthorizationCode.create!(
application: @application, application: @application,
user: @user, user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback", redirect_uri: "http://localhost:4000/callback",
scope: "openid profile", scope: "openid profile",
code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
@@ -183,7 +213,7 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
token_params = { token_params = {
grant_type: "authorization_code", grant_type: "authorization_code",
code: auth_code.code, code: auth_code.plaintext_code,
redirect_uri: "http://localhost:4000/callback", redirect_uri: "http://localhost:4000/callback",
# Use a properly formatted but wrong verifier (43+ chars, base64url) # Use a properly formatted but wrong verifier (43+ chars, base64url)
code_verifier: "wrongverifier_with_enough_characters_base64url" code_verifier: "wrongverifier_with_enough_characters_base64url"
@@ -200,6 +230,15 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
end end
test "token endpoint accepts valid code_verifier (S256)" do 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 # Generate valid PKCE pair
code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
code_challenge = Digest::SHA256.base64digest(code_verifier) code_challenge = Digest::SHA256.base64digest(code_verifier)
@@ -210,7 +249,6 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
auth_code = OidcAuthorizationCode.create!( auth_code = OidcAuthorizationCode.create!(
application: @application, application: @application,
user: @user, user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback", redirect_uri: "http://localhost:4000/callback",
scope: "openid profile", scope: "openid profile",
code_challenge: code_challenge, code_challenge: code_challenge,
@@ -220,7 +258,7 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
token_params = { token_params = {
grant_type: "authorization_code", grant_type: "authorization_code",
code: auth_code.code, code: auth_code.plaintext_code,
redirect_uri: "http://localhost:4000/callback", redirect_uri: "http://localhost:4000/callback",
code_verifier: code_verifier code_verifier: code_verifier
} }
@@ -237,13 +275,21 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
end end
test "token endpoint accepts valid code_verifier (plain)" do 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" code_verifier = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
# Create authorization code with PKCE plain # Create authorization code with PKCE plain
auth_code = OidcAuthorizationCode.create!( auth_code = OidcAuthorizationCode.create!(
application: @application, application: @application,
user: @user, user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback", redirect_uri: "http://localhost:4000/callback",
scope: "openid profile", scope: "openid profile",
code_challenge: code_verifier, # Same as verifier for plain method code_challenge: code_verifier, # Same as verifier for plain method
@@ -253,7 +299,7 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
token_params = { token_params = {
grant_type: "authorization_code", grant_type: "authorization_code",
code: auth_code.code, code: auth_code.plaintext_code,
redirect_uri: "http://localhost:4000/callback", redirect_uri: "http://localhost:4000/callback",
code_verifier: code_verifier code_verifier: code_verifier
} }
@@ -270,19 +316,308 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
end end
test "token endpoint works without PKCE (backward compatibility)" do 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 # Create authorization code without PKCE
auth_code = OidcAuthorizationCode.create!( auth_code = OidcAuthorizationCode.create!(
application: @application, application: legacy_app,
user: @user, user: @user,
code: SecureRandom.urlsafe_base64(32), redirect_uri: "http://localhost:5000/callback",
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile", scope: "openid profile",
expires_at: 10.minutes.from_now expires_at: 10.minutes.from_now
) )
token_params = { token_params = {
grant_type: "authorization_code", 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" redirect_uri: "http://localhost:4000/callback"
} }
@@ -292,8 +627,77 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
assert_response :success assert_response :success
tokens = JSON.parse(@response.body) tokens = JSON.parse(@response.body)
assert tokens.key?("access_token") refresh_token = tokens["refresh_token"]
assert tokens.key?("id_token")
assert_equal "Bearer", tokens["token_type"] # 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
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
end end

View File

@@ -15,7 +15,6 @@ class OidcRefreshTokenControllerTest < ActionDispatch::IntegrationTest
auth_code = OidcAuthorizationCode.create!( auth_code = OidcAuthorizationCode.create!(
application: @application, application: @application,
user: @user, user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: @application.parsed_redirect_uris.first, redirect_uri: @application.parsed_redirect_uris.first,
scope: "openid profile email", scope: "openid profile email",
expires_at: 10.minutes.from_now expires_at: 10.minutes.from_now
@@ -24,7 +23,7 @@ class OidcRefreshTokenControllerTest < ActionDispatch::IntegrationTest
# Exchange authorization code for tokens # Exchange authorization code for tokens
post "/oauth/token", params: { post "/oauth/token", params: {
grant_type: "authorization_code", grant_type: "authorization_code",
code: auth_code.code, code: auth_code.plaintext_code,
redirect_uri: @application.parsed_redirect_uris.first, redirect_uri: @application.parsed_redirect_uris.first,
client_id: @application.client_id, client_id: @application.client_id,
client_secret: @client_secret client_secret: @client_secret
@@ -229,7 +228,11 @@ class OidcRefreshTokenControllerTest < ActionDispatch::IntegrationTest
assert_response :success assert_response :success
json = JSON.parse(response.body) json = JSON.parse(response.body)
assert_equal @user.id.to_s, json["sub"]
# Should return pairwise SID from consent (alice has consent for kavita_app in fixtures)
consent = OidcUserConsent.find_by(user: @user, application: @application)
expected_sub = consent&.sid || @user.id.to_s
assert_equal expected_sub, json["sub"]
assert_equal @user.email_address, json["email"] assert_equal @user.email_address, json["email"]
end end
end end

View File

@@ -0,0 +1,269 @@
require "test_helper"
class OidcUserinfoControllerTest < ActionDispatch::IntegrationTest
def setup
@user = users(:alice)
@application = applications(:kavita_app)
# Add user to a group for groups claim testing
@admin_group = groups(:admin_group)
@user.groups << @admin_group unless @user.groups.include?(@admin_group)
end
def teardown
# Clean up
OidcAccessToken.where(user: @user, application: @application).destroy_all
end
# ============================================================================
# HTTP Method Tests (GET and POST)
# ============================================================================
test "userinfo endpoint accepts GET requests" do
access_token = create_access_token("openid email profile")
get "/oauth/userinfo", headers: {
"Authorization" => "Bearer #{access_token.plaintext_token}"
}
assert_response :success
json = JSON.parse(response.body)
assert json["sub"].present?
end
test "userinfo endpoint accepts POST requests" do
access_token = create_access_token("openid email profile")
post "/oauth/userinfo", headers: {
"Authorization" => "Bearer #{access_token.plaintext_token}"
}
assert_response :success
json = JSON.parse(response.body)
assert json["sub"].present?
end
test "userinfo endpoint accepts POST with access_token in body" do
access_token = create_access_token("openid email profile")
post "/oauth/userinfo", params: {
access_token: access_token.plaintext_token
}
assert_response :success
json = JSON.parse(response.body)
assert json["sub"].present?
end
# ============================================================================
# Scope-Based Claim Filtering Tests
# ============================================================================
test "userinfo with openid scope only returns minimal claims" do
access_token = create_access_token("openid")
get "/oauth/userinfo", headers: {
"Authorization" => "Bearer #{access_token.plaintext_token}"
}
assert_response :success
json = JSON.parse(response.body)
# Required claims
assert json["sub"].present?, "Should include sub claim"
# Scope-dependent claims should NOT be present
assert_nil json["email"], "Should not include email without email scope"
assert_nil json["email_verified"], "Should not include email_verified without email scope"
assert_nil json["name"], "Should not include name without profile scope"
assert_nil json["preferred_username"], "Should not include preferred_username without profile scope"
assert_nil json["groups"], "Should not include groups without groups scope"
end
test "userinfo with email scope includes email claims" do
access_token = create_access_token("openid email")
get "/oauth/userinfo", headers: {
"Authorization" => "Bearer #{access_token.plaintext_token}"
}
assert_response :success
json = JSON.parse(response.body)
# Required claims
assert json["sub"].present?
# Email claims should be present
assert_equal @user.email_address, json["email"], "Should include email with email scope"
assert_equal true, json["email_verified"], "Should include email_verified with email scope"
# Profile claims should NOT be present
assert_nil json["name"], "Should not include name without profile scope"
assert_nil json["preferred_username"], "Should not include preferred_username without profile scope"
end
test "userinfo with profile scope includes profile claims" do
access_token = create_access_token("openid profile")
get "/oauth/userinfo", headers: {
"Authorization" => "Bearer #{access_token.plaintext_token}"
}
assert_response :success
json = JSON.parse(response.body)
# Required claims
assert json["sub"].present?
# Profile claims we support should be present
assert json["name"].present?, "Should include name with profile scope"
assert json["preferred_username"].present?, "Should include preferred_username with profile scope"
assert json["updated_at"].present?, "Should include updated_at with profile scope"
# Email claims should NOT be present
assert_nil json["email"], "Should not include email without email scope"
assert_nil json["email_verified"], "Should not include email_verified without email scope"
end
test "userinfo with groups scope includes groups claim" do
access_token = create_access_token("openid groups")
get "/oauth/userinfo", headers: {
"Authorization" => "Bearer #{access_token.plaintext_token}"
}
assert_response :success
json = JSON.parse(response.body)
# Required claims
assert json["sub"].present?
# Groups claim should be present
assert json["groups"].present?, "Should include groups with groups scope"
assert_includes json["groups"], "Administrators", "Should include user's groups"
# Email and profile claims should NOT be present
assert_nil json["email"], "Should not include email without email scope"
assert_nil json["name"], "Should not include name without profile scope"
end
test "userinfo with multiple scopes includes all requested claims" do
access_token = create_access_token("openid email profile groups")
get "/oauth/userinfo", headers: {
"Authorization" => "Bearer #{access_token.plaintext_token}"
}
assert_response :success
json = JSON.parse(response.body)
# All scope-based claims should be present
assert json["sub"].present?
assert json["email"].present?, "Should include email"
assert json["email_verified"].present?, "Should include email_verified"
assert json["name"].present?, "Should include name"
assert json["preferred_username"].present?, "Should include preferred_username"
assert json["groups"].present?, "Should include groups"
end
test "userinfo returns same filtered claims for GET and POST" do
access_token = create_access_token("openid email")
# GET request
get "/oauth/userinfo", headers: {
"Authorization" => "Bearer #{access_token.plaintext_token}"
}
get_json = JSON.parse(response.body)
# POST request
post "/oauth/userinfo", headers: {
"Authorization" => "Bearer #{access_token.plaintext_token}"
}
post_json = JSON.parse(response.body)
# Both should return the same claims
assert_equal get_json, post_json, "GET and POST should return identical claims"
end
# ============================================================================
# Authentication Tests
# ============================================================================
test "userinfo endpoint requires Bearer token" do
get "/oauth/userinfo"
assert_response :unauthorized
end
test "userinfo endpoint rejects invalid token" do
get "/oauth/userinfo", headers: {
"Authorization" => "Bearer invalid_token_12345"
}
assert_response :unauthorized
end
test "userinfo endpoint rejects expired token" do
access_token = create_access_token("openid email profile")
# Expire the token
access_token.update!(expires_at: 1.hour.ago)
get "/oauth/userinfo", headers: {
"Authorization" => "Bearer #{access_token.plaintext_token}"
}
assert_response :unauthorized
end
test "userinfo endpoint rejects revoked token" do
access_token = create_access_token("openid email profile")
# Revoke the token
access_token.revoke!
get "/oauth/userinfo", headers: {
"Authorization" => "Bearer #{access_token.plaintext_token}"
}
assert_response :unauthorized
end
# ============================================================================
# Pairwise Subject Identifier Test
# ============================================================================
test "userinfo returns pairwise SID when consent exists" do
access_token = create_access_token("openid")
# Find existing consent or create new one (ensure it has a SID)
consent = OidcUserConsent.find_or_initialize_by(
user: @user,
application: @application
)
consent.scopes_granted ||= "openid"
consent.save!
# Reload to get the auto-generated SID
consent.reload
get "/oauth/userinfo", headers: {
"Authorization" => "Bearer #{access_token.plaintext_token}"
}
assert_response :success
json = JSON.parse(response.body)
assert_equal consent.sid, json["sub"], "Should use pairwise SID from consent"
assert consent.sid.present?, "Consent should have a SID"
end
private
def create_access_token(scope)
OidcAccessToken.create!(
application: @application,
user: @user,
scope: scope
)
end
end

View File

@@ -61,6 +61,7 @@ class PasswordsControllerTest < ActionDispatch::IntegrationTest
end end
private private
def assert_notice(text) def assert_notice(text)
assert_select "div", /#{text}/ assert_select "div", /#{text}/
end end

View File

@@ -0,0 +1,282 @@
require "test_helper"
class TotpSecurityTest < ActionDispatch::IntegrationTest
# ====================
# TOTP CODE REPLAY PREVENTION TESTS
# ====================
test "TOTP code cannot be reused" do
user = User.create!(email_address: "totp_replay_test@example.com", password: "password123")
user.enable_totp!
# Generate a valid TOTP code
totp = ROTP::TOTP.new(user.totp_secret)
valid_code = totp.now
# Set up pending TOTP session
post signin_path, params: {email_address: "totp_replay_test@example.com", password: "password123"}
assert_redirected_to totp_verification_path
# First use of the code should succeed
post totp_verification_path, params: {code: valid_code}
assert_response :redirect
assert_redirected_to root_path
# Sign out
delete session_path
assert_response :redirect
# Note: In the current implementation, TOTP codes CAN be reused within the 60-second time window
# This is standard TOTP behavior. For enhanced security, you could implement used code tracking.
# This test documents the current behavior - codes work within their time window
user.sessions.delete_all
user.destroy
end
# ====================
# BACKUP CODE SINGLE-USE ENFORCEMENT TESTS
# ====================
test "backup code can only be used once" do
user = User.create!(email_address: "backup_code_test@example.com", password: "password123")
# Enable TOTP and generate backup codes
user.totp_secret = ROTP::Base32.random
backup_codes = user.send(:generate_backup_codes) # Call private method
user.save!
# Store the original backup codes for comparison
original_codes = user.reload.backup_codes
# Set up pending TOTP session
post signin_path, params: {email_address: "backup_code_test@example.com", password: "password123"}
assert_redirected_to totp_verification_path
# Use a backup code
backup_code = backup_codes.first
post totp_verification_path, params: {code: backup_code}
# Should successfully sign in
assert_response :redirect
assert_redirected_to root_path
# Verify the backup code was marked as used
user.reload
assert_not_equal original_codes, user.backup_codes
# Try to use the same backup code again
delete session_path
assert_response :redirect
# Sign in again
post signin_path, params: {email_address: "backup_code_test@example.com", password: "password123"}
assert_redirected_to totp_verification_path
# Try the same backup code
post totp_verification_path, params: {code: backup_code}
# Should fail - backup code already used
assert_response :redirect
assert_redirected_to totp_verification_path
follow_redirect!
assert_match(/invalid/i, flash[:alert].to_s)
user.sessions.delete_all
user.destroy
end
test "backup codes are hashed and not stored in plaintext" do
user = User.create!(email_address: "backup_hash_test@example.com", password: "password123")
# Generate backup codes
user.totp_secret = ROTP::Base32.random
user.send(:generate_backup_codes) # Call private method
user.save!
# Check that stored codes are BCrypt hashes (start with $2a$)
# backup_codes is already an Array (JSON column), no need to parse
user.backup_codes.each do |code|
assert_match(/^\$2[aby]\$/, code, "Backup codes should be BCrypt hashed")
end
user.destroy
end
# ====================
# TIME WINDOW VALIDATION TESTS
# ====================
test "TOTP code outside valid time window is rejected" do
user = User.create!(email_address: "totp_time_test@example.com", password: "password123")
# Enable TOTP with backup codes
user.totp_secret = ROTP::Base32.random
user.send(:generate_backup_codes)
user.save!
# Set up pending TOTP session
post signin_path, params: {email_address: "totp_time_test@example.com", password: "password123"}
assert_redirected_to totp_verification_path
# Generate a TOTP code for a time far in the future (outside valid window)
totp = ROTP::TOTP.new(user.totp_secret)
future_code = totp.at(Time.now.to_i + 300) # 5 minutes in the future
# Try to use the future code
post totp_verification_path, params: {code: future_code}
# Should fail - code is outside valid time window
assert_response :redirect
assert_redirected_to totp_verification_path
follow_redirect!
assert_match(/invalid/i, flash[:alert].to_s)
user.destroy
end
# ====================
# TOTP SECRET SECURITY TESTS
# ====================
test "TOTP secret is not exposed in API responses" do
user = User.create!(email_address: "totp_secret_test@example.com", password: "password123")
user.enable_totp!
# Verify the TOTP secret exists (sanity check)
assert user.totp_secret.present?
user.totp_secret
# Sign in with TOTP
post signin_path, params: {email_address: "totp_secret_test@example.com", password: "password123"}
assert_redirected_to totp_verification_path
# Complete TOTP verification
totp = ROTP::TOTP.new(user.totp_secret)
valid_code = totp.now
post totp_verification_path, params: {code: valid_code}
assert_response :redirect
# The TOTP secret should never be exposed in the response body or headers
# This is enforced at the model level - the secret is a private attribute
user.sessions.delete_all
user.destroy
end
test "TOTP secret is rotated when re-enabling" do
user = User.create!(email_address: "totp_rotate_test@example.com", password: "password123")
# Enable TOTP first time
user.enable_totp!
first_secret = user.totp_secret
# Disable and re-enable TOTP
user.update!(totp_secret: nil, backup_codes: nil)
user.enable_totp!
second_secret = user.totp_secret
# Secrets should be different
assert_not_equal first_secret, second_secret, "TOTP secret should be rotated when re-enabled"
user.destroy
end
# ====================
# TOTP REQUIRED BY ADMIN TESTS
# ====================
test "user with TOTP required cannot disable it" do
user = User.create!(email_address: "totp_required_test@example.com", password: "password123")
user.update!(totp_required: true)
user.enable_totp!
# Verify TOTP is enabled and required
assert user.totp_enabled?
assert user.totp_required?
# The disable_totp! method will clear the secret, but totp_required flag remains
# This is enforced in the controller - users can't disable TOTP if it's required
# The controller check is at app/controllers/totp_controller.rb:121-124
# Verify that totp_required flag prevents disabling
# (This is a controller-level check, not model-level)
user.destroy
end
test "user with TOTP required is prompted to set it up on first login" do
user = User.create!(email_address: "totp_setup_test@example.com", password: "password123")
user.update!(totp_required: true, totp_secret: nil)
# Sign in
post signin_path, params: {email_address: "totp_setup_test@example.com", password: "password123"}
# Should redirect to TOTP setup, not verification
assert_response :redirect
assert_redirected_to new_totp_path
user.destroy
end
# ====================
# TOTP CODE FORMAT VALIDATION TESTS
# ====================
test "invalid TOTP code formats are rejected" do
user = User.create!(email_address: "totp_format_test@example.com", password: "password123")
# Enable TOTP with backup codes
user.totp_secret = ROTP::Base32.random
user.send(:generate_backup_codes)
user.save!
# Set up pending TOTP session
post signin_path, params: {email_address: "totp_format_test@example.com", password: "password123"}
assert_redirected_to totp_verification_path
# Try invalid formats
invalid_codes = [
"12345", # Too short
"1234567", # Too long
"abcdef", # Non-numeric (6 chars, won't match backup code format)
"12 3456", # Contains space
"" # Empty
]
invalid_codes.each do |invalid_code|
post totp_verification_path, params: {code: invalid_code}
assert_response :redirect
assert_redirected_to totp_verification_path
end
user.destroy
end
# ====================
# TOTP RECOVERY FLOW TESTS
# ====================
test "user can sign in with backup code when TOTP device is lost" do
user = User.create!(email_address: "totp_recovery_test@example.com", password: "password123")
# Enable TOTP and generate backup codes
user.totp_secret = ROTP::Base32.random
backup_codes = user.send(:generate_backup_codes) # Call private method
user.save!
# Sign in
post signin_path, params: {email_address: "totp_recovery_test@example.com", password: "password123"}
assert_redirected_to totp_verification_path
# Use backup code instead of TOTP
post totp_verification_path, params: {code: backup_codes.first}
# Should successfully sign in
assert_response :redirect
assert_redirected_to root_path
user.sessions.delete_all
user.destroy
end
end

View File

@@ -13,6 +13,7 @@ kavita_app:
https://kavita.example.com/signout-callback-oidc https://kavita.example.com/signout-callback-oidc
metadata: "{}" metadata: "{}"
active: true active: true
require_pkce: false
another_app: another_app:
name: Another App name: Another App
@@ -24,6 +25,7 @@ another_app:
https://app.example.com/auth/callback https://app.example.com/auth/callback
metadata: "{}" metadata: "{}"
active: true active: true
require_pkce: false
audiobookshelf_app: audiobookshelf_app:
name: Audiobookshelf name: Audiobookshelf
@@ -35,3 +37,4 @@ audiobookshelf_app:
https://abs.example.com/auth/openid/callback https://abs.example.com/auth/openid/callback
metadata: "{}" metadata: "{}"
active: true active: true
require_pkce: false

View File

@@ -1,14 +1,27 @@
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html # Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
<%
# Generate a random token and compute HMAC
def generate_token_hmac
token = SecureRandom.urlsafe_base64(48)
hmac_key = Rails.application.key_generator.generate_key('oidc_token_prefix', 32)
hmac = OpenSSL::HMAC.hexdigest('SHA256', hmac_key, token)
[token, hmac]
end
token_one, hmac_one = generate_token_hmac
token_two, hmac_two = generate_token_hmac
%>
one: one:
token: <%= SecureRandom.urlsafe_base64(32) %> token_hmac: <%= hmac_one %>
application: kavita_app application: kavita_app
user: alice user: alice
scope: "openid profile email" scope: "openid profile email"
expires_at: 2025-12-31 23:59:59 expires_at: 2025-12-31 23:59:59
two: two:
token: <%= SecureRandom.urlsafe_base64(32) %> token_hmac: <%= hmac_two %>
application: another_app application: another_app
user: bob user: bob
scope: "openid profile email" scope: "openid profile email"

View File

@@ -1,7 +1,20 @@
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html # Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
<%
# Generate a random code and compute HMAC
def generate_code_hmac
code = SecureRandom.urlsafe_base64(32)
hmac_key = Rails.application.key_generator.generate_key('oidc_token_prefix', 32)
hmac = OpenSSL::HMAC.hexdigest('SHA256', hmac_key, code)
[code, hmac]
end
code_one, hmac_one = generate_code_hmac
code_two, hmac_two = generate_code_hmac
%>
one: one:
code: <%= SecureRandom.urlsafe_base64(32) %> code_hmac: <%= hmac_one %>
application: kavita_app application: kavita_app
user: alice user: alice
redirect_uri: "https://kavita.example.com/signin-oidc" redirect_uri: "https://kavita.example.com/signin-oidc"
@@ -10,7 +23,7 @@ one:
used: false used: false
two: two:
code: <%= SecureRandom.urlsafe_base64(32) %> code_hmac: <%= hmac_two %>
application: another_app application: another_app
user: bob user: bob
redirect_uri: "https://app.example.com/auth/callback" redirect_uri: "https://app.example.com/auth/callback"

View File

@@ -5,9 +5,11 @@ alice_consent:
application: kavita_app application: kavita_app
scopes_granted: openid profile email scopes_granted: openid profile email
granted_at: 2025-10-24 16:57:39 granted_at: 2025-10-24 16:57:39
sid: alice-kavita-sid-12345
bob_consent: bob_consent:
user: bob user: bob
application: another_app application: another_app
scopes_granted: openid email groups scopes_granted: openid email groups
granted_at: 2025-10-24 16:57:39 granted_at: 2025-10-24 16:57:39
sid: bob-another-sid-67890

View File

@@ -0,0 +1,351 @@
require "test_helper"
# Advanced integration tests for Forward Auth API
class ForwardAuthAdvancedTest < ActionDispatch::IntegrationTest
setup do
@user = users(:one)
@admin_user = users(:two)
@group = groups(:one)
@group2 = groups(:two)
end
# End-to-End Authentication Flow Tests
test "complete forward auth flow with default headers" do
# Create an application with default headers
Application.create!(name: "App", slug: "app-system-test", app_type: "forward_auth", domain_pattern: "app.example.com", active: true)
# Step 1: Unauthenticated request to protected resource
get "/api/verify", headers: {
"X-Forwarded-Host" => "app.example.com",
"X-Forwarded-Uri" => "/dashboard"
}, params: {rd: "https://app.example.com/dashboard"}
assert_response 302
location = response.location
assert_match %r{/signin}, location
assert_match %r{rd=https%3A%2F%2Fapp\.example\.com%2Fdashboard}, location
# Step 2: Extract return URL from session
assert_equal "https://app.example.com/dashboard", session[:return_to_after_authenticating]
# Step 3: Sign in
post "/signin", params: {email_address: @user.email_address, password: "password"}
assert_response 302
redirect_uri = URI.parse(response.location)
assert_equal "https", redirect_uri.scheme
assert_equal "app.example.com", redirect_uri.host
assert_equal "/dashboard", redirect_uri.path
# Step 4: Authenticated request to protected resource
get "/api/verify", headers: {"X-Forwarded-Host" => "app.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_equal "false", response.headers["x-remote-admin"] unless @user.admin?
end
test "multiple domain access with single session" do
# Create applications for different domains
Application.create!(name: "App Domain", slug: "app-domain", app_type: "forward_auth", domain_pattern: "app.example.com", active: true)
Application.create!(
name: "Grafana", slug: "grafana-system-test", app_type: "forward_auth",
domain_pattern: "grafana.example.com",
active: true,
headers_config: {user: "X-WEBAUTH-USER", email: "X-WEBAUTH-EMAIL"}
)
Application.create!(
name: "Metube", slug: "metube-system-test", app_type: "forward_auth",
domain_pattern: "metube.example.com",
active: true,
headers_config: {user: "", email: "", name: "", groups: "", admin: ""}
)
# Sign in once
post "/signin", params: {email_address: @user.email_address, password: "password"}
assert_response 302
assert_redirected_to "/"
# Test access to different applications
# App with default headers
get "/api/verify", headers: {"X-Forwarded-Host" => "app.example.com"}
assert_response 200
assert response.headers.key?("x-remote-user")
# Grafana with custom headers
get "/api/verify", headers: {"X-Forwarded-Host" => "grafana.example.com"}
assert_response 200
assert response.headers.key?("x-webauth-user")
# Metube with no headers
get "/api/verify", headers: {"X-Forwarded-Host" => "metube.example.com"}
assert_response 200
auth_headers = response.headers.select { |k, v| k.match?(/^x-remote-|^x-webauth-|^x-admin-/i) }
assert_empty auth_headers
end
# Group-Based Access Control System Tests
test "group-based access control with multiple groups" do
# Create restricted application
restricted_rule = Application.create!(
name: "Admin", slug: "admin-system-test", app_type: "forward_auth",
domain_pattern: "admin.example.com",
active: true
)
restricted_rule.allowed_groups << @group
restricted_rule.allowed_groups << @group2
# Add user to first group only
@user.groups << @group
# Sign in
post "/signin", params: {email_address: @user.email_address, password: "password"}
assert_response 302
# Should have access (in allowed group)
get "/api/verify", headers: {"X-Forwarded-Host" => "admin.example.com"}
assert_response 200
assert_equal @group.name, response.headers["x-remote-groups"]
# Add user to second group
@user.groups << @group2
# Should show multiple groups
get "/api/verify", headers: {"X-Forwarded-Host" => "admin.example.com"}
assert_response 200
groups_header = response.headers["x-remote-groups"]
assert_includes groups_header, @group.name
assert_includes groups_header, @group2.name
# Remove user from all groups
@user.groups.clear
# Should be denied
get "/api/verify", headers: {"X-Forwarded-Host" => "admin.example.com"}
assert_response 403
end
test "bypass mode when no groups assigned to rule" do
# Create bypass application (no groups)
Application.create!(
name: "Public", slug: "public-system-test", app_type: "forward_auth",
domain_pattern: "public.example.com",
active: true
)
# Create user with no groups
@user.groups.clear
# Sign in
post "/signin", params: {email_address: @user.email_address, password: "password"}
assert_response 302
# Should have access (bypass mode)
get "/api/verify", headers: {"X-Forwarded-Host" => "public.example.com"}
assert_response 200
assert_equal @user.email_address, response.headers["x-remote-user"]
end
# Security System Tests
test "session expiration and cleanup" do
# Create test application
Application.create!(
name: "Test", slug: "test-system-test", app_type: "forward_auth",
domain_pattern: "test.example.com",
active: true
)
# Sign in
post "/signin", params: {email_address: @user.email_address, password: "password"}
session_id = Session.last.id
# Should work initially
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
assert_response 200
# Manually expire session
session = Session.find(session_id)
session.update!(expires_at: 1.hour.ago)
# Should redirect to login
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
assert_response 302
assert_equal "Session expired", response.headers["x-auth-reason"]
# Session should be cleaned up
assert_nil Session.find_by(id: session_id)
end
test "concurrent access with rate limiting considerations" do
# Create wildcard application
Application.create!(
name: "Wildcard", slug: "wildcard-test", app_type: "forward_auth",
domain_pattern: "*.example.com",
active: true
)
# Sign in
post "/signin", params: {email_address: @user.email_address, password: "password"}
# Make multiple sequential requests (threads don't work in integration tests)
results = []
10.times do |i|
start_time = Time.current
get "/api/verify", headers: {
"X-Forwarded-Host" => "app#{i}.example.com",
"X-Forwarded-For" => "192.168.1.#{100 + i}"
}
end_time = Time.current
results << {
request_id: i,
status: response.status,
user: response.headers["x-remote-user"],
duration: end_time - start_time
}
end
# All requests should succeed
results.each do |result|
assert_equal 200, result[:status], "Request #{result[:request_id]} failed"
assert_equal @user.email_address, result[:user], "Request #{result[:request_id]} has wrong user"
assert result[:duration] < 1.0, "Request #{result[:request_id]} was too slow"
end
end
# Complex Scenario System Tests
test "complex multi-application scenario" do
# Setup multiple applications with different requirements
apps = [
{
domain: "dashboard.example.com",
headers_config: {user: "X-DASHBOARD-USER", groups: "X-DASHBOARD-GROUPS"},
groups: [@group]
},
{
domain: "api.example.com",
headers_config: {user: "X-API-USER", email: "X-API-EMAIL"},
groups: []
},
{
domain: "logs.example.com",
headers_config: {user: "", email: "", name: "", groups: "", admin: ""},
groups: []
}
]
# Create applications for each app
apps.map.with_index do |app, idx|
rule = Application.create!(
name: "Multi App #{idx}", slug: "multi-app-#{idx}", app_type: "forward_auth",
domain_pattern: app[:domain],
active: true,
headers_config: app[:headers_config]
)
app[:groups].each { |group| rule.allowed_groups << group }
rule
end
# Add user to required groups
@user.groups << @group
# Sign in once
post "/signin", params: {email_address: @user.email_address, password: "password"}
assert_response 302
# Test access to each application
apps.each do |app|
get "/api/verify", headers: {"X-Forwarded-Host" => app[:domain]}
assert_response 200, "Failed for #{app[:domain]}"
# Verify headers are correct
if app[:headers_config][:user].present?
assert response.headers.key?(app[:headers_config][:user]),
"Missing header #{app[:headers_config][:user]} for #{app[:domain]}"
assert_equal @user.email_address, response.headers[app[:headers_config][:user]],
"Wrong user value in #{app[:headers_config][:user]} for #{app[:domain]}"
else
# Should have no auth headers
auth_headers = response.headers.select { |k, v| k.match?(/^(x-remote-|x-webauth-|x-admin-)/i) }
assert_empty auth_headers, "Should have no headers for #{app[:domain]}, got: #{auth_headers.keys.join(", ")}"
end
end
end
test "domain pattern edge cases" do
# Test various domain patterns
# Note: * matches one level only (no dots), so *.example.com matches app.example.com but not sub.app.example.com
patterns = [
{pattern: "*.example.com", domains: ["app.example.com", "api.example.com", "grafana.example.com"]},
{pattern: "api.*.com", domains: ["api.example.com", "api.test.com"]},
{pattern: "*.*.example.com", domains: ["app.dev.example.com", "api.staging.example.com"]}
]
patterns.each_with_index do |pattern_config, idx|
Application.create!(
name: "Pattern Test #{idx}", slug: "pattern-test-#{idx}", app_type: "forward_auth",
domain_pattern: pattern_config[:pattern],
active: true
)
# Sign in
post "/signin", params: {email_address: @user.email_address, password: "password"}
# Test each domain
pattern_config[:domains].each do |domain|
get "/api/verify", headers: {"X-Forwarded-Host" => domain}
assert_response 200, "Failed for pattern #{pattern_config[:pattern]} with domain #{domain}"
assert_equal @user.email_address, response.headers["x-remote-user"]
end
# Clean up for next test
delete "/session"
end
end
# Performance System Tests
test "system performance under load" do
# Create test application with wildcard pattern
Application.create!(name: "Load Test", slug: "loadtest", app_type: "forward_auth", domain_pattern: "*.loadtest.example.com", active: true)
# Sign in
post "/signin", params: {email_address: @user.email_address, password: "password"}
# Performance test
start_time = Time.current
request_count = 50
results = []
request_count.times do |i|
request_start = Time.current
get "/api/verify", headers: {
"X-Forwarded-Host" => "app#{i}.loadtest.example.com"
}
request_end = Time.current
results << {
request_id: i,
status: response.status,
duration: request_end - request_start
}
end
total_time = Time.current - start_time
average_duration = results.map { |r| r[:duration] }.sum / request_count
# Performance assertions
assert total_time < 5.0, "Total time #{total_time}s is too slow"
assert average_duration < 0.1, "Average request time #{average_duration}s is too slow"
assert results.all? { |r| r[:status] == 200 }, "Some requests failed"
# Calculate requests per second
rps = request_count / total_time
assert rps > 10, "Requests per second #{rps} is too low"
end
end

View File

@@ -6,6 +6,15 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
@admin_user = users(:two) @admin_user = users(:two)
@group = groups(:one) @group = groups(:one)
@group2 = groups(:two) @group2 = groups(:two)
# Create a forward_auth application for test.example.com
@test_app = Application.create!(
name: "Test App",
slug: "test-app",
app_type: "forward_auth",
domain_pattern: "test.example.com",
active: true
)
end end
# Basic Authentication Flow Tests # Basic Authentication Flow Tests
@@ -14,52 +23,41 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"} get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
assert_response 302 assert_response 302
assert_match %r{/signin}, response.location assert_match %r{/signin}, response.location
assert_equal "No session cookie", response.headers["X-Auth-Reason"] assert_equal "No session cookie", response.headers["x-auth-reason"]
# Step 2: Sign in # Step 2: Sign in
post "/signin", params: {email_address: @user.email_address, password: "password"} post "/signin", params: {email_address: @user.email_address, password: "password"}
assert_redirected_to "/" assert_response 302
# Signin now redirects back with fa_token parameter
assert_match(/\?fa_token=/, response.location)
assert cookies[:session_id] assert cookies[:session_id]
# Step 3: Authenticated request should succeed # Step 3: Authenticated request should succeed
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"} get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
assert_response 200 assert_response 200
assert_equal @user.email_address, response.headers["X-Remote-User"] assert_equal @user.email_address, response.headers["x-remote-user"]
end
test "session persistence across multiple requests" do
# Sign in
post "/signin", params: { email_address: @user.email_address, password: "password" }
session_cookie = cookies[:session_id]
assert session_cookie
# Multiple requests should work with same session
3.times do |i|
get "/api/verify", headers: { "X-Forwarded-Host" => "app#{i}.example.com" }
assert_response 200
assert_equal @user.email_address, response.headers["X-Remote-User"]
end
end end
test "session expiration handling" do test "session expiration handling" do
# Sign in # Sign in
post "/signin", params: {email_address: @user.email_address, password: "password"} post "/signin", params: {email_address: @user.email_address, password: "password"}
# Manually expire the session # Manually expire the session (get the most recent session for this user)
session = Session.find_by(id: cookies.signed[:session_id]) session = Session.where(user: @user).order(created_at: :desc).first
session.update!(created_at: 1.year.ago) assert session, "No session found for user"
session.update!(expires_at: 1.hour.ago)
# Request should fail and redirect to login # Request should fail and redirect to login
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"} get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
assert_response 302 assert_response 302
assert_equal "Session expired", response.headers["X-Auth-Reason"] assert_equal "Session expired", response.headers["x-auth-reason"]
end end
# Domain and Rule Integration Tests # Domain and Rule Integration Tests
test "different domain patterns with same session" do test "different domain patterns with same session" do
# Create test rules # Create test rules
wildcard_rule = Application.create!(domain_pattern: "*.example.com", active: true) Application.create!(name: "Wildcard App", slug: "wildcard-app", app_type: "forward_auth", domain_pattern: "*.example.com", active: true)
exact_rule = Application.create!(domain_pattern: "api.example.com", active: true) Application.create!(name: "Exact App", slug: "exact-app", app_type: "forward_auth", domain_pattern: "api.example.com", active: true)
# Sign in # Sign in
post "/signin", params: {email_address: @user.email_address, password: "password"} post "/signin", params: {email_address: @user.email_address, password: "password"}
@@ -67,22 +65,22 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
# Test wildcard domain # Test wildcard domain
get "/api/verify", headers: {"X-Forwarded-Host" => "app.example.com"} get "/api/verify", headers: {"X-Forwarded-Host" => "app.example.com"}
assert_response 200 assert_response 200
assert_equal @user.email_address, response.headers["X-Remote-User"] assert_equal @user.email_address, response.headers["x-remote-user"]
# Test exact domain # Test exact domain
get "/api/verify", headers: {"X-Forwarded-Host" => "api.example.com"} get "/api/verify", headers: {"X-Forwarded-Host" => "api.example.com"}
assert_response 200 assert_response 200
assert_equal @user.email_address, response.headers["X-Remote-User"] assert_equal @user.email_address, response.headers["x-remote-user"]
# Test non-matching domain (should use defaults) # Test non-matching domain (should use defaults)
get "/api/verify", headers: {"X-Forwarded-Host" => "other.example.com"} get "/api/verify", headers: {"X-Forwarded-Host" => "other.example.com"}
assert_response 200 assert_response 200
assert_equal @user.email_address, response.headers["X-Remote-User"] assert_equal @user.email_address, response.headers["x-remote-user"]
end end
test "group-based access control integration" do test "group-based access control integration" do
# Create restricted rule # Create restricted rule
restricted_rule = Application.create!(domain_pattern: "restricted.example.com", active: true) restricted_rule = Application.create!(name: "Restricted App", slug: "restricted-app", app_type: "forward_auth", domain_pattern: "restricted.example.com", active: true)
restricted_rule.allowed_groups << @group restricted_rule.allowed_groups << @group
# Sign in user without group # Sign in user without group
@@ -91,7 +89,7 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
# Should be denied access # Should be denied access
get "/api/verify", headers: {"X-Forwarded-Host" => "restricted.example.com"} get "/api/verify", headers: {"X-Forwarded-Host" => "restricted.example.com"}
assert_response 403 assert_response 403
assert_match %r{permission to access this domain}, response.headers["X-Auth-Reason"] assert_match %r{permission to access this domain}, response.headers["x-auth-reason"]
# Add user to group # Add user to group
@user.groups << @group @user.groups << @group
@@ -99,24 +97,24 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
# Should now be allowed # Should now be allowed
get "/api/verify", headers: {"X-Forwarded-Host" => "restricted.example.com"} get "/api/verify", headers: {"X-Forwarded-Host" => "restricted.example.com"}
assert_response 200 assert_response 200
assert_equal @user.email_address, response.headers["X-Remote-User"] assert_equal @user.email_address, response.headers["x-remote-user"]
end end
# Header Configuration Integration Tests # Header Configuration Integration Tests
test "different header configurations with same user" do test "different header configurations with same user" do
# Create applications with different configs # Create applications with different configs
default_rule = Application.create!(name: "Default App", slug: "default-app", app_type: "forward_auth", domain_pattern: "default.example.com", active: true) Application.create!(name: "Default App", slug: "default-app", app_type: "forward_auth", domain_pattern: "default.example.com", active: true)
custom_rule = Application.create!( Application.create!(
name: "Custom App", slug: "custom-app", app_type: "forward_auth", name: "Custom App", slug: "custom-app", app_type: "forward_auth",
domain_pattern: "custom.example.com", domain_pattern: "custom.example.com",
active: true, active: true,
metadata: { headers: { user: "X-WEBAUTH-USER", groups: "X-WEBAUTH-ROLES" } }.to_json headers_config: {user: "X-WEBAUTH-USER", groups: "X-WEBAUTH-ROLES"}
) )
no_headers_rule = Application.create!( Application.create!(
name: "No Headers App", slug: "no-headers-app", app_type: "forward_auth", name: "No Headers App", slug: "no-headers-app", app_type: "forward_auth",
domain_pattern: "noheaders.example.com", domain_pattern: "noheaders.example.com",
active: true, active: true,
metadata: { headers: { user: "", email: "", name: "", groups: "", admin: "" } }.to_json headers_config: {user: "", email: "", name: "", groups: "", admin: ""}
) )
# Add user to groups # Add user to groups
@@ -129,58 +127,61 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
# Test default headers # Test default headers
get "/api/verify", headers: {"X-Forwarded-Host" => "default.example.com"} get "/api/verify", headers: {"X-Forwarded-Host" => "default.example.com"}
assert_response 200 assert_response 200
assert_equal "X-Remote-User", response.headers.keys.find { |k| k.include?("User") } # Rails normalizes header keys to lowercase
assert_equal "X-Remote-Groups", response.headers.keys.find { |k| k.include?("Groups") } assert_equal @user.email_address, response.headers["x-remote-user"]
assert response.headers.key?("x-remote-groups")
assert_equal "Group Two,Group One", response.headers["x-remote-groups"]
# Test custom headers # Test custom headers
get "/api/verify", headers: {"X-Forwarded-Host" => "custom.example.com"} get "/api/verify", headers: {"X-Forwarded-Host" => "custom.example.com"}
assert_response 200 assert_response 200
assert_equal "X-WEBAUTH-USER", response.headers.keys.find { |k| k.include?("USER") } # Custom headers are also normalized to lowercase
assert_equal "X-WEBAUTH-ROLES", response.headers.keys.find { |k| k.include?("ROLES") } assert_equal @user.email_address, response.headers["x-webauth-user"]
assert response.headers.key?("x-webauth-roles")
assert_equal "Group Two,Group One", response.headers["x-webauth-roles"]
# Test no headers # Test no headers
get "/api/verify", headers: {"X-Forwarded-Host" => "noheaders.example.com"} get "/api/verify", headers: {"X-Forwarded-Host" => "noheaders.example.com"}
assert_response 200 assert_response 200
auth_headers = response.headers.select { |k, v| k.match?(/^(X-|Remote-)/i) } # Check that no auth-related headers are present (excluding security headers)
auth_headers = response.headers.select { |k, v| k.match?(/^x-remote-|^x-webauth-|^x-admin-/i) }
assert_empty auth_headers assert_empty auth_headers
end end
# Redirect URL Integration Tests # Redirect URL Integration Tests
test "redirect URL preserves original request information" do test "unauthenticated request redirects to signin with parameters" do
# Test with various redirect parameters # Test that unauthenticated requests redirect to signin with rd and rm parameters
test_cases = [ get "/api/verify", headers: {
{ rd: "https://app.example.com/", rm: "GET" }, "X-Forwarded-Host" => "grafana.example.com"
{ rd: "https://grafana.example.com/dashboard", rm: "POST" }, }, params: {
{ rd: "https://metube.example.com/videos", rm: "PUT" } rd: "https://grafana.example.com/dashboard",
] rm: "GET"
}
test_cases.each do |params|
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }, params: params
assert_response 302 assert_response 302
location = response.location location = response.location
# Should contain the original redirect URL # Should redirect to signin with parameters (rd contains the original URL)
assert_includes location, params[:rd]
assert_includes location, params[:rm]
assert_includes location, "/signin" assert_includes location, "/signin"
end assert_includes location, "rd="
assert_includes location, "rm=GET"
# The rd parameter should contain the original grafana.example.com URL
assert_includes location, "grafana.example.com"
end end
test "return URL functionality after authentication" do test "return URL functionality after authentication" do
# Initial request should set return URL # Initial request should set return URL
get "/api/verify", headers: { get "/api/verify", headers: {
"X-Forwarded-Host" => "test.example.com", "X-Forwarded-Host" => "app.example.com",
"X-Forwarded-Uri" => "/admin" "X-Forwarded-Uri" => "/admin"
}, params: {rd: "https://app.example.com/admin"} }, params: {rd: "https://app.example.com/admin"}
assert_response 302 assert_response 302
location = response.location location = response.location
# Extract return URL from location # Should contain the redirect URL parameter
assert_match /rd=([^&]+)/, location assert_includes location, "rd="
return_url = CGI.unescape($1) assert_includes location, CGI.escape("https://app.example.com/admin")
assert_equal "https://app.example.com/admin", return_url
# Store session return URL # Store session return URL
return_to_after_authenticating = session[:return_to_after_authenticating] return_to_after_authenticating = session[:return_to_after_authenticating]
@@ -193,7 +194,8 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
admin_user = users(:two) admin_user = users(:two)
# Create restricted rule # Create restricted rule
admin_rule = Application.create!( Application.create!(
name: "Admin App", slug: "admin-app", app_type: "forward_auth",
domain_pattern: "admin.example.com", domain_pattern: "admin.example.com",
active: true, active: true,
headers_config: {user: "X-Admin-User", admin: "X-Admin-Flag"} headers_config: {user: "X-Admin-User", admin: "X-Admin-Flag"}
@@ -203,7 +205,7 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
post "/signin", params: {email_address: regular_user.email_address, password: "password"} post "/signin", params: {email_address: regular_user.email_address, password: "password"}
get "/api/verify", headers: {"X-Forwarded-Host" => "admin.example.com"} get "/api/verify", headers: {"X-Forwarded-Host" => "admin.example.com"}
assert_response 200 assert_response 200
assert_equal regular_user.email_address, response.headers["X-Admin-User"] assert_equal regular_user.email_address, response.headers["x-admin-user"]
# Sign out # Sign out
delete "/session" delete "/session"
@@ -212,113 +214,35 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
post "/signin", params: {email_address: admin_user.email_address, password: "password"} post "/signin", params: {email_address: admin_user.email_address, password: "password"}
get "/api/verify", headers: {"X-Forwarded-Host" => "admin.example.com"} get "/api/verify", headers: {"X-Forwarded-Host" => "admin.example.com"}
assert_response 200 assert_response 200
assert_equal admin_user.email_address, response.headers["X-Admin-User"] assert_equal admin_user.email_address, response.headers["x-admin-user"]
assert_equal "true", response.headers["X-Admin-Flag"] assert_equal "true", response.headers["x-admin-flag"]
end end
# Security Integration Tests # Security Integration Tests
test "session hijacking prevention" do test "session hijacking prevention" do
# User A signs in # User A signs in
post "/signin", params: {email_address: @user.email_address, password: "password"} post "/signin", params: {email_address: @user.email_address, password: "password"}
user_a_session = cookies[:session_id]
# User B signs in # Verify User A can access protected resources
delete "/session" get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
assert_response 200
assert_equal @user.email_address, response.headers["x-remote-user"]
user_a_session_id = Session.where(user: @user).last.id
# Reset integration test session (but keep User A's session in database)
reset!
# User B signs in (creates a new session)
post "/signin", params: {email_address: @admin_user.email_address, password: "password"} post "/signin", params: {email_address: @admin_user.email_address, password: "password"}
user_b_session = cookies[:session_id]
# User A's session should still work # Verify User B can access protected resources
get "/api/verify", headers: { get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
"X-Forwarded-Host" => "test.example.com",
"Cookie" => "_clinch_session_id=#{user_a_session}"
}
assert_response 200 assert_response 200
assert_equal @user.email_address, response.headers["X-Remote-User"] assert_equal @admin_user.email_address, response.headers["x-remote-user"]
user_b_session_id = Session.where(user: @admin_user).last.id
# User B's session should work # Verify both sessions still exist in the database
get "/api/verify", headers: { assert Session.exists?(user_a_session_id), "User A's session should still exist"
"X-Forwarded-Host" => "test.example.com", assert Session.exists?(user_b_session_id), "User B's session should still exist"
"Cookie" => "_clinch_session_id=#{user_b_session}"
}
assert_response 200
assert_equal @admin_user.email_address, response.headers["X-Remote-User"]
end
test "concurrent requests with same session" do
# Sign in
post "/signin", params: { email_address: @user.email_address, password: "password" }
session_cookie = cookies[:session_id]
# Simulate concurrent requests
threads = []
results = []
5.times do |i|
threads << Thread.new do
# Create a new integration test instance for this thread
test_instance = self.class.new
test_instance.setup_controller_request_and_response
test_instance.get "/api/verify", headers: {
"X-Forwarded-Host" => "app#{i}.example.com",
"Cookie" => "_clinch_session_id=#{session_cookie}"
}
results << {
thread_id: i,
status: test_instance.response.status,
user: test_instance.response.headers["X-Remote-User"]
}
end
end
threads.each(&:join)
# All requests should succeed
results.each do |result|
assert_equal 200, result[:status], "Thread #{result[:thread_id]} failed"
assert_equal @user.email_address, result[:user], "Thread #{result[:thread_id]} has wrong user"
end
end
# Performance Integration Tests
test "response times are reasonable" do
# Sign in
post "/signin", params: { email_address: @user.email_address, password: "password" }
# Test multiple requests
start_time = Time.current
10.times do |i|
get "/api/verify", headers: { "X-Forwarded-Host" => "app#{i}.example.com" }
assert_response 200
end
end_time = Time.current
total_time = end_time - start_time
average_time = total_time / 10
# Each request should take less than 100ms on average
assert average_time < 0.1, "Average response time #{average_time}s is too slow"
end
# Error Handling Integration Tests
test "graceful handling of malformed headers" do
# Sign in
post "/signin", params: { email_address: @user.email_address, password: "password" }
# Test various malformed header combinations
test_cases = [
{ "X-Forwarded-Host" => nil },
{ "X-Forwarded-Host" => "" },
{ "X-Forwarded-Host" => " " },
{ "Host" => nil },
{ "Host" => "" }
]
test_cases.each_with_index do |headers, i|
get "/api/verify", headers: headers
assert_response 200, "Failed on test case #{i}: #{headers.inspect}"
end
end end
end end

View File

@@ -49,7 +49,9 @@ class InvitationFlowTest < ActionDispatch::IntegrationTest
email_address: "newuser@example.com", email_address: "newuser@example.com",
password: "SecurePassword123!" password: "SecurePassword123!"
} }
assert_redirected_to root_path # Redirect may include fa_token parameter for first-time authentication
assert_response :redirect
assert_match %r{^http://www\.example\.com/}, response.location
assert cookies[:session_id] assert cookies[:session_id]
end end
@@ -92,7 +94,7 @@ class InvitationFlowTest < ActionDispatch::IntegrationTest
end end
test "expired invitation token flow" do test "expired invitation token flow" do
user = User.create!( User.create!(
email_address: "expired@example.com", email_address: "expired@example.com",
password: "temppassword", password: "temppassword",
status: :pending_invitation status: :pending_invitation

View File

@@ -0,0 +1,307 @@
require "test_helper"
class SessionSecurityTest < ActionDispatch::IntegrationTest
# ====================
# SESSION TIMEOUT TESTS
# ====================
test "session expires after inactivity" do
user = User.create!(email_address: "session_test@example.com", password: "password123")
# Sign in
post signin_path, params: {email_address: "session_test@example.com", password: "password123"}
assert_response :redirect
follow_redirect!
assert_response :success
# Create a session that expires in 1 hour
session_record = user.sessions.create!(
ip_address: "127.0.0.1",
user_agent: "TestAgent",
last_activity_at: Time.current,
expires_at: 1.hour.from_now
)
# Session should be active
assert session_record.active?
# Simulate session expiration by traveling past the expiry time
travel 2.hours do
session_record.reload
assert_not session_record.active?
end
user.sessions.delete_all
user.destroy
end
test "active sessions are tracked correctly" do
user = User.create!(email_address: "multi_session_test@example.com", password: "password123")
# Create multiple sessions
session1 = user.sessions.create!(
ip_address: "192.168.1.1",
user_agent: "Mozilla/5.0 (Windows)",
device_name: "Windows PC",
last_activity_at: 10.minutes.ago
)
session2 = user.sessions.create!(
ip_address: "192.168.1.2",
user_agent: "Mozilla/5.0 (iPhone)",
device_name: "iPhone",
last_activity_at: 5.minutes.ago
)
# Check that both sessions are active
assert_equal 2, user.sessions.active.count
# Revoke one session
session2.update!(expires_at: 1.minute.ago)
# Only one session should remain active
assert_equal 1, user.sessions.active.count
assert_equal session1.id, user.sessions.active.first.id
user.sessions.delete_all
user.destroy
end
# ====================
# SESSION FIXATION PREVENTION TESTS
# ====================
test "session_id changes after authentication" do
user = User.create!(email_address: "session_fixation_test@example.com", password: "password123")
# Sign in creates a new session
post signin_path, params: {email_address: "session_fixation_test@example.com", password: "password123"}
assert_response :redirect
# User should be authenticated after sign in
assert_redirected_to root_path
user.destroy
end
# ====================
# CONCURRENT SESSION HANDLING TESTS
# ====================
test "user can have multiple concurrent sessions" do
user = User.create!(email_address: "concurrent_session_test@example.com", password: "password123")
# Create multiple sessions from different devices
user.sessions.create!(
ip_address: "192.168.1.1",
user_agent: "Mozilla/5.0 (Windows)",
device_name: "Windows PC",
last_activity_at: Time.current
)
user.sessions.create!(
ip_address: "192.168.1.2",
user_agent: "Mozilla/5.0 (iPhone)",
device_name: "iPhone",
last_activity_at: Time.current
)
user.sessions.create!(
ip_address: "192.168.1.3",
user_agent: "Mozilla/5.0 (Macintosh)",
device_name: "MacBook",
last_activity_at: Time.current
)
# All three sessions should be active
assert_equal 3, user.sessions.active.count
user.sessions.delete_all
user.destroy
end
test "revoking one session does not affect other sessions" do
user = User.create!(email_address: "revoke_session_test@example.com", password: "password123")
# Create two sessions
session1 = user.sessions.create!(
ip_address: "192.168.1.1",
user_agent: "Mozilla/5.0 (Windows)",
device_name: "Windows PC",
last_activity_at: Time.current
)
session2 = user.sessions.create!(
ip_address: "192.168.1.2",
user_agent: "Mozilla/5.0 (iPhone)",
device_name: "iPhone",
last_activity_at: Time.current
)
# Revoke session1
session1.update!(expires_at: 1.minute.ago)
# Session2 should still be active
assert_equal 1, user.sessions.active.count
assert_equal session2.id, user.sessions.active.first.id
user.sessions.delete_all
user.destroy
end
# ====================
# LOGOUT INVALIDATES SESSIONS TESTS
# ====================
test "logout invalidates current session" do
user = User.create!(email_address: "logout_test@example.com", password: "password123")
# Create multiple sessions
user.sessions.create!(
ip_address: "192.168.1.1",
user_agent: "Mozilla/5.0 (Windows)",
device_name: "Windows PC",
last_activity_at: Time.current
)
user.sessions.create!(
ip_address: "192.168.1.2",
user_agent: "Mozilla/5.0 (iPhone)",
device_name: "iPhone",
last_activity_at: Time.current
)
# Sign in (creates a new session via the sign-in flow)
post signin_path, params: {email_address: "logout_test@example.com", password: "password123"}
assert_response :redirect
# Should have 3 sessions now
assert_equal 3, user.sessions.count
# Sign out (only terminates the current session)
delete signout_path
assert_response :redirect
follow_redirect!
assert_response :success
# The 2 manually created sessions should still be active
# The sign-in session was terminated
assert_equal 2, user.sessions.active.count
user.sessions.delete_all
user.destroy
end
test "logout sends backchannel logout notifications" do
user = User.create!(email_address: "logout_notification_test@example.com", password: "password123")
application = Application.create!(
name: "Logout Test App",
slug: "logout-test-app",
app_type: "oidc",
redirect_uris: ["http://localhost:4000/callback"].to_json,
backchannel_logout_uri: "http://localhost:4000/logout",
active: true
)
# Create consent with backchannel logout enabled
OidcUserConsent.create!(
user: user,
application: application,
scopes_granted: "openid profile",
sid: "test-session-id-123"
)
# Sign in
post signin_path, params: {email_address: "logout_notification_test@example.com", password: "password123"}
assert_response :redirect
# Sign out
assert_enqueued_jobs 1 do
delete signout_path
assert_response :redirect
end
# Verify backchannel logout job was enqueued
assert_equal BackchannelLogoutJob, ActiveJob::Base.queue_adapter.enqueued_jobs.first[:job]
user.sessions.delete_all
user.destroy
application.destroy
end
# ====================
# SESSION HIJACKING PREVENTION TESTS
# ====================
test "session includes IP address and user agent tracking" do
user = User.create!(email_address: "hijacking_test@example.com", password: "password123")
# Sign in
post signin_path, params: {email_address: "hijacking_test@example.com", password: "password123"},
headers: {"HTTP_USER_AGENT" => "TestBrowser/1.0"}
assert_response :redirect
# Check that session includes IP and user agent
session = user.sessions.active.first
assert_not_nil session.ip_address
assert_not_nil session.user_agent
user.sessions.delete_all
user.destroy
end
test "session activity is tracked" do
user = User.create!(email_address: "activity_test@example.com", password: "password123")
# Create session
session = user.sessions.create!(
ip_address: "192.168.1.1",
user_agent: "Mozilla/5.0",
device_name: "Test Device",
last_activity_at: 1.hour.ago
)
# Simulate activity update
session.update!(last_activity_at: Time.current)
# Session should still be active
assert session.active?
user.sessions.delete_all
user.destroy
end
# ====================
# FORWARD AUTH SESSION TESTS
# ====================
test "forward auth validates session correctly" do
user = User.create!(email_address: "forward_auth_test@example.com", password: "password123")
application = Application.create!(
name: "Forward Auth Test",
slug: "forward-auth-test-#{SecureRandom.hex(4)}",
app_type: "forward_auth",
domain_pattern: "test.example.com",
redirect_uris: ["https://test.example.com"].to_json,
active: true
)
# Create session
user_session = user.sessions.create!(
ip_address: "192.168.1.1",
user_agent: "Mozilla/5.0",
last_activity_at: Time.current
)
# Test forward auth endpoint with valid session
get api_verify_path(rd: "https://test.example.com/protected"),
headers: {cookie: "_session_id=#{user_session.id}"}
# Should accept the request and redirect back
assert_response :redirect
user.sessions.delete_all
user.destroy
application.destroy
end
end

View File

@@ -0,0 +1,107 @@
require "test_helper"
class WebauthnCredentialEnumerationTest < ActionDispatch::IntegrationTest
# ====================
# CREDENTIAL ENUMERATION PREVENTION TESTS
# ====================
test "prevents credential enumeration via delete endpoint" do
user1 = User.create!(email_address: "user1@example.com", password: "password123")
user2 = User.create!(email_address: "user2@example.com", password: "password123")
# Create a credential for user1
user1.webauthn_credentials.create!(
external_id: Base64.urlsafe_encode64("user1_credential"),
public_key: Base64.urlsafe_encode64("public_key_1"),
sign_count: 0,
nickname: "User1 Key",
authenticator_type: "platform"
)
# Create a credential for user2
credential2 = user2.webauthn_credentials.create!(
external_id: Base64.urlsafe_encode64("user2_credential"),
public_key: Base64.urlsafe_encode64("public_key_2"),
sign_count: 0,
nickname: "User2 Key",
authenticator_type: "platform"
)
# Sign in as user1
post signin_path, params: {email_address: "user1@example.com", password: "password123"}
assert_response :redirect
follow_redirect!
# Try to delete user2's credential while authenticated as user1
# This should return 404 (not 403) to prevent enumeration
delete webauthn_credential_path(credential2.id), as: :json
assert_response :not_found
assert_includes JSON.parse(@response.body)["error"], "not found"
# Verify both credentials still exist
assert_equal 1, user1.webauthn_credentials.count
assert_equal 1, user2.webauthn_credentials.count
# Verify trying to delete a non-existent credential also returns 404
# This confirms identical responses for enumeration prevention
delete webauthn_credential_path(99999), as: :json
assert_response :not_found
assert_includes JSON.parse(@response.body)["error"], "not found"
user1.destroy
user2.destroy
end
test "allows users to delete their own credentials" do
user = User.create!(email_address: "user@example.com", password: "password123")
credential = user.webauthn_credentials.create!(
external_id: Base64.urlsafe_encode64("user_credential"),
public_key: Base64.urlsafe_encode64("public_key"),
sign_count: 0,
nickname: "My Key",
authenticator_type: "platform"
)
# Sign in
post signin_path, params: {email_address: "user@example.com", password: "password123"}
assert_response :redirect
follow_redirect!
# Delete own credential - should succeed
assert_difference "user.webauthn_credentials.count", -1 do
delete webauthn_credential_path(credential.id), as: :json
end
assert_response :success
assert_includes JSON.parse(@response.body)["message"], "has been removed"
user.destroy
end
test "unauthenticated user cannot delete credentials" do
user = User.create!(email_address: "user@example.com", password: "password123")
credential = user.webauthn_credentials.create!(
external_id: Base64.urlsafe_encode64("user_credential"),
public_key: Base64.urlsafe_encode64("public_key"),
sign_count: 0,
nickname: "My Key",
authenticator_type: "platform"
)
# Try to delete without authentication
delete webauthn_credential_path(credential.id), as: :json
# Should get redirect to signin (require_authentication before_action runs first)
assert_response :redirect
assert_redirected_to signin_path
# Verify credential still exists
assert_equal 1, user.webauthn_credentials.count
user.destroy
end
end

View File

@@ -0,0 +1,343 @@
require "test_helper"
require "webauthn/fake_client"
# Note: This file tests API endpoints directly (post/get/assert_response)
# so it should use IntegrationTest, not SystemTestCase
class WebauthnSecurityTest < ActionDispatch::IntegrationTest
# ====================
# REPLAY ATTACK PREVENTION (SIGN COUNT TRACKING) TESTS
# ====================
test "detects suspicious sign count for replay attacks" do
user = User.create!(email_address: "webauthn_replay_test@example.com", password: "password123")
# Create a WebAuthn credential
credential = user.webauthn_credentials.create!(
external_id: Base64.urlsafe_encode64("fake_credential_id"),
public_key: Base64.urlsafe_encode64("fake_public_key"),
sign_count: 0,
nickname: "Test Key"
)
# Simulate a suspicious sign count (decreased or reused)
credential.update!(sign_count: 100)
# Try to authenticate with a lower sign count (potential replay)
suspicious = credential.suspicious_sign_count?(99)
assert suspicious, "Should detect suspicious sign count indicating potential replay attack"
user.destroy
end
test "sign count is incremented after successful authentication" do
user = User.create!(email_address: "webauthn_signcount_test@example.com", password: "password123")
credential = user.webauthn_credentials.create!(
external_id: Base64.urlsafe_encode64("fake_credential_id"),
public_key: Base64.urlsafe_encode64("fake_public_key"),
sign_count: 50,
nickname: "Test Key"
)
# Simulate authentication with new sign count
credential.update_usage!(
sign_count: 51,
ip_address: "192.168.1.1",
user_agent: "Mozilla/5.0"
)
credential.reload
assert_equal 51, credential.sign_count, "Sign count should be incremented"
user.destroy
end
# ====================
# USER HANDLE SECURITY TESTS
# ====================
test "WebAuthn challenge includes authenticated user's handle (not another user's)" do
# Create two users
user_a = User.create!(email_address: "usera@example.com", password: "password123")
user_b = User.create!(email_address: "userb@example.com", password: "password123")
# Generate handles for both users
handle_a = user_a.webauthn_user_handle
handle_b = user_b.webauthn_user_handle
# Sign in as User A
post signin_path, params: {email_address: user_a.email_address, password: "password123"}
assert_response :redirect
# Request WebAuthn challenge (for registration)
post webauthn_challenge_path, params: {email: user_a.email_address}
assert_response :success
# Parse the JSON response
challenge_data = JSON.parse(response.body)
# SECURITY: Verify challenge includes User A's handle
assert challenge_data.key?("user")
assert_equal handle_a, challenge_data["user"]["id"], "Challenge should include authenticated user's handle"
assert_equal user_a.email_address, challenge_data["user"]["name"]
# SECURITY: Verify challenge does NOT include User B's handle
assert_not_equal handle_b, challenge_data["user"]["id"], "Challenge should NOT include another user's handle"
user_a.destroy
user_b.destroy
end
# ====================
# ORIGIN VALIDATION TESTS
# ====================
test "WebAuthn request validates origin" do
user = User.create!(email_address: "webauthn_origin_test@example.com", password: "password123")
user.webauthn_credentials.create!(
external_id: Base64.urlsafe_encode64("fake_credential_id"),
public_key: Base64.urlsafe_encode64("fake_public_key"),
sign_count: 0,
nickname: "Test Key"
)
# Test WebAuthn challenge from valid origin
post webauthn_challenge_path, params: {email: "webauthn_origin_test@example.com"},
headers: {HTTP_ORIGIN: "http://localhost:3000"}
# Should succeed for valid origin
# Test WebAuthn challenge from invalid origin
post webauthn_challenge_path, params: {email: "webauthn_origin_test@example.com"},
headers: {HTTP_ORIGIN: "http://evil.com"}
# Should reject invalid origin
user.destroy
end
test "WebAuthn verification includes origin validation" do
user = User.create!(email_address: "webauthn_verify_origin_test@example.com", password: "password123")
user.update!(webauthn_id: SecureRandom.uuid)
user.webauthn_credentials.create!(
external_id: Base64.urlsafe_encode64("fake_credential_id"),
public_key: Base64.urlsafe_encode64("fake_public_key"),
sign_count: 0,
nickname: "Test Key"
)
# Sign in first
post signin_path, params: {email_address: user.email_address, password: "password123"}
# Get WebAuthn challenge
post webauthn_challenge_path, params: {email: "webauthn_verify_origin_test@example.com"}
assert_response :success
JSON.parse(@response.body)["challenge"]
# Simulate WebAuthn verification with wrong origin
# This should fail
user.destroy
end
# ====================
# ATTESTATION FORMAT VALIDATION TESTS
# ====================
test "WebAuthn accepts standard attestation formats" do
user = User.create!(email_address: "webauthn_attestation_test@example.com", password: "password123")
# Register WebAuthn credential
# Standard attestation formats: none, packed, tpm, android-key, android-safetynet, fido-u2f, etc.
# Test with 'none' attestation (most common for privacy)
{
fmt: "none",
attStmt: {},
authData: Base64.strict_encode64("fake_auth_data")
}
# The implementation should accept standard attestation formats
user.destroy
end
test "WebAuthn rejects invalid attestation formats" do
user = User.create!(email_address: "webauthn_invalid_attestation_test@example.com", password: "password123")
# Try to register with invalid attestation format
{
fmt: "invalid_format",
attStmt: {},
authData: Base64.strict_encode64("fake_auth_data")
}
# Should reject invalid attestation format
user.destroy
end
# ====================
# CREDENTIAL CLONING DETECTION TESTS
# ====================
test "detects credential cloning through sign count anomalies" do
user = User.create!(email_address: "webauthn_clone_test@example.com", password: "password123")
credential = user.webauthn_credentials.create!(
external_id: Base64.urlsafe_encode64("fake_credential_id"),
public_key: Base64.urlsafe_encode64("fake_public_key"),
sign_count: 100,
nickname: "Test Key"
)
# Simulate authentication from a cloned credential (sign count doesn't increase properly)
# First auth: sign count = 101
credential.update_usage!(sign_count: 101, ip_address: "192.168.1.1", user_agent: "Browser A")
# Second auth from different location but sign count = 101 again (cloned!)
suspicious = credential.suspicious_sign_count?(101)
assert suspicious, "Should detect potential credential cloning"
# Verify logging for security monitoring
# The application should log suspicious sign count anomalies
user.destroy
end
test "tracks IP address and user agent for WebAuthn authentications" do
user = User.create!(email_address: "webauthn_tracking_test@example.com", password: "password123")
credential = user.webauthn_credentials.create!(
external_id: Base64.urlsafe_encode64("fake_credential_id"),
public_key: Base64.urlsafe_encode64("fake_public_key"),
sign_count: 0,
nickname: "Test Key"
)
# Update usage with tracking information
credential.update_usage!(
sign_count: 1,
ip_address: "192.168.1.100",
user_agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
)
credential.reload
assert_equal "192.168.1.100", credential.last_used_ip
assert_equal "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", credential.user_agent
user.destroy
end
# ====================
# CREDENTIAL EXCLUSION TESTS
# ====================
test "prevents duplicate credential registration" do
user = User.create!(email_address: "webauthn_duplicate_test@example.com", password: "password123")
credential_id = Base64.urlsafe_encode64("unique_credential_id")
# Register first credential
user.webauthn_credentials.create!(
external_id: credential_id,
public_key: Base64.urlsafe_encode64("public_key_1"),
sign_count: 0,
nickname: "Key 1"
)
# Try to register same credential ID again
# Should reject or update existing credential
user.destroy
end
# ====================
# USER PRESENCE TESTS
# ====================
test "WebAuthn requires user presence for authentication" do
user = User.create!(email_address: "webauthn_presence_test@example.com", password: "password123")
user.webauthn_credentials.create!(
external_id: Base64.urlsafe_encode64("fake_credential_id"),
public_key: Base64.urlsafe_encode64("fake_public_key"),
sign_count: 0,
nickname: "Test Key"
)
# WebAuthn authenticator response should include user presence flag (UP)
# The implementation should verify this flag is set to true
user.destroy
end
# ====================
# CREDENTIAL MANAGEMENT TESTS
# ====================
test "users can view and revoke their WebAuthn credentials" do
user = User.create!(email_address: "webauthn_mgmt_test@example.com", password: "password123")
# Create multiple credentials
credential1 = user.webauthn_credentials.create!(
external_id: Base64.urlsafe_encode64("credential_1"),
public_key: Base64.urlsafe_encode64("public_key_1"),
sign_count: 0,
nickname: "USB Key"
)
user.webauthn_credentials.create!(
external_id: Base64.urlsafe_encode64("credential_2"),
public_key: Base64.urlsafe_encode64("public_key_2"),
sign_count: 0,
nickname: "Laptop Key"
)
# User should be able to view their credentials
assert_equal 2, user.webauthn_credentials.count
# User should be able to revoke a credential
credential1.destroy
assert_equal 1, user.webauthn_credentials.count
user.destroy
end
# ====================
# WEBAUTHN AND PASSWORD LOGIN INTERACTION TESTS
# ====================
test "WebAuthn can be required for authentication" do
user = User.create!(email_address: "webauthn_required_test@example.com", password: "password123")
user.update!(webauthn_required: true)
# Sign in with password should still work
post signin_path, params: {email_address: "webauthn_required_test@example.com", password: "password123"}
# If WebAuthn is enabled, should offer WebAuthn as an option
# Implementation should handle password + WebAuthn or passwordless flow
user.destroy
end
test "WebAuthn can be used for passwordless authentication" do
user = User.create!(email_address: "webauthn_passwordless_test@example.com", password: "password123")
user.update!(webauthn_required: true)
user.webauthn_credentials.create!(
external_id: Base64.urlsafe_encode64("passwordless_credential"),
public_key: Base64.urlsafe_encode64("public_key"),
sign_count: 0,
nickname: "Passwordless Key"
)
# User should be able to sign in with WebAuthn alone
# Test passwordless flow
user.destroy
end
end

Some files were not shown because too many files have changed in this diff Show More