27 Commits

Author SHA1 Message Date
Dan Milne
e631f606e7 Better error messages
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-03 12:29:27 +11:00
Dan Milne
f4a697ae9b More OpenID Conformance test fixes - work with POST, correct auth code character set, correct no-store cache headers 2026-01-03 12:28:43 +11:00
Dan Milne
16e34ffaf0 Updates for oidc conformance 2026-01-03 10:11:10 +11:00
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
33 changed files with 1340 additions and 2416 deletions

View File

@@ -19,7 +19,9 @@ jobs:
bundler-cache: true
- name: Scan for common Rails security vulnerabilities using static analysis
run: bin/brakeman --no-pager
run: bin/brakeman --no-pager --no-exit-on-warn
# Note: 2 weak warnings exist and are documented as acceptable
# See docs/beta-checklist.md for details
- name: Scan for known security vulnerabilities in gems used
run: bin/bundler-audit
@@ -39,6 +41,34 @@ jobs:
- name: Scan for security vulnerabilities in JavaScript dependencies
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:
runs-on: ubuntu-latest
steps:

View File

@@ -1 +1 @@
3.4.6
3.4.8

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,7 +8,7 @@
# 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
ARG RUBY_VERSION=3.4.6
ARG RUBY_VERSION=3.4.8
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
LABEL org.opencontainers.image.source=https://github.com/dkam/clinch
@@ -16,8 +16,9 @@ LABEL org.opencontainers.image.source=https://github.com/dkam/clinch
# Rails app lives here
WORKDIR /rails
# Install base packages
# Install base packages and upgrade to latest security patches
RUN apt-get update -qq && \
apt-get upgrade -y && \
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 && \
rm -rf /var/lib/apt/lists /var/cache/apt/archives

View File

@@ -90,4 +90,7 @@ group :test do
# 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

View File

@@ -1,7 +1,7 @@
GEM
remote: https://rubygems.org/
specs:
action_text-trix (2.1.15)
action_text-trix (2.1.16)
railties
actioncable (8.1.1)
actionpack (= 8.1.1)
@@ -80,14 +80,14 @@ GEM
android_key_attestation (0.3.0)
ast (2.4.3)
base64 (0.3.0)
bcrypt (3.1.20)
bcrypt_pbkdf (1.1.1)
bigdecimal (3.3.1)
bcrypt (3.1.21)
bcrypt_pbkdf (1.1.2)
bigdecimal (4.0.1)
bindata (2.5.1)
bindex (0.8.1)
bootsnap (1.19.0)
bootsnap (1.20.1)
msgpack (~> 1.2)
brakeman (7.1.1)
brakeman (7.1.2)
racc
builder (3.3.0)
bundler-audit (0.9.3)
@@ -106,37 +106,37 @@ GEM
childprocess (5.1.0)
logger (~> 1.5)
chunky_png (1.4.0)
concurrent-ruby (1.3.5)
connection_pool (2.5.5)
concurrent-ruby (1.3.6)
connection_pool (3.0.2)
cose (1.3.1)
cbor (~> 0.5.9)
openssl-signature_algorithm (~> 1.0)
crass (1.0.6)
date (3.5.0)
debug (1.11.0)
date (3.5.1)
debug (1.11.1)
irb (~> 1.10)
reline (>= 0.3.8)
docile (1.4.1)
dotenv (3.1.8)
dotenv (3.2.0)
drb (2.2.3)
ed25519 (1.4.0)
erb (6.0.0)
erb (6.0.1)
erubi (1.13.1)
et-orbi (1.4.0)
tzinfo
ffi (1.17.2-aarch64-linux-gnu)
ffi (1.17.2-aarch64-linux-musl)
ffi (1.17.2-arm-linux-gnu)
ffi (1.17.2-arm-linux-musl)
ffi (1.17.2-arm64-darwin)
ffi (1.17.2-x86_64-linux-gnu)
ffi (1.17.2-x86_64-linux-musl)
ffi (1.17.3-aarch64-linux-gnu)
ffi (1.17.3-aarch64-linux-musl)
ffi (1.17.3-arm-linux-gnu)
ffi (1.17.3-arm-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)
activesupport (>= 6.1)
i18n (1.14.7)
i18n (1.14.8)
concurrent-ruby (~> 1.0)
image_processing (1.14.0)
mini_magick (>= 4.9.5, < 6)
@@ -145,18 +145,18 @@ GEM
actionpack (>= 6.0.0)
activesupport (>= 6.0.0)
railties (>= 6.0.0)
io-console (0.8.1)
irb (1.15.3)
io-console (0.8.2)
irb (1.16.0)
pp (>= 0.6.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
jbuilder (2.14.1)
actionview (>= 7.0.0)
activesupport (>= 7.0.0)
json (2.16.0)
json (2.18.0)
jwt (3.1.2)
base64
kamal (2.9.0)
kamal (2.10.1)
activesupport (>= 7.0)
base64 (~> 0.2)
bcrypt_pbkdf (~> 1.0)
@@ -176,7 +176,7 @@ GEM
launchy (>= 2.2, < 4)
lint_roller (1.1.0)
logger (1.7.0)
loofah (2.24.1)
loofah (2.25.0)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.9.0)
@@ -190,9 +190,9 @@ GEM
mini_magick (5.3.1)
logger
mini_mime (1.1.5)
minitest (5.26.2)
minitest (5.27.0)
msgpack (1.8.0)
net-imap (0.5.12)
net-imap (0.6.2)
date
net-protocol
net-pop (0.1.2)
@@ -207,21 +207,21 @@ GEM
net-protocol
net-ssh (7.3.0)
nio4r (2.7.5)
nokogiri (1.18.10-aarch64-linux-gnu)
nokogiri (1.19.0-aarch64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.10-aarch64-linux-musl)
nokogiri (1.19.0-aarch64-linux-musl)
racc (~> 1.4)
nokogiri (1.18.10-arm-linux-gnu)
nokogiri (1.19.0-arm-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.10-arm-linux-musl)
nokogiri (1.19.0-arm-linux-musl)
racc (~> 1.4)
nokogiri (1.18.10-arm64-darwin)
nokogiri (1.19.0-arm64-darwin)
racc (~> 1.4)
nokogiri (1.18.10-x86_64-linux-gnu)
nokogiri (1.19.0-x86_64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.10-x86_64-linux-musl)
nokogiri (1.19.0-x86_64-linux-musl)
racc (~> 1.4)
openssl (3.3.2)
openssl (4.0.0)
openssl-signature_algorithm (1.3.0)
openssl (> 2.0)
ostruct (0.6.3)
@@ -232,12 +232,12 @@ GEM
pp (0.6.3)
prettyprint
prettyprint (0.2.0)
prism (1.6.0)
prism (1.7.0)
propshaft (1.3.1)
actionpack (>= 7.0.0)
activesupport (>= 7.0.0)
rack
psych (5.2.6)
psych (5.3.1)
date
stringio
public_suffix (7.0.0)
@@ -251,7 +251,7 @@ GEM
rack (>= 3.0.0)
rack-test (2.2.0)
rack (>= 1.3)
rackup (2.2.1)
rackup (2.3.1)
rack (>= 3)
rails (8.1.1)
actioncable (= 8.1.1)
@@ -285,7 +285,7 @@ GEM
zeitwerk (~> 2.6)
rainbow (3.1.1)
rake (13.3.1)
rdoc (6.16.1)
rdoc (7.0.3)
erb
psych (>= 4.0.0)
tsort
@@ -309,22 +309,22 @@ GEM
rubocop-ast (>= 1.47.1, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.48.0)
rubocop-ast (1.49.0)
parser (>= 3.3.7.2)
prism (~> 1.4)
prism (~> 1.7)
rubocop-performance (1.26.1)
lint_roller (~> 1.1)
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.47.1, < 2.0)
ruby-progressbar (1.13.0)
ruby-vips (2.2.5)
ruby-vips (2.3.0)
ffi (~> 1.12)
logger
rubyzip (3.2.2)
safety_net_attestation (0.5.0)
jwt (>= 2.0, < 4.0)
securerandom (0.4.1)
selenium-webdriver (4.38.0)
selenium-webdriver (4.39.0)
base64 (~> 0.2)
logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5)
@@ -358,14 +358,14 @@ GEM
fugit (~> 1.11)
railties (>= 7.1)
thor (>= 1.3.1)
sqlite3 (2.8.1-aarch64-linux-gnu)
sqlite3 (2.8.1-aarch64-linux-musl)
sqlite3 (2.8.1-arm-linux-gnu)
sqlite3 (2.8.1-arm-linux-musl)
sqlite3 (2.8.1-arm64-darwin)
sqlite3 (2.8.1-x86_64-linux-gnu)
sqlite3 (2.8.1-x86_64-linux-musl)
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
logger
net-scp (>= 1.1.2)
@@ -386,22 +386,22 @@ GEM
rubocop-performance (~> 1.26.0)
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringio (3.1.8)
stringio (3.2.0)
tailwindcss-rails (4.4.0)
railties (>= 7.0.0)
tailwindcss-ruby (~> 4.0)
tailwindcss-ruby (4.1.16)
tailwindcss-ruby (4.1.16-aarch64-linux-gnu)
tailwindcss-ruby (4.1.16-aarch64-linux-musl)
tailwindcss-ruby (4.1.16-arm64-darwin)
tailwindcss-ruby (4.1.16-x86_64-linux-gnu)
tailwindcss-ruby (4.1.16-x86_64-linux-musl)
tailwindcss-ruby (4.1.18)
tailwindcss-ruby (4.1.18-aarch64-linux-gnu)
tailwindcss-ruby (4.1.18-aarch64-linux-musl)
tailwindcss-ruby (4.1.18-arm64-darwin)
tailwindcss-ruby (4.1.18-x86_64-linux-gnu)
tailwindcss-ruby (4.1.18-x86_64-linux-musl)
thor (1.4.0)
thruster (0.1.16)
thruster (0.1.16-aarch64-linux)
thruster (0.1.16-arm64-darwin)
thruster (0.1.16-x86_64-linux)
timeout (0.4.4)
thruster (0.1.17)
thruster (0.1.17-aarch64-linux)
thruster (0.1.17-arm64-darwin)
thruster (0.1.17-x86_64-linux)
timeout (0.6.0)
tpm-key_attestation (0.14.1)
bindata (~> 2.4)
openssl (> 2.0)
@@ -437,7 +437,7 @@ GEM
websocket-extensions (0.1.5)
xpath (3.2.0)
nokogiri (~> 1.8)
zeitwerk (2.7.3)
zeitwerk (2.7.4)
PLATFORMS
aarch64-linux
@@ -463,6 +463,7 @@ DEPENDENCIES
jwt (~> 3.1)
kamal
letter_opener
minitest (< 6.0)
propshaft
public_suffix (~> 7.0)
puma (>= 5.0)
@@ -487,4 +488,4 @@ DEPENDENCIES
webauthn (~> 3.0)
BUNDLED WITH
2.7.2
4.0.3

277
README.md
View File

@@ -1,7 +1,9 @@
# Clinch
## Position and Control for your Authentication
> [!NOTE]
> This software is experimental. 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**
@@ -11,6 +13,8 @@ Clinch gives you one place to manage users and lets any web app authenticate aga
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:
**[Authelia](https://www.authelia.com)** is a fantastic choice for those who prefer external user management through LDAP and enjoy comprehensive YAML-based configuration. It's lightweight, secure, and works beautifully with reverse proxies.
@@ -257,6 +261,24 @@ Configure different claims for different applications on a per-user basis:
- Proxy redirects to Clinch login page
- 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
@@ -282,56 +304,207 @@ bin/rails db:migrate
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
# Build image
docker build -t clinch .
# Generate SECRET_KEY_BASE (required)
openssl rand -hex 64
# Run container
docker run -p 3000:3000 \
-v clinch-storage:/rails/storage \
-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
# Generate OIDC private key (optional - auto-generated if not provided)
openssl genpkey -algorithm RSA -out private_key.pem -pkeyopt rsa_keygen_bits:2048
cat private_key.pem # Copy the output into OIDC_PRIVATE_KEY below
```
**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
### Environment Variables
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>
```
All configuration is handled via environment variables (see the `.env` file in the Docker Compose section above).
### First Run
1. Visit Clinch at `http://localhost:3000` (or your configured domain)
@@ -556,12 +729,30 @@ 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

View File

@@ -52,12 +52,24 @@ module Authentication
# Extract root domain for cross-subdomain cookies (required for forward auth)
domain = extract_root_domain(request.host)
cookie_options = {
value: session.id,
httponly: true,
same_site: :lax,
secure: Rails.env.production?
}
# Set cookie options based on environment
# Production: Use SameSite=None to allow cross-site cookies (needed for OIDC conformance testing)
# Development: Use SameSite=Lax since HTTPS might not be available
cookie_options = if Rails.env.production?
{
value: session.id,
httponly: true,
same_site: :none, # Allow cross-site cookies for OIDC testing
secure: true # Required for SameSite=None
}
else
{
value: session.id,
httponly: true,
same_site: :lax,
secure: false
}
end
# Set domain for cross-subdomain authentication if we can extract it
cookie_options[:domain] = domain if domain.present?

View File

@@ -3,6 +3,7 @@ class InvitationsController < ApplicationController
allow_unauthenticated_access
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
# Show the password setup form

View File

@@ -1,7 +1,8 @@
class OidcController < ApplicationController
# Discovery and JWKS endpoints are public
allow_unauthenticated_access only: [:discovery, :jwks, :token, :revoke, :userinfo, :logout]
skip_before_action :verify_authenticity_token, only: [:token, :revoke, :logout]
# authorize is also unauthenticated to handle prompt=none and prompt=login specially
allow_unauthenticated_access only: [:discovery, :jwks, :token, :revoke, :userinfo, :logout, :authorize]
skip_before_action :verify_authenticity_token, only: [:token, :revoke, :userinfo, :logout, :authorize, :consent]
# Rate limiting to prevent brute force and abuse
rate_limit to: 60, within: 1.minute, only: [:token, :revoke], with: -> {
@@ -30,10 +31,21 @@ class OidcController < ApplicationController
id_token_signing_alg_values_supported: ["RS256"],
scopes_supported: ["openid", "profile", "email", "groups", "offline_access"],
token_endpoint_auth_methods_supported: ["client_secret_post", "client_secret_basic"],
claims_supported: ["sub", "email", "email_verified", "name", "preferred_username", "groups", "admin", "auth_time", "acr", "azp", "at_hash"],
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"],
backchannel_logout_supported: true,
backchannel_logout_session_supported: true
backchannel_logout_session_supported: true,
request_parameter_supported: false
}
render json: config
@@ -56,32 +68,14 @@ class OidcController < ApplicationController
code_challenge = params[:code_challenge]
code_challenge_method = params[:code_challenge_method] || "plain"
# Validate required parameters
unless client_id.present? && redirect_uri.present? && response_type == "code"
error_details = []
error_details << "client_id is required" unless client_id.present?
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
# Validate client_id first (required before we can look up the application)
# OAuth2 RFC 6749 Section 4.1.2.1: If client_id is missing/invalid, show error page (don't redirect)
unless client_id.present?
render plain: "Invalid request: client_id is required", status: :bad_request
return
end
# Validate PKCE parameters if present
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
# Find the application by client_id
@application = Application.find_by(client_id: client_id, app_type: "oidc")
unless @application
# Log all OIDC applications for debugging
@@ -99,7 +93,14 @@ class OidcController < ApplicationController
return
end
# Validate redirect URI first (required before we can safely redirect with errors)
# 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)
Rails.logger.error "OAuth: Invalid request - redirect URI mismatch. Expected: #{@application.parsed_redirect_uris}, Got: #{redirect_uri}"
@@ -114,6 +115,56 @@ class OidcController < ApplicationController
return
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
# ============================================================================
# Reject request objects (JWT-encoded authorization parameters)
# Per OIDC Core §3.1.2.6: If request parameter is present and not supported,
# return request_not_supported error
if params[:request].present? || params[:request_uri].present?
Rails.logger.error "OAuth: Request object not supported"
error_uri = "#{redirect_uri}?error=request_not_supported"
error_uri += "&error_description=#{CGI.escape("Request objects are not supported")}"
error_uri += "&state=#{CGI.escape(state)}" if state.present?
redirect_to error_uri, allow_other_host: true
return
end
# 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}"
@@ -125,7 +176,17 @@ class OidcController < ApplicationController
# Check if user is authenticated
unless authenticated?
# Store OAuth parameters in session and redirect to sign in
# Handle prompt=none - no UI allowed, return error immediately
# Per OIDC Core spec §3.1.2.6: If prompt=none and user not authenticated,
# return login_required error without showing any UI
if params[:prompt] == "none"
error_uri = "#{redirect_uri}?error=login_required"
error_uri += "&state=#{CGI.escape(state)}" if state.present?
redirect_to error_uri, allow_other_host: true
return
end
# Normal flow: store OAuth parameters and redirect to sign in
session[:oauth_params] = {
client_id: client_id,
redirect_uri: redirect_uri,
@@ -135,10 +196,57 @@ class OidcController < ApplicationController
code_challenge: code_challenge,
code_challenge_method: code_challenge_method
}
# Store the current URL (with all OAuth params) for redirect after authentication
session[:return_to_after_authenticating] = request.url
redirect_to signin_path, alert: "Please sign in to continue"
return
end
# Handle prompt=login - force re-authentication
# Per OIDC Core spec §3.1.2.1: If prompt=login, the Authorization Server MUST prompt
# the End-User for reauthentication, even if the End-User is currently authenticated
if params[:prompt] == "login"
# Destroy current session to force re-authentication
# This creates a fresh authentication event with a new auth_time
Current.session&.destroy!
# Clear the session cookie so the user is truly logged out
cookies.delete(:session_id)
# Store the current URL (which contains all OAuth params) for redirect after login
# Remove prompt=login to prevent infinite re-auth loop
return_url = request.url.sub(/&prompt=login(?=&|$)|\?prompt=login&?/, '\1')
# Fix any resulting URL issues (like ?& or & at end)
return_url = return_url.gsub("?&", "?").gsub(/[?&]$/, "")
session[:return_to_after_authenticating] = return_url
redirect_to signin_path, alert: "Please sign in to continue"
return
end
# Handle max_age - require re-authentication if session is too old
# Per OIDC Core spec §3.1.2.1: If max_age is provided and the auth time is older,
# the Authorization Server MUST prompt for reauthentication
if params[:max_age].present?
max_age_seconds = params[:max_age].to_i
# Calculate session age
session_age_seconds = Time.current.to_i - Current.session.created_at.to_i
if session_age_seconds > max_age_seconds
# Session is too old - require re-authentication
# Store return URL in session (creates new session cookie)
# Destroy session and clear cookie to force fresh login
Current.session&.destroy!
cookies.delete(:session_id)
session[:return_to_after_authenticating] = request.url
redirect_to signin_path, alert: "Please sign in to continue"
return
end
end
# Get the authenticated user
user = Current.session.user
@@ -419,6 +527,7 @@ class OidcController < ApplicationController
# Generate ID token (JWT) with pairwise SID, at_hash, auth_time, and acr
# auth_time and acr come from the authorization code (captured at /authorize time)
# scopes determine which claims are included (per OIDC Core spec)
id_token = OidcJwtService.generate_id_token(
user,
application,
@@ -426,9 +535,14 @@ class OidcController < ApplicationController
nonce: auth_code.nonce,
access_token: access_token_record.plaintext_token,
auth_time: auth_code.auth_time,
acr: auth_code.acr
acr: auth_code.acr,
scopes: auth_code.scope
)
# RFC6749-5.1: Token endpoint MUST return Cache-Control: no-store
response.headers["Cache-Control"] = "no-store"
response.headers["Pragma"] = "no-cache"
# Return tokens
render json: {
access_token: access_token_record.plaintext_token, # Opaque token
@@ -547,15 +661,21 @@ class OidcController < ApplicationController
# Generate new ID token (JWT with pairwise SID, at_hash, auth_time, acr; no nonce for refresh grants)
# auth_time and acr come from the original refresh token (carried over from initial auth)
# 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
acr: refresh_token_record.acr,
scopes: refresh_token_record.scope
)
# RFC6749-5.1: Token endpoint MUST return Cache-Control: no-store
response.headers["Cache-Control"] = "no-store"
response.headers["Pragma"] = "no-cache"
# Return new tokens
render json: {
access_token: new_access_token.plaintext_token, # Opaque token
@@ -569,17 +689,22 @@ class OidcController < ApplicationController
render json: {error: "invalid_grant"}, status: :bad_request
end
# GET /oauth/userinfo
# GET/POST /oauth/userinfo
# OIDC Core spec: UserInfo endpoint MUST support GET, SHOULD support POST
def userinfo
# Extract access token from Authorization header
auth_header = request.headers["Authorization"]
unless auth_header&.start_with?("Bearer ")
# Extract access token from Authorization header or POST body
# RFC 6750: Bearer token can be in Authorization header, request body, or query string
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
return
end
token = auth_header.sub("Bearer ", "")
# Find and validate access token (opaque token with BCrypt hashing)
access_token = OidcAccessToken.find_by_token(token)
unless access_token&.active?
@@ -605,17 +730,35 @@ class OidcController < ApplicationController
consent = OidcUserConsent.find_by(user: user, application: access_token.application)
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 = {
sub: subject,
email: user.email_address,
email_verified: true,
preferred_username: user.email_address,
name: user.name.presence || user.email_address
sub: subject
}
# Add groups if user has any
if user.groups.any?
# Email claims (only if 'email' scope requested)
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)
end
@@ -631,6 +774,10 @@ class OidcController < ApplicationController
application = access_token.application
claims.merge!(application.custom_claims_for_user(user))
# Security: Don't cache user data responses
response.headers["Cache-Control"] = "no-store"
response.headers["Pragma"] = "no-cache"
render json: claims
end
@@ -775,12 +922,12 @@ class OidcController < ApplicationController
}
end
# Validate code verifier format (base64url-encoded, 43-128 characters)
unless code_verifier.match?(/\A[A-Za-z0-9\-_]{43,128}\z/)
# Validate code verifier format (per RFC 7636: [A-Za-z0-9\-._~], 43-128 characters)
unless code_verifier.match?(/\A[A-Za-z0-9\.\-_~]{43,128}\z/)
return {
valid: false,
error: "invalid_request",
error_description: "Invalid code_verifier format. Must be 43-128 characters of base64url encoding",
error_description: "Invalid code_verifier format. Must be 43-128 characters [A-Z/a-z/0-9/-/./_/~]",
status: :bad_request
}
end

View File

@@ -2,6 +2,7 @@ class PasswordsController < ApplicationController
allow_unauthenticated_access
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: 10.minutes, only: :update, with: -> { redirect_to new_password_path, alert: "Too many attempts. Try again later." }
def new
end

View File

@@ -14,6 +14,20 @@ class SessionsController < ApplicationController
return
end
# Extract login_hint from the return URL for pre-filling the email field (OIDC spec)
@login_hint = nil
if session[:return_to_after_authenticating].present?
begin
uri = URI.parse(session[:return_to_after_authenticating])
if uri.query.present?
query_params = CGI.parse(uri.query)
@login_hint = query_params["login_hint"]&.first
end
rescue URI::InvalidURIError
# Ignore parsing errors
end
end
respond_to do |format|
format.html # render HTML login page
format.json { render json: {error: "Authentication required"}, status: :unauthorized }
@@ -73,7 +87,10 @@ class SessionsController < ApplicationController
# Sign in successful (password only)
start_new_session_for user, acr: "1"
redirect_to after_authentication_url, notice: "Signed in successfully.", allow_other_host: true
# Use status: :see_other to ensure browser makes a GET request
# This prevents Turbo from converting it to a TURBO_STREAM request
redirect_to after_authentication_url, notice: "Signed in successfully.", allow_other_host: true, status: :see_other
end
def verify_totp

View File

@@ -1,6 +1,9 @@
class WebauthnCredential < ApplicationRecord
belongs_to :user
# Set default authenticator_type if not provided
after_initialize :set_default_authenticator_type, if: :new_record?
# Validations
validates :external_id, presence: true, uniqueness: true
validates :public_key, presence: true
@@ -77,6 +80,10 @@ class WebauthnCredential < ApplicationRecord
private
def set_default_authenticator_type
self.authenticator_type ||= "cross-platform"
end
def time_ago_in_words(time)
seconds = Time.current - time
minutes = seconds / 60

View File

@@ -3,7 +3,7 @@ class OidcJwtService
class << self
# Generate an ID token (JWT) for the user
def generate_id_token(user, application, consent: nil, nonce: nil, access_token: nil, auth_time: nil, acr: 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
# Use application's configured ID token TTL (defaults to 1 hour)
ttl = application.id_token_expiry_seconds
@@ -11,18 +11,23 @@ class OidcJwtService
# Use pairwise SID from consent if available, fallback to user ID
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 = {
iss: issuer_url,
sub: subject,
aud: application.client_id,
exp: now + ttl,
iat: now,
email: user.email_address,
email_verified: true,
preferred_username: user.username.presence || user.email_address,
name: user.name.presence || user.email_address
iat: now
}
# 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)
payload[:nonce] = nonce if nonce.present?
@@ -44,12 +49,13 @@ class OidcJwtService
payload[:at_hash] = at_hash
end
# Add groups if user has any
if user.groups.any?
# Groups claims (only if 'groups' scope requested)
if requested_scopes.include?("groups") && user.groups.any?
payload[:groups] = user.groups.pluck(:name)
end
# 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|
payload = deep_merge_claims(payload, group.parsed_custom_claims)
end

View File

@@ -12,7 +12,7 @@
autofocus: true,
autocomplete: "username",
placeholder: "your@email.com",
value: params[:email_address],
value: @login_hint || params[:email_address],
data: { action: "blur->webauthn#checkWebAuthnSupport change->webauthn#checkWebAuthnSupport" },
class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
</div>

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

@@ -4,5 +4,5 @@
# Use this to limit dissemination of sensitive information.
# See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors.
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

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

View File

@@ -26,11 +26,11 @@ Rails.application.routes.draw do
# OIDC (OpenID Connect) routes
get "/.well-known/openid-configuration", to: "oidc#discovery"
get "/.well-known/jwks.json", to: "oidc#jwks"
get "/oauth/authorize", to: "oidc#authorize"
match "/oauth/authorize", to: "oidc#authorize", via: [:get, :post]
post "/oauth/authorize/consent", to: "oidc#consent", as: :oauth_consent
post "/oauth/token", to: "oidc#token"
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"
# ForwardAuth / Trusted Header SSO

View File

@@ -1,275 +0,0 @@
# Rodauth-OAuth Analysis Documents
This directory contains a comprehensive analysis of rodauth-oauth and how it compares to your custom OIDC implementation in Clinch.
## Start Here
### 1. **RODAUTH_DECISION_GUIDE.md** (15-minute read)
**Purpose:** Help you make a decision about your OAuth/OIDC implementation
**Contains:**
- TL;DR of three options
- Decision flowchart
- Feature roadmap scenarios
- Effort estimates for each path
- Security comparison
- Real-world questions to ask your team
- Next actions for each option
**Best for:** Deciding whether to keep your implementation, migrate, or use a hybrid approach
---
### 2. **rodauth-oauth-quick-reference.md** (20-minute read)
**Purpose:** Quick lookup guide and architecture overview
**Contains:**
- What Rodauth-OAuth is (concise)
- Key statistics and certifications
- Feature advantages & disadvantages
- Architecture diagrams (text-based)
- Database schema comparison
- Feature matrix with implementation effort
- Performance considerations
- Getting started guide
- Code examples (minimal setup)
**Best for:** Understanding what you're looking at, quick decision support
---
### 3. **rodauth-oauth-analysis.md** (45-minute deep-dive)
**Purpose:** Comprehensive technical analysis for decision-making
**Contains:**
- Complete architecture breakdown (12 sections)
- All 34 features detailed and explained
- Full database schema documentation
- Request flow diagrams
- Feature dependency graphs
- Integration paths with Rails
- Security analysis
- Migration procedures
- Code comparisons
- Performance metrics
**Best for:** Deep understanding before making technical decisions, planning migrations
---
## How to Use These Documents
### Scenario 1: "I have 15 minutes"
1. Read: RODAUTH_DECISION_GUIDE.md (sections: TL;DR + Decision Matrix)
2. Go to: Next Actions for your chosen option
3. Done: You have a direction
### Scenario 2: "I have 45 minutes"
1. Read: RODAUTH_DECISION_GUIDE.md (complete)
2. Skim: rodauth-oauth-quick-reference.md (focus on code examples)
3. Decide: Which path interests you most
4. Plan: Team discussion using decision matrix
### Scenario 3: "I'm doing technical deep-dive"
1. Read: RODAUTH_DECISION_GUIDE.md (complete)
2. Read: rodauth-oauth-quick-reference.md (complete)
3. Read: rodauth-oauth-analysis.md (sections 1-6)
4. Reference: rodauth-oauth-analysis.md (sections 7-12 as needed)
### Scenario 4: "I'm planning a migration"
1. Read: RODAUTH_DECISION_GUIDE.md (effort estimates section)
2. Read: rodauth-oauth-analysis.md (migration path section)
3. Reference: rodauth-oauth-analysis.md (database schema section)
4. Plan: Detailed migration steps
---
## Three Options Explained (Very Brief)
### Option A: Keep Your Implementation
- **Time:** Ongoing (add features incrementally)
- **Effort:** 4-6 months to reach feature parity
- **Maintenance:** 8-10 hours/month
- **Best if:** Auth Code + PKCE is sufficient forever
### Option B: Switch to Rodauth-OAuth
- **Time:** 5-9 weeks (one-time migration)
- **Learning:** 1-2 weeks (Roda framework)
- **Maintenance:** 1-2 hours/month
- **Best if:** Need enterprise features, want low maintenance
### Option C: Hybrid Approach (Microservices)
- **Time:** 3-5 weeks (independent setup)
- **Learning:** Low (Roda is isolated)
- **Maintenance:** 2-3 hours/month
- **Best if:** Want Option B benefits without full Rails→Roda migration
---
## Key Findings
**What Rodauth-OAuth Provides That You Don't Have:**
- Refresh tokens
- Token revocation (RFC 7009)
- Token introspection (RFC 7662)
- Client Credentials grant (machine-to-machine)
- Device Code flow (IoT/smart TV)
- JWT Access Tokens (stateless)
- Session Management
- Front & Back-Channel Logout
- Token hashing (bcrypt security)
- DPoP support (token binding)
- TLS mutual authentication
- Dynamic Client Registration
- 20+ more optional features
**Security Differences:**
- Your impl: Tokens stored in plaintext (DB breach = token theft)
- Rodauth: Tokens hashed with bcrypt (secure even if DB breached)
**Maintenance Burden:**
- Your impl: YOU maintain everything
- Rodauth: Community maintains, you maintain config only
---
## Document Structure
### RODAUTH_DECISION_GUIDE.md Sections:
```
1. TL;DR - Three options
2. Decision Matrix - Flowchart
3. Feature Roadmap Comparison
4. Architecture Diagrams (visual)
5. Effort Estimates
6. Real-World Questions
7. Security Comparison
8. Cost-Benefit Summary
9. Decision Scorecard
10. Next Actions
```
### rodauth-oauth-quick-reference.md Sections:
```
1. What Is It? (overview)
2. Key Stats
3. Why Consider It? (advantages)
4. Architecture Overview (your impl vs rodauth)
5. Database Schema Comparison
6. Feature Comparison Matrix
7. Code Examples
8. Integration Paths
9. Getting Started
10. Next Steps
```
### rodauth-oauth-analysis.md Sections:
```
1. Executive Summary
2. What Rodauth-OAuth Is
3. File Structure & Organization
4. OIDC/OAuth Features
5. Architecture: How It Works
6. Database Schema Requirements
7. Integration with Rails
8. Architectural Comparison
9. Feature Matrix
10. Integration Complexity
11. Key Findings & Recommendations
12. Migration Path & Code Examples
```
---
## For Your Team
### Sharing with Stakeholders
- **Non-technical:** Use RODAUTH_DECISION_GUIDE.md (TL;DR section)
- **Technical leads:** Use rodauth-oauth-quick-reference.md
- **Engineers:** Use rodauth-oauth-analysis.md (sections 1-6)
- **Security team:** Use rodauth-oauth-analysis.md (security sections)
### Team Discussion
Print out the decision matrix from RODAUTH_DECISION_GUIDE.md and:
1. Walk through each option
2. Discuss team comfort with framework learning
3. Check against feature roadmap
4. Decide on maintenance philosophy
5. Vote on preferred option
---
## Next Steps After Reading
### If Choosing Option A (Keep Custom):
- [ ] Plan feature roadmap (refresh tokens first)
- [ ] Allocate team capacity
- [ ] Add token hashing security
- [ ] Set up security monitoring
### If Choosing Option B (Full Migration):
- [ ] Assign team member to learn Roda/Rodauth
- [ ] Run examples from `/tmp/rodauth-oauth/examples`
- [ ] Plan database migration
- [ ] Prepare rollback plan
- [ ] Schedule migration window
### If Choosing Option C (Hybrid):
- [ ] Evaluate microservices capability
- [ ] Review service communication plan
- [ ] Set up service infrastructure
- [ ] Plan gradual deployment
---
## Bonus: Running the Example
Rodauth-OAuth includes a working OIDC server example you can run:
```bash
cd /Users/dkam/Development/clinch/tmp/rodauth-oauth/examples/oidc
ruby authentication_server.rb
# Then visit: http://localhost:9292
# Login with: foo@bar.com / password
# See: Full OIDC provider in action
```
---
## Questions?
These documents should answer:
- What is rodauth-oauth?
- How does it compare to my implementation?
- What features would we gain?
- What would we lose?
- How much effort is a migration?
- Should we switch?
If questions remain, reference the specific section in the analysis documents.
---
## Document Generation Info
**Generated:** November 12, 2025
**Analysis Duration:** Complete codebase exploration of rodauth-oauth gem
**Sources Analyzed:**
- 34 feature files (10,000+ lines of code)
- 7 database migrations
- 6 complete example applications
- Comprehensive test suite
- README and migration guides
**Analysis Includes:**
- Line-by-line code structure review
- Database schema comparison
- Feature cross-reference analysis
- Integration complexity assessment
- Security analysis
- Effort estimation models
---
**Start with RODAUTH_DECISION_GUIDE.md and go from there!**

View File

@@ -1,426 +0,0 @@
# Rodauth-OAuth Decision Guide
## TL;DR - Make Your Choice Here
### Option A: Keep Your Rails Implementation
**Best if:** Authorization Code + PKCE is all you need, forever
- Keep your current 450 lines of OIDC controller code
- Maintain incrementally as needs change
- Stay 100% in Rails ecosystem
- Time investment: Ongoing (2-3 months to feature parity)
- Learning curve: None (already know Rails)
### Option B: Switch to Rodauth-OAuth
**Best if:** You need enterprise features, standards compliance, low maintenance
- Replace 450 lines with plugin config
- Get 34 optional features on demand
- OpenID Certified, production-hardened
- Time investment: 4-8 weeks (one-time)
- Learning curve: Medium (learn Roda/Rodauth)
### Option C: Hybrid (Recommended if Option B appeals you)
**Best if:** You want rodauth-oauth benefits without framework change
- Run Rodauth-OAuth as separate microservice
- Keep your Rails app unchanged
- Services talk via HTTP APIs
- Time investment: 2-3 weeks (independent services)
- Learning curve: Low (Roda is isolated)
---
## Decision Matrix
```
┌─────────────────────────────────────────────────────────────────┐
│ Do you need features beyond Authorization Code + PKCE? │
├─────────────────────────────────────────────────────────────────┤
│ YES ─→ Go to Question 2 │
│ NO ─→ KEEP YOUR IMPLEMENTATION │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Can your team learn Roda (different from Rails)? │
├─────────────────────────────────────────────────────────────────┤
│ YES ─→ SWITCH TO RODAUTH-OAUTH │
│ NO ─→ Go to Question 3 │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Can you run separate services (microservices)? │
├─────────────────────────────────────────────────────────────────┤
│ YES ─→ USE HYBRID APPROACH │
│ NO ─→ KEEP YOUR IMPLEMENTATION │
└─────────────────────────────────────────────────────────────────┘
```
---
## Feature Roadmap Comparison
### Scenario 1: You Need Refresh Tokens (Common)
**Option A (Keep Custom):**
- Implement refresh token endpoints
- Add refresh_token columns to DB
- Token rotation logic
- Estimate: 1-2 weeks of work
- Ongoing: Maintain refresh token security
**Option B (Rodauth-OAuth):**
- Already built and tested
- Just enable: `:oauth_authorization_code_grant` (includes refresh)
- Token rotation: Configurable options
- Estimate: Already included
- Ongoing: Community maintains
**Option C (Hybrid):**
- Rodauth-OAuth handles it
- Your app unchanged
- Same as Option B for this feature
### Scenario 2: You Need Token Revocation
**Option A (Keep Custom):**
- Build `/oauth/revoke` endpoint
- Implement token blacklist or DB update
- Handle race conditions
- Estimate: 1-2 weeks
- Ongoing: Monitor revocation leaks
**Option B (Rodauth-OAuth):**
- Enable `:oauth_token_revocation` feature
- RFC 7009 compliant out of the box
- Estimate: Already included
- Ongoing: Community handles RFC updates
**Option C (Hybrid):**
- Same as Option B
### Scenario 3: You Need Client Credentials Grant
**Option A (Keep Custom):**
- New endpoint logic
- Client authentication (different from user auth)
- Token generation for apps without users
- Estimate: 2-3 weeks
- Ongoing: Test with external clients
**Option B (Rodauth-OAuth):**
- Enable `:oauth_client_credentials_grant` feature
- All edge cases handled
- Estimate: Already included
- Ongoing: Community maintains
**Option C (Hybrid):**
- Same as Option B
---
## Architecture Diagrams
### Current Setup (Your Implementation)
```
┌─────────────────────────────┐
│ Your Rails Application │
├─────────────────────────────┤
│ app/controllers/ │
│ oidc_controller.rb │ ← 450 lines of OAuth logic
│ │
│ app/models/ │
│ OidcAuthorizationCode │
│ OidcAccessToken │
│ OidcUserConsent │
│ │
│ app/services/ │
│ OidcJwtService │
├─────────────────────────────┤
│ Rails ActiveRecord │
├─────────────────────────────┤
│ PostgreSQL Database │
│ - oidc_authorization_codes
│ - oidc_access_tokens
│ - oidc_user_consents
│ - applications
└─────────────────────────────┘
```
### Option B: Full Migration
```
┌──────────────────────────────┐
│ Roda + Rodauth-OAuth App │
├──────────────────────────────┤
│ lib/rodauth_app.rb │ ← Config (not code!)
│ enable :oidc, │
│ enable :oauth_pkce, │
│ enable :oauth_token_... │
│ │
│ [Routes auto-mounted] │
│ /.well-known/config │
│ /oauth/authorize │
│ /oauth/token │
│ /oauth/userinfo │
│ /oauth/revoke │
│ /oauth/introspect │
├──────────────────────────────┤
│ Sequel ORM │
├──────────────────────────────┤
│ PostgreSQL Database │
│ - accounts (rodauth)
│ - oauth_applications
│ - oauth_grants (unified!)
│ - optional feature tables
└──────────────────────────────┘
```
### Option C: Microservices Architecture (Hybrid)
```
┌──────────────────────────┐ ┌──────────────────────────┐
│ Your Rails App │ │ Rodauth-OAuth Service │
├──────────────────────────┤ ├──────────────────────────┤
│ Normal Rails Controllers │ │ lib/rodauth_app.rb │
│ & Business Logic │ │ [OAuth Features] │
│ │ │ │
│ HTTP Calls to →──────────┼─────→ /.well-known/config │
│ OAuth Service OAuth │ │ /oauth/authorize │
│ HTTP API │ │ /oauth/token │
│ │ │ /oauth/userinfo │
│ Verify Tokens via →──────┼─────→ /oauth/introspect │
│ /oauth/introspect │ │ │
├──────────────────────────┤ ├──────────────────────────┤
│ Rails ActiveRecord │ │ Sequel ORM │
├──────────────────────────┤ ├──────────────────────────┤
│ PostgreSQL │ │ PostgreSQL │
│ [business tables] │ │ [oauth tables] │
└──────────────────────────┘ └──────────────────────────┘
```
---
## Effort Estimates
### Option A: Keep & Enhance Custom Implementation
```
Refresh Tokens: 1-2 weeks
Token Revocation: 1-2 weeks
Token Introspection: 1-2 weeks
Client Credentials: 2-3 weeks
Device Code: 3-4 weeks
JWT Access Tokens: 1-2 weeks
Session Management: 2-3 weeks
Front-Channel Logout: 1-2 weeks
Back-Channel Logout: 2-3 weeks
─────────────────────────────────
TOTAL FOR PARITY: 15-25 weeks
(4-6 months of work)
ONGOING MAINTENANCE: ~8-10 hours/month
(security updates, RFC changes, bug fixes)
```
### Option B: Migrate to Rodauth-OAuth
```
Learn Roda/Rodauth: 1-2 weeks
Migrate Database Schema: 1-2 weeks
Replace OIDC Code: 1-2 weeks
Test & Validation: 2-3 weeks
─────────────────────────────────
ONE-TIME EFFORT: 5-9 weeks
(1-2 months)
ONGOING MAINTENANCE: ~1-2 hours/month
(dependency updates, config tweaks)
```
### Option C: Hybrid Approach
```
Set up Rodauth service: 1-2 weeks
Configure integration: 1-2 weeks
Test both services: 1 week
─────────────────────────────────
ONE-TIME EFFORT: 3-5 weeks
(less than Option B)
ONGOING MAINTENANCE: ~2-3 hours/month
(maintain two services, but Roda handles OAuth)
```
---
## Real-World Questions to Ask Your Team
### Question 1: Feature Needs
- "Do we need refresh tokens?"
- "Will clients ask for token revocation?"
- "Do we support service-to-service auth (client credentials)?"
- "Will we ever need device code flow (IoT)?"
If YES to any: **Option B or C makes sense**
### Question 2: Maintenance Philosophy
- "Do we want to own the OAuth code?"
- "Can we afford to maintain OAuth compliance?"
- "Do we have experts in OAuth/OIDC?"
If NO to all: **Option B or C is better**
### Question 3: Framework Flexibility
- "Is Rails non-negotiable for this company?"
- "Can our team learn a new framework?"
- "Can we run microservices?"
If Rails is required: **Option C (hybrid)**
### Question 4: Time Constraints
- "Do we have 4-8 weeks for a migration?"
- "Can we maintain OAuth for years?"
- "What if specs change?"
If time-constrained: **Option B is fastest path to full features**
---
## Security Comparison
### Your Implementation
- ✓ PKCE support
- ✓ JWT signing
- ✓ HTTPS recommended
- ✗ Token hashing (stores tokens in plaintext)
- ✗ Token rotation
- ✗ DPoP (token binding)
- ✗ Automatic spec compliance
- Risk: Token theft if DB compromised
### Rodauth-OAuth
- ✓ PKCE support
- ✓ JWT signing
- ✓ Token hashing (bcrypt by default)
- ✓ Token rotation policies
- ✓ DPoP support (RFC 9449)
- ✓ TLS mutual authentication
- ✓ Automatic spec updates
- ✓ Certified compliance
- Risk: Minimal (industry-standard)
---
## Cost-Benefit Summary
### Keep Your Implementation
```
Costs:
- 15-25 weeks to feature parity
- Ongoing security monitoring
- Spec compliance tracking
- Bug fixes & edge cases
Benefits:
- No framework learning
- Full code understanding
- Rails-native patterns
- Minimal dependencies
```
### Switch to Rodauth-OAuth
```
Costs:
- 5-9 weeks migration effort
- Learn Roda/Rodauth
- Database schema changes
- Test all flows
Benefits:
- Get 34 features immediately
- Certified compliance
- Community-maintained
- Security best practices
- Ongoing support
```
### Hybrid Approach
```
Costs:
- 3-5 weeks setup
- Learn Roda basics
- Operate two services
- Service communication
Benefits:
- All Rodauth-OAuth features
- Rails app unchanged
- Independent scaling
- Clear separation of concerns
```
---
## Decision Scorecard
| Factor | Option A | Option B | Option C |
|--------|----------|----------|----------|
| Initial Time | Low | Medium | Medium-Low |
| Ongoing Effort | High | Low | Medium |
| Feature Completeness | Low | High | High |
| Framework Learning | None | Medium | Low |
| Standards Compliance | Manual | Auto | Auto |
| Deployment Complexity | Simple | Simple | Complex |
| Team Preference | ??? | ??? | ??? |
---
## Next Actions
### For Option A (Keep Custom):
1. Plan feature roadmap (refresh tokens first)
2. Allocate team capacity for implementation
3. Document OAuth decisions
4. Set up security monitoring
### For Option B (Full Migration):
1. Assign someone to learn Roda/Rodauth
2. Run rodauth-oauth examples
3. Plan database migration
4. Schedule migration window
5. Prepare rollback plan
### For Option C (Hybrid):
1. Evaluate microservices capability
2. Run Rodauth-OAuth example
3. Plan service boundaries
4. Set up service communication
5. Plan infrastructure for two services
---
## Still Can't Decide?
Ask these questions:
1. **Will you add features beyond Auth Code + PKCE in next 12 months?**
- YES → Option B or C
- NO → Option A
2. **Do you have maintenance bandwidth?**
- YES → Option A
- NO → Option B or C
3. **Can you run multiple services?**
- YES → Option C (best of both)
- NO → Option B (if framework is OK) or Option A (stay Rails)
---
## Document Files
You now have three documents:
1. **rodauth-oauth-analysis.md** - Deep technical analysis (12 sections)
2. **rodauth-oauth-quick-reference.md** - Quick lookup guide
3. **RODAUTH_DECISION_GUIDE.md** - This decision framework
Read in this order:
1. This guide (make a decision)
2. Quick reference (understand architecture)
3. Analysis (deep dive on your choice)
---
**Made Your Decision?** Create an issue/commit to document your choice and next steps!

View File

@@ -24,6 +24,18 @@ This checklist ensures Clinch meets security, quality, and documentation standar
- [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`
@@ -136,7 +148,7 @@ This checklist ensures Clinch meets security, quality, and documentation standar
- [ ] Document required vs. optional configuration
- [ ] Provide sensible defaults
- [ ] Validate production SMTP configuration
- [ ] Ensure OIDC private key generation process is documented
- [x] Ensure OIDC private key generation process is documented
### Database
- [x] Migrations are idempotent
@@ -153,24 +165,25 @@ This checklist ensures Clinch meets security, quality, and documentation standar
### Deployment
- [x] Docker support
- [x] Docker Compose example
- [ ] Production deployment guide
- [ ] Backup and restore documentation
- [ ] Migration strategy documentation
- [x] Production deployment guide (Docker Compose with .env configuration, upgrading, logs)
- [x] Backup and restore documentation
## Security Hardening
### Headers & CSP
- [ ] Review Content Security Policy
- [ ] HSTS configuration
- [ ] X-Frame-Options
- [ ] X-Content-Type-Options
- [ ] Referrer-Policy
- [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
- [ ] Login attempt rate limiting
- [ ] API endpoint rate limiting
- [ ] Token endpoint rate limiting
- [ ] Password reset 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
@@ -180,39 +193,49 @@ This checklist ensures Clinch meets security, quality, and documentation standar
### Logging & Monitoring
- [x] Sentry integration (optional)
- [ ] Document what should be logged
- [ ] Document what should NOT be logged (tokens, passwords)
- [x] Parameter filtering configured (passwords, tokens, secrets, backup codes, emails filtered from logs)
- [ ] Audit log for admin actions
## Known Limitations & Risks
### Documented Risks
- [ ] Document that ForwardAuth requires same-domain setup
- [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
- [ ] Rate limiting on authentication endpoints
- [ ] Account lockout after N failed attempts
### 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
- [ ] Brute force detection
- [ ] Suspicious login detection
- [ ] 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
## External Security Review
## Protocol Conformance & Security Review
- [ ] Consider bug bounty or security audit
- [ ] Penetration testing for OIDC flows
- [ ] WebAuthn implementation review
- [ ] Token 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
- [ ] Backup and disaster recovery guide
- [x] Backup and disaster recovery guide
- [ ] Upgrade guide
- [ ] Breaking change policy
@@ -225,44 +248,57 @@ To move from "experimental" to "Beta", the following must be completed:
- [x] All tests passing
- [x] Core features implemented and tested
- [x] Basic documentation complete
- [ ] At least one external security review or penetration test
- [ ] Production deployment guide
- [ ] Backup/restore documentation
- [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):**
- [ ] Rate limiting on auth endpoints
- [ ] Security headers configuration documented
- [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
- [ ] Known limitations documented
**Nice to have (Can defer to post-Beta):**
- [ ] Bug bounty program
- [ ] Advanced monitoring/alerting
- [ ] Automated security testing in CI beyond brakeman/bundler-audit
- [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:** Pre-Beta / Experimental
**Current Status:** Ready for Beta Release 🎉
**Strengths:**
- ✅ Comprehensive security tooling in place
- ✅ Strong test coverage (341 tests, 1349 assertions)
- ✅ Strong test coverage (374 tests, 1538 assertions)
- ✅ Modern security features (PKCE, token rotation, WebAuthn)
- ✅ Clean security scans (brakeman, bundler-audit)
- ✅ Clean security scans (brakeman, bundler-audit, Trivy)
- ✅ Well-documented codebase
-**OpenID Connect Conformance certified** - 48/48 tests passed
**Before Beta Release:**
- 🔶 External security review recommended
- 🔶 Rate limiting implementation needed
- 🔶 Production deployment documentation
- 🔶 Security hardening checklist completion
**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 ✅
**Recommendation:** Consider Beta status after:
1. External security review or penetration testing
2. Rate limiting implementation
3. Production hardening documentation
4. 1-2 months of real-world testing
**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-01
Last updated: 2026-01-02

View File

@@ -1,913 +0,0 @@
# Rodauth-OAuth Analysis: Comprehensive Comparison with Clinch's Custom Implementation
## Executive Summary
**Rodauth-OAuth** is a production-ready Ruby gem that implements the OAuth 2.0 framework and OpenID Connect on top of the `rodauth` authentication library. It's architected as a modular feature-based system that integrates with Roda (a routing library) and provides extensive OAuth/OIDC capabilities.
Your current Clinch implementation is a **custom, minimalist Rails-based OIDC provider** focusing on the authorization code grant with PKCE support. Switching to rodauth-oauth would provide significantly more features and standards compliance but requires architectural changes.
---
## 1. What Rodauth-OAuth Is
### Core Identity
- **Type**: Ruby gem providing OAuth 2.0 & OpenID Connect implementation
- **Framework**: Built on top of `rodauth` (a dedicated authentication library)
- **Web Framework**: Designed for Roda framework (lightweight, routing-focused)
- **Rails Support**: Available via `rodauth-rails` wrapper
- **Maturity**: Production-ready, OpenID-Certified for multiple profiles
- **Author**: Tiago Cardoso (tiago.cardoso@gmail.com)
- **License**: Apache 2.0
### Architecture Philosophy
- **Feature-based**: Modular "features" that can be enabled/disabled
- **Database-agnostic**: Uses Sequel ORM, works with any SQL database
- **Highly configurable**: Override methods to customize behavior
- **Standards-focused**: Implements RFCs and OpenID specs strictly
---
## 2. File Structure and Organization
### Directory Layout in `/tmp/rodauth-oauth`
```
rodauth-oauth/
├── lib/
│ └── rodauth/
│ ├── oauth.rb # Main module entry point
│ ├── oauth/
│ │ ├── version.rb
│ │ ├── database_extensions.rb
│ │ ├── http_extensions.rb
│ │ ├── jwe_extensions.rb
│ │ └── ttl_store.rb
│ └── features/ # 34 feature files!
│ ├── oauth_base.rb # Foundation
│ ├── oauth_authorization_code_grant.rb
│ ├── oauth_pkce.rb
│ ├── oauth_jwt*.rb # JWT support (5 files)
│ ├── oidc.rb # OpenID Core
│ ├── oidc_*logout.rb # Logout flows (3 files)
│ ├── oauth_client_credentials_grant.rb
│ ├── oauth_device_code_grant.rb
│ ├── oauth_token_revocation.rb
│ ├── oauth_token_introspection.rb
│ ├── oauth_dynamic_client_registration.rb
│ ├── oauth_dpop.rb # DPoP support
│ ├── oauth_tls_client_auth.rb
│ ├── oauth_pushed_authorization_request.rb
│ ├── oauth_assertion_base.rb
│ └── ... (more features)
├── test/
│ ├── migrate/ # Database migrations
│ │ ├── 001_accounts.rb
│ │ ├── 003_oauth_applications.rb
│ │ ├── 004_oauth_grants.rb
│ │ ├── 005_pushed_requests.rb
│ │ ├── 006_saml_settings.rb
│ │ └── 007_dpop_proofs.rb
│ └── [multiple test directories with hundreds of tests]
├── examples/ # Full working examples
│ ├── authorization_server/
│ ├── oidc/
│ ├── jwt/
│ ├── device_grant/
│ ├── saml_assertion/
│ └── mtls/
├── templates/ # HTML/ERB templates
├── locales/ # i18n translations
├── doc/
└── [Gemfile, README, MIGRATION-GUIDE, etc.]
```
### Feature Count: 34 Features!
The gem is completely modular. Each feature can be independently enabled:
**Core OAuth Features:**
- `oauth_base` - Foundation
- `oauth_authorization_code_grant` - Authorization Code Flow
- `oauth_implicit_grant` - Implicit Flow
- `oauth_client_credentials_grant` - Client Credentials Flow
- `oauth_device_code_grant` - Device Code Flow
**Token Management:**
- `oauth_token_revocation` - RFC 7009
- `oauth_token_introspection` - RFC 7662
- `oauth_refresh_token` - Refresh tokens
**Security & Advanced:**
- `oauth_pkce` - RFC 7636 (what Clinch is using!)
- `oauth_jwt` - JWT Access Tokens
- `oauth_jwt_bearer_grant` - RFC 7523
- `oauth_saml_bearer_grant` - RFC 7522
- `oauth_tls_client_auth` - Mutual TLS
- `oauth_dpop` - Demonstrating Proof-of-Possession
- `oauth_jwt_secured_authorization_request` - Request Objects
- `oauth_resource_indicators` - RFC 8707
- `oauth_pushed_authorization_request` - RFC 9126
**OpenID Connect:**
- `oidc` - Core OpenID Connect
- `oidc_session_management` - Session Management
- `oidc_rp_initiated_logout` - RP-Initiated Logout
- `oidc_frontchannel_logout` - Front-Channel Logout
- `oidc_backchannel_logout` - Back-Channel Logout
- `oidc_dynamic_client_registration` - Dynamic Registration
- `oidc_self_issued` - Self-Issued Provider
**Management & Discovery:**
- `oauth_application_management` - Client app dashboard
- `oauth_grant_management` - Grant management dashboard
- `oauth_dynamic_client_registration` - RFC 7591/7592
- `oauth_jwt_jwks` - JWKS endpoint
---
## 3. OIDC/OAuth Features Provided
### Grant Types Supported (15 types!)
| Grant Type | Status | RFC/Spec |
|-----------|--------|----------|
| Authorization Code | Yes | RFC 6749 |
| Implicit | Optional | RFC 6749 |
| Client Credentials | Optional | RFC 6749 |
| Device Code | Optional | RFC 8628 |
| Refresh Token | Yes | RFC 6749 |
| JWT Bearer | Optional | RFC 7523 |
| SAML Bearer | Optional | RFC 7522 |
### Response Types & Modes
**Response Types:**
- `code` (Authorization Code) - Default
- `id_token` (OIDC Implicit) - Optional
- `token` (Implicit) - Optional
- `id_token token` (Hybrid) - Optional
- `code id_token` (Hybrid) - Optional
- `code token` (Hybrid) - Optional
- `code id_token token` (Hybrid) - Optional
**Response Modes:**
- `query` (URL parameters)
- `fragment` (URL fragment)
- `form_post` (HTML form)
- `jwt` (JWT-based response)
### OpenID Connect Features
**Certified for:**
- Basic OP (OpenID Provider)
- Implicit OP
- Hybrid OP
- Config OP (Discovery)
- Dynamic OP (Dynamic Client Registration)
- Form Post OP
- 3rd Party-Init OP
- Session Management OP
- RP-Initiated Logout OP
- Front-Channel Logout OP
- Back-Channel Logout OP
**Standard Claims Support:**
- `openid`, `email`, `profile`, `address`, `phone` scopes
- Automatic claim mapping per OpenID spec
- Custom claims via extension
**Token Features:**
- JWT ID Tokens
- JWT Access Tokens
- Encrypted JWTs (JWE support)
- HMAC-SHA256 signing
- RSA/EC signing
- Custom token formats
### Security Features
| Feature | Details |
|---------|---------|
| PKCE | RFC 7636 - Proof Key for Public Clients |
| Token Hashing | Bcrypt-based token storage (plain text optional) |
| DPoP | RFC 9449 - Demonstrating Proof-of-Possession |
| TLS Client Auth | RFC 8705 - Mutual TLS authentication |
| Request Objects | JWT-signed/encrypted authorization requests |
| Pushed Auth Requests | RFC 9126 - Pushed Authorization Requests |
| Token Introspection | RFC 7662 - Token validation without DB lookup |
| Token Revocation | RFC 7009 - Revoke tokens on demand |
### Scopes & Authorization
- Configurable scope list per application
- Offline access support (refresh tokens)
- Scope-based access control
- Custom scope handlers
- Consent UI for user authorization
---
## 4. Architecture: How It Works
### As a Plugin System
Rodauth-OAuth integrates with Roda as a **plugin**:
```ruby
# This is how you configure it
class AuthServer < Roda
plugin :rodauth do
db database_connection
# Enable features
enable :login, :logout, :create_account, :oidc, :oidc_session_management,
:oauth_pkce, :oauth_authorization_code_grant
# Configure
oauth_application_scopes %w[openid email profile]
oauth_require_pkce true
hmac_secret "SECRET"
# Customize with blocks
oauth_jwt_keys("RS256" => [private_key])
oauth_jwt_public_keys("RS256" => [public_key])
end
end
```
### Request Flow Architecture
```
1. Authorization Request
rodauth validates params
(if not auth'd) user logs in via rodauth
(if first use) consent page rendered
create oauth_grant (code, nonce, PKCE challenge, etc.)
redirect with auth code
2. Token Exchange
rodauth validates client (Basic/POST auth)
validates code, redirect_uri, PKCE verifier
creates access token (plain or JWT)
creates refresh token
returns JSON with tokens
3. UserInfo
validate access token
lookup grant/account
return claims as JSON
```
### Feature Composition
Features depend on each other. For example:
- `oidc` depends on: `active_sessions`, `oauth_jwt`, `oauth_jwt_jwks`, `oauth_authorization_code_grant`, `oauth_implicit_grant`
- `oauth_pkce` depends on: `oauth_authorization_code_grant`
- `oidc_rp_initiated_logout` depends on: `oidc`
This is a **strong dependency injection pattern**.
---
## 5. Database Schema Requirements
### Rodauth-OAuth Tables
#### `accounts` table (from rodauth)
```sql
CREATE TABLE accounts (
id INTEGER PRIMARY KEY,
status_id INTEGER DEFAULT 1, -- unverified/verified/closed
email VARCHAR UNIQUE NOT NULL,
-- password-related columns (added by rodauth features)
password_hash VARCHAR,
-- other rodauth-managed columns
);
```
#### `oauth_applications` table (75+ columns!)
```sql
CREATE TABLE oauth_applications (
id INTEGER PRIMARY KEY,
account_id INTEGER FOREIGN KEY,
-- Basic info
name VARCHAR NOT NULL,
description VARCHAR,
homepage_url VARCHAR,
logo_uri VARCHAR,
tos_uri VARCHAR,
policy_uri VARCHAR,
-- OAuth credentials
client_id VARCHAR UNIQUE NOT NULL,
client_secret VARCHAR UNIQUE NOT NULL,
registration_access_token VARCHAR,
-- OAuth config
redirect_uri VARCHAR NOT NULL,
scopes VARCHAR NOT NULL,
token_endpoint_auth_method VARCHAR,
grant_types VARCHAR,
response_types VARCHAR,
response_modes VARCHAR,
-- JWT/JWKS
jwks_uri VARCHAR,
jwks TEXT,
jwt_public_key TEXT,
-- OIDC-specific
sector_identifier_uri VARCHAR,
application_type VARCHAR,
initiate_login_uri VARCHAR,
subject_type VARCHAR,
-- Token encryption algorithms
id_token_signed_response_alg VARCHAR,
id_token_encrypted_response_alg VARCHAR,
id_token_encrypted_response_enc VARCHAR,
userinfo_signed_response_alg VARCHAR,
userinfo_encrypted_response_alg VARCHAR,
userinfo_encrypted_response_enc VARCHAR,
-- Request object handling
request_object_signing_alg VARCHAR,
request_object_encryption_alg VARCHAR,
request_object_encryption_enc VARCHAR,
request_uris VARCHAR,
require_signed_request_object BOOLEAN,
-- PAR (Pushed Auth Requests)
require_pushed_authorization_requests BOOLEAN DEFAULT FALSE,
-- DPoP
dpop_bound_access_tokens BOOLEAN DEFAULT FALSE,
-- TLS Client Auth
tls_client_auth_subject_dn VARCHAR,
tls_client_auth_san_dns VARCHAR,
tls_client_auth_san_uri VARCHAR,
tls_client_auth_san_ip VARCHAR,
tls_client_auth_san_email VARCHAR,
tls_client_certificate_bound_access_tokens BOOLEAN DEFAULT FALSE,
-- Logout URIs
post_logout_redirect_uris VARCHAR,
frontchannel_logout_uri VARCHAR,
frontchannel_logout_session_required BOOLEAN DEFAULT FALSE,
backchannel_logout_uri VARCHAR,
backchannel_logout_session_required BOOLEAN DEFAULT FALSE,
-- Response encryption
authorization_signed_response_alg VARCHAR,
authorization_encrypted_response_alg VARCHAR,
authorization_encrypted_response_enc VARCHAR,
contact_info VARCHAR,
software_id VARCHAR,
software_version VARCHAR
);
```
#### `oauth_grants` table (everything in one table!)
```sql
CREATE TABLE oauth_grants (
id INTEGER PRIMARY KEY,
account_id INTEGER FOREIGN KEY, -- nullable for client credentials
oauth_application_id INTEGER FOREIGN KEY,
sub_account_id INTEGER, -- for context-based ownership
type VARCHAR, -- 'authorization_code', 'refresh_token', etc.
-- Authorization code flow
code VARCHAR UNIQUE (per app),
redirect_uri VARCHAR,
-- Tokens (stored hashed or plain)
token VARCHAR UNIQUE,
token_hash VARCHAR UNIQUE,
refresh_token VARCHAR UNIQUE,
refresh_token_hash VARCHAR UNIQUE,
-- Expiry
expires_in TIMESTAMP NOT NULL,
revoked_at TIMESTAMP,
-- Scopes
scopes VARCHAR NOT NULL,
access_type VARCHAR DEFAULT 'offline', -- 'offline' or 'online'
-- PKCE
code_challenge VARCHAR,
code_challenge_method VARCHAR, -- 'plain' or 'S256'
-- Device Code Grant
user_code VARCHAR UNIQUE,
last_polled_at TIMESTAMP,
-- TLS Client Auth
certificate_thumbprint VARCHAR,
-- Resource Indicators
resource VARCHAR,
-- OpenID Connect
nonce VARCHAR,
acr VARCHAR, -- Authentication Context Class
claims_locales VARCHAR,
claims VARCHAR, -- custom OIDC claims
-- DPoP
dpop_jkt VARCHAR -- DPoP key thumbprint
);
```
#### Optional Tables for Advanced Features
```sql
-- For Pushed Authorization Requests
CREATE TABLE oauth_pushed_requests (
request_uri VARCHAR UNIQUE PRIMARY KEY,
oauth_application_id INTEGER FOREIGN KEY,
params TEXT, -- JSON params
created_at TIMESTAMP
);
-- For SAML Assertion Grant
CREATE TABLE oauth_saml_settings (
id INTEGER PRIMARY KEY,
oauth_application_id INTEGER FOREIGN KEY,
idp_url VARCHAR,
certificate TEXT,
-- ...
);
-- For DPoP
CREATE TABLE oauth_dpop_proofs (
id INTEGER PRIMARY KEY,
oauth_grant_id INTEGER FOREIGN KEY,
jti VARCHAR UNIQUE,
created_at TIMESTAMP
);
```
### Key Differences from Your Implementation
| Aspect | Your Implementation | Rodauth-OAuth |
|--------|-------------------|----------------|
| Authorization Codes | Separate table | In oauth_grants |
| Access Tokens | Separate table | In oauth_grants |
| Refresh Tokens | Not implemented | In oauth_grants |
| Token Hashing | Not done | Bcrypt (default) |
| Applications | Basic (name, client_id, secret) | 75+ columns for full spec |
| PKCE | Simple columns | Built-in feature |
| Account Data | In users table | In accounts table |
| Session Management | Session model | Rodauth's account_active_session_keys |
| User Consent | OidcUserConsent table | In memory or via hooks |
---
## 6. Integration Points with Rails
### Via Rodauth-Rails Wrapper
Rodauth-OAuth can be used in Rails through the `rodauth-rails` gem:
```bash
# Install generator
gem 'rodauth-rails'
bundle install
rails generate rodauth:install
rails generate rodauth:oauth:install # Generates OIDC tables/migrations
rails generate rodauth:oauth:views # Generates templates
```
### Generated Components
1. **Migration**: `db/migrate/*_create_rodauth_oauth.rb`
- Creates all OAuth tables
- Customizable column names via config
2. **Models**: `app/models/`
- `RodauthApp` (configuration)
- `OauthApplication` (client app)
- `OauthGrant` (grants/tokens)
- Customizable!
3. **Views**: `app/views/rodauth/`
- Authorization consent form
- Application management dashboard
- Grant management dashboard
4. **Lib**: `lib/rodauth_app.rb`
- Main rodauth configuration
### Rails Controller Integration
```ruby
class BooksController < ApplicationController
before_action :require_oauth_authorization, only: %i[create update]
before_action :require_oauth_authorization_scopes, only: %i[create update]
private
def require_oauth_authorization(scope = "books.read")
rodauth.require_oauth_authorization(scope)
end
end
```
Or for route protection:
```ruby
# config/routes.rb
namespace :api do
resources :books, only: [:index] # protected by rodauth
end
```
---
## 7. Architectural Comparison
### Your Custom Implementation
**Pros:**
- Simple, easy to understand
- Minimal dependencies (just JWT, OpenSSL)
- Lightweight database (small tables)
- Direct Rails integration
- Minimal features = less surface area
**Cons:**
- Only supports Authorization Code + PKCE
- No refresh tokens
- No token revocation/introspection
- No client credentials grant
- No JWT access tokens
- Manual consent management
- Not standards-compliant (missing many OIDC features)
- Will need continuous custom development
**Architecture:**
```
Rails Controller
OidcController (450 lines)
OidcAuthorizationCode Model
OidcAccessToken Model
OidcUserConsent Model
Database
```
### Rodauth-OAuth Implementation
**Pros:**
- 34 built-in features
- OpenID-Certified
- Production-tested
- Highly configurable
- Comprehensive token management
- Standards-compliant (RFCs & OpenID specs)
- Strong test coverage (hundreds of tests)
- Active maintenance
**Cons:**
- More complex (needs Roda/Rodauth knowledge)
- Larger codebase to learn
- Rails integration via wrapper (extra layer)
- Different paradigm (Roda vs Rails)
- More database columns to manage
**Architecture:**
```
Roda App
Rodauth Plugin (configurable)
├── oauth_base (foundation)
├── oauth_authorization_code_grant
├── oauth_pkce
├── oauth_jwt
├── oidc (all OpenID features)
├── [other optional features]
Sequel ORM
Database (flexible schema)
```
---
## 8. Feature Comparison Matrix
| Feature | Your Impl | Rodauth-OAuth | Notes |
|---------|-----------|---------------|-------|
| **Authorization Code** | ✓ | ✓ | Both support |
| **PKCE** | ✓ | ✓ | Both support |
| **Refresh Tokens** | ✗ | ✓ | You'd need to add |
| **Implicit Flow** | ✗ | ✓ Optional | Legacy, not recommended |
| **Client Credentials** | ✗ | ✓ Optional | Machine-to-machine |
| **Device Code** | ✗ | ✓ Optional | IoT devices |
| **JWT Bearer Grant** | ✗ | ✓ Optional | Service accounts |
| **SAML Bearer Grant** | ✗ | ✓ Optional | Enterprise SAML |
| **JWT Access Tokens** | ✗ | ✓ Optional | Stateless tokens |
| **Token Revocation** | ✗ | ✓ | RFC 7009 |
| **Token Introspection** | ✗ | ✓ | RFC 7662 |
| **Pushed Auth Requests** | ✗ | ✓ Optional | RFC 9126 |
| **DPoP** | ✗ | ✓ Optional | RFC 9449 |
| **TLS Client Auth** | ✗ | ✓ Optional | RFC 8705 |
| **OpenID Connect** | ✓ Basic | ✓ Full | Yours is minimal |
| **ID Tokens** | ✓ | ✓ | Both support |
| **UserInfo Endpoint** | ✓ | ✓ | Both support |
| **Discovery** | ✓ | ✓ | Both support |
| **Session Management** | ✗ | ✓ Optional | Check session iframe |
| **RP-Init Logout** | ✓ | ✓ | Both support |
| **Front-Channel Logout** | ✗ | ✓ | Iframe-based |
| **Back-Channel Logout** | ✗ | ✓ | Server-to-server |
| **Dynamic Client Reg** | ✗ | ✓ Optional | RFC 7591/7592 |
| **Token Hashing** | ✗ | ✓ | Security best practice |
| **Scopes** | ✓ | ✓ | Both support |
| **Custom Claims** | ✓ Manual | ✓ Built-in | Yours via JWT service |
| **Consent UI** | ✓ | ✓ | Both support |
| **Client App Dashboard** | ✗ | ✓ Optional | Built-in |
| **Grant Management Dashboard** | ✗ | ✓ Optional | Built-in |
---
## 9. Integration Complexity Analysis
### Switching to Rodauth-OAuth
#### Medium Complexity (Not Trivial, but Doable)
**What you'd need to do:**
1. **Learn Roda + Rodauth**
- Move from pure Rails to Roda-based architecture
- Understand rodauth feature system
- Time: 1-2 weeks for Rails developers
2. **Migrate Database Schema**
- Consolidate tables: authorization codes + access tokens → oauth_grants
- Rename columns to match rodauth conventions
- Add many new columns for feature support
- Migration script needed: ~100-300 lines
- Time: 1 week development + testing
3. **Replace Your OIDC Code**
- Replace your 450-line OidcController
- Remove your 3 model files
- Keep your OidcJwtService (mostly compatible)
- Add rodauth configuration
- Time: 1-2 weeks
4. **Update Application/Client Model**
- Expand `Application` model properties
- Support all OAuth scopes, grant types, response types
- Time: 3-5 days
5. **Create Migrations from Template**
- Use rodauth-oauth migration templates
- Customize for your database
- Time: 2-3 days
6. **Testing**
- Write integration tests
- Verify all OAuth flows still work
- Check token validation logic
- Time: 2-3 weeks
**Total Effort:** 4-8 weeks for experienced team
### Keeping Your Implementation (Custom Path)
#### What You'd Need to Add
To reach feature parity with rodauth-oauth (for common use cases):
1. **Refresh Token Support** (1-2 weeks)
- Database schema
- Token refresh endpoint
- Token validation logic
2. **Token Revocation** (1 week)
- Revocation endpoint
- Token blacklist/invalidation
3. **Token Introspection** (1 week)
- Introspection endpoint
- Token validation without DB lookup
4. **Client Credentials Grant** (2 weeks)
- Endpoint logic
- Client authentication
- Token generation for apps
5. **Improved Security** (ongoing)
- Token hashing (bcrypt)
- Rate limiting
- Additional validation
6. **Advanced OIDC Features**
- Session Management
- Logout endpoints (front/back-channel)
- Dynamic client registration
- Device code flow
**Total Effort:** 2-3 months ongoing
---
## 10. Key Findings & Recommendations
### What Rodauth-OAuth Does Better
1. **Standards Compliance**
- Certified for 11 OpenID Connect profiles
- Implements 20+ RFCs and specs
- Regular spec updates
2. **Security**
- Token hashing by default
- DPoP support (token binding)
- TLS client auth
- Proper scope enforcement
3. **Features**
- 34 optional features (you get what you need)
- No bloat - only enable what you use
- Mature refresh token handling
4. **Production Readiness**
- Thousands of test cases
- Open source (auditable)
- Active maintenance
- Real-world deployments
5. **Flexibility**
- Works with any SQL database
- Highly configurable column names
- Custom behavior via overrides
- Multiple app types support
### What Your Implementation Does Better
1. **Simplicity**
- Fewer dependencies
- Smaller codebase
- Easier to reason about
2. **Rails Integration**
- Direct Rails ActiveRecord
- No Roda learning curve
- Familiar patterns
3. **Control**
- Full control of every line
- No surprises
- Easy to debug
### Recommendation
**Use Rodauth-OAuth IF:**
- You need a production OIDC/OAuth provider
- You want standards compliance
- You plan to support multiple grant types
- You need token revocation/introspection
- You want a maintained codebase
**Keep Your Custom Implementation IF:**
- Authorization Code + PKCE only is sufficient
- You're avoiding Roda/Rodauth learning curve
- Your org standardizes on Rails patterns
- You have time to add features incrementally
- You need maximum control and simplicity
**Hybrid Approach:**
- Use rodauth-oauth for OIDC/OAuth server components
- Keep your Rails app for other features
- They can coexist (separate services)
---
## 11. Migration Path (If You Decide to Switch)
### Phase 1: Preparation (Week 1-2)
- Set up separate Roda app with rodauth-oauth
- Run alongside your existing service
- Parallel user testing
### Phase 2: Data Migration (Week 2-3)
- Create migration script for oauth_grants table
- Backfill existing auth codes and tokens
- Verify data integrity
### Phase 3: Gradual Cutover (Week 4-6)
- Direct some OAuth clients to new server
- Monitor for issues
- Swap over when confident
### Phase 4: Cleanup (Week 6+)
- Remove custom OIDC code
- Decommission old tables
- Document new architecture
---
## 12. Code Examples
### Rodauth-OAuth: Minimal Setup
```ruby
# Gemfile
gem 'roda'
gem 'rodauth-oauth'
gem 'sequel'
# lib/auth_server.rb
class AuthServer < Roda
plugin :render, views: 'views'
plugin :sessions, secret: 'SECRET'
plugin :rodauth do
db DB
enable :login, :logout, :create_account, :oidc, :oauth_pkce,
:oauth_authorization_code_grant, :oauth_token_introspection
oauth_application_scopes %w[openid email profile]
oauth_require_pkce true
hmac_secret 'HMAC_SECRET'
oauth_jwt_keys('RS256' => [private_key])
end
route do |r|
r.rodauth # All OAuth routes automatically mounted
# Your custom routes
r.get 'api' do
rodauth.require_oauth_authorization('api.read')
# return data
end
end
end
```
### Your Current Approach: Manual
```ruby
# app/controllers/oidc_controller.rb
def authorize
validate_params
find_application
check_authentication
handle_consent
generate_code
redirect_with_code
end
def token
extract_client_credentials
find_application
validate_code
check_pkce
generate_tokens
return_json
end
```
---
## Summary Table
| Aspect | Your Implementation | Rodauth-OAuth |
|--------|-------------------|----------------|
| **Framework** | Rails | Roda |
| **Database ORM** | ActiveRecord | Sequel |
| **Grant Types** | 1 (Auth Code) | 7+ options |
| **Token Types** | Opaque | Opaque or JWT |
| **Security Features** | Basic | Advanced (DPoP, MTLS, etc.) |
| **OIDC Compliance** | Partial | Full (Certified) |
| **Lines of Code** | ~1000 | ~10,000+ |
| **Features** | 2-3 | 34 optional |
| **Maintenance Burden** | High | Low (OSS) |
| **Learning Curve** | Low | Medium (Roda) |
| **Production Ready** | Yes | Yes |
| **Community** | Just you | Active |

View File

@@ -1,418 +0,0 @@
# Rodauth-OAuth: Quick Reference Guide
## What Is It?
A production-ready Ruby gem implementing OAuth 2.0 and OpenID Connect. Think of it as a complete, standards-certified OAuth/OIDC server library for Ruby apps.
## Key Stats
- **Framework**: Roda (not Rails, but works with Rails via wrapper)
- **Features**: 34 modular features you can enable/disable
- **Certification**: Officially certified for 11 OpenID Connect profiles
- **Test Coverage**: Hundreds of tests
- **Status**: Production-ready, actively maintained
## Why Consider It?
### Advantages Over Your Implementation
1. **Complete OAuth/OIDC Implementation**
- All major grant types supported
- Certified compliance with standards
- 20+ RFC implementations
2. **Security Features**
- Token hashing (bcrypt) by default
- DPoP support (token binding)
- TLS mutual authentication
- Proper scope enforcement
3. **Advanced Token Management**
- Refresh tokens (you don't have)
- Token revocation
- Token introspection
- Token rotation policies
4. **Low Maintenance**
- Well-tested codebase
- Active community
- Regular spec updates
- Battle-tested in production
5. **Extensible**
- Highly configurable
- Override any behavior you need
- Database-agnostic
- Works with any SQL DB
### What Your Implementation Does Better
1. **Simplicity** - Fewer lines of code, easier to understand
2. **Rails Native** - No need to learn Roda
3. **Control** - Full ownership of the codebase
4. **Minimal Dependencies** - Just JWT and OpenSSL
## Architecture Overview
### Your Current Setup
```
Rails App
└─ OidcController (450 lines)
├─ /oauth/authorize
├─ /oauth/token
├─ /oauth/userinfo
└─ /logout
Models:
├─ OidcAuthorizationCode
├─ OidcAccessToken
└─ OidcUserConsent
Features Supported:
├─ Authorization Code Flow ✓
├─ PKCE ✓
└─ Basic OIDC ✓
NOT Supported:
├─ Refresh Tokens
├─ Token Revocation
├─ Token Introspection
├─ Client Credentials Grant
├─ Device Code Flow
├─ Session Management
├─ Front/Back-Channel Logout
└─ Dynamic Client Registration
```
### Rodauth-OAuth Setup
```
Roda App (web framework)
└─ Rodauth Plugin (authentication/authorization)
├─ oauth_base (foundation)
├─ oauth_authorization_code_grant
├─ oauth_pkce
├─ oauth_jwt (optional)
├─ oidc (OpenID core)
├─ oidc_session_management (optional)
├─ oidc_rp_initiated_logout (optional)
├─ oidc_frontchannel_logout (optional)
├─ oidc_backchannel_logout (optional)
├─ oauth_token_revocation (optional)
├─ oauth_token_introspection (optional)
├─ oauth_client_credentials_grant (optional)
└─ ... (28+ more optional features)
Routes Generated Automatically:
├─ /.well-known/openid-configuration ✓
├─ /.well-known/jwks.json ✓
├─ /oauth/authorize ✓
├─ /oauth/token ✓
├─ /oauth/userinfo ✓
├─ /oauth/introspect (optional)
├─ /oauth/revoke (optional)
└─ /logout ✓
```
## Database Schema Comparison
### Your Current Tables
```
oidc_authorization_codes
├─ id
├─ user_id
├─ application_id
├─ code (unique)
├─ redirect_uri
├─ scope
├─ nonce
├─ code_challenge
├─ code_challenge_method
├─ used (boolean)
├─ expires_at
└─ created_at
oidc_access_tokens
├─ id
├─ user_id
├─ application_id
├─ token (unique)
├─ scope
├─ expires_at
└─ created_at
oidc_user_consents
├─ user_id
├─ application_id
├─ scopes_granted
└─ granted_at
applications
├─ id
├─ name
├─ client_id (unique)
├─ client_secret
├─ redirect_uris (JSON)
├─ app_type
└─ ... (few more fields)
```
### Rodauth-OAuth Tables
```
accounts (from rodauth)
├─ id
├─ status_id
├─ email
└─ password_hash
oauth_applications (75+ columns!)
├─ Basic: id, account_id, name, description
├─ OAuth: client_id, client_secret, redirect_uri, scopes
├─ Config: token_endpoint_auth_method, grant_types, response_types
├─ JWT/JWKS: jwks_uri, jwks, jwt_public_key
├─ OIDC: subject_type, id_token_signed_response_alg, etc.
├─ PAR: require_pushed_authorization_requests
├─ DPoP: dpop_bound_access_tokens
├─ TLS: tls_client_auth_* fields
└─ Logout: post_logout_redirect_uris, frontchannel_logout_uri, etc.
oauth_grants (consolidated - replaces your two tables!)
├─ id, account_id, oauth_application_id
├─ type (authorization_code, refresh_token, etc.)
├─ code, token, refresh_token (with hashed versions)
├─ expires_in, revoked_at
├─ scopes, access_type
├─ code_challenge, code_challenge_method (PKCE)
├─ user_code, last_polled_at (Device code grant)
├─ nonce, acr, claims (OIDC)
├─ dpop_jkt (DPoP)
└─ certificate_thumbprint, resource (advanced)
[Optional tables for features you enable]
```
## Feature Comparison Matrix
| Feature | Your Code | Rodauth-OAuth | Effort to Add* |
|---------|-----------|---------------|--------|
| Authorization Code Flow | ✓ | ✓ | N/A |
| PKCE | ✓ | ✓ | N/A |
| Refresh Tokens | ✗ | ✓ | 1-2 weeks |
| Token Revocation | ✗ | ✓ | 1 week |
| Token Introspection | ✗ | ✓ | 1 week |
| Client Credentials Grant | ✗ | ✓ | 2 weeks |
| Device Code Flow | ✗ | ✓ | 3 weeks |
| JWT Access Tokens | ✗ | ✓ | 1 week |
| Session Management | ✗ | ✓ | 2-3 weeks |
| Front-Channel Logout | ✗ | ✓ | 1-2 weeks |
| Back-Channel Logout | ✗ | ✓ | 2 weeks |
| Dynamic Client Reg | ✗ | ✓ | 3-4 weeks |
| Token Hashing | ✗ | ✓ | 1 week |
*Time estimates for adding to your implementation
## Code Examples
### Rodauth-OAuth: Minimal OAuth Server
```ruby
# Gemfile
gem 'roda'
gem 'rodauth-oauth'
gem 'sequel'
# lib/auth_server.rb
class AuthServer < Roda
plugin :sessions, secret: ENV['SESSION_SECRET']
plugin :rodauth do
db DB
enable :login, :logout, :create_account,
:oidc, :oauth_pkce, :oauth_authorization_code_grant,
:oauth_token_revocation
oauth_application_scopes %w[openid email profile]
oauth_require_pkce true
end
route do |r|
r.rodauth # All OAuth endpoints auto-mounted!
# Your app logic here
end
end
```
That's it! All these endpoints are automatically available:
- GET /.well-known/openid-configuration
- GET /.well-known/jwks.json
- GET /oauth/authorize
- POST /oauth/token
- POST /oauth/revoke
- GET /oauth/userinfo
- GET /logout
### Your Current Approach
```ruby
# app/controllers/oidc_controller.rb
class OidcController < ApplicationController
def authorize
# 150 lines of validation logic
end
def token
# 100 lines of token generation logic
end
def userinfo
# 50 lines of claims logic
end
def logout
# 50 lines of logout logic
end
private
def validate_pkce(auth_code, code_verifier)
# 50 lines of PKCE validation
end
end
```
## Integration Paths
### Option 1: Stick with Your Implementation
- Keep building features incrementally
- Effort: 2-3 months to reach feature parity
- Pro: Rails native, full control
- Con: Continuous maintenance burden
### Option 2: Switch to Rodauth-OAuth
- Learn Roda/Rodauth (1-2 weeks)
- Migrate database (1 week)
- Replace 450 lines of code with config (1 week)
- Testing & validation (2-3 weeks)
- Effort: 4-8 weeks total
- Pro: Production-ready, certified, maintained
- Con: Different framework (Roda)
### Option 3: Hybrid Approach
- Keep your Rails app for business logic
- Use rodauth-oauth as separate OAuth/OIDC service
- Services communicate via HTTP/APIs
- Effort: 2-3 weeks (independent services)
- Pro: Best of both worlds
- Con: Operational complexity
## Decision Matrix
### Use Rodauth-OAuth If You Need...
- [x] Standards compliance (OpenID certified)
- [x] Multiple grant types (Client Credentials, Device Code, etc.)
- [x] Token revocation/introspection
- [x] Refresh tokens
- [x] Advanced logout (front/back-channel)
- [x] Session management
- [x] Token hashing/security best practices
- [x] Hands-off maintenance
- [x] Production-battle-tested code
### Keep Your Implementation If You...
- [x] Only need Authorization Code + PKCE
- [x] Want zero Roda/external framework learning
- [x] Value Rails patterns over standards
- [x] Like to understand every line of code
- [x] Can allocate time for ongoing maintenance
- [x] Prefer minimal dependencies
## Key Differences You'll Notice
### 1. Framework Paradigm
- **Your impl**: Rails (MVC, familiar)
- **Rodauth**: Roda (routing-focused, lightweight)
### 2. Database ORM
- **Your impl**: ActiveRecord (Rails native)
- **Rodauth**: Sequel (lighter, more control)
### 3. Configuration Style
- **Your impl**: Rails initializers, environment variables
- **Rodauth**: Plugin block with DSL
### 4. Model Management
- **Your impl**: Rails models with validations, associations
- **Rodauth**: Minimal models, logic in database
### 5. Testing Approach
- **Your impl**: RSpec, model/controller tests
- **Rodauth**: Request-based integration tests
## File Locations (If You Switch)
```
Current Structure
├── app/controllers/oidc_controller.rb
├── app/models/
│ ├── oidc_authorization_code.rb
│ ├── oidc_access_token.rb
│ └── oidc_user_consent.rb
├── app/services/oidc_jwt_service.rb
├── db/migrate/*oidc*.rb
Rodauth-OAuth Equivalent
├── lib/rodauth_app.rb # Configuration (replaces most controllers)
├── app/views/rodauth/ # Templates (consent form, etc.)
├── config/routes.rb # Simple: routes mount rodauth
└── db/migrate/*rodauth_oauth*.rb
```
## Performance Considerations
### Your Implementation
- Small tables → fast queries
- Fewer columns → less overhead
- Simple token validation
- Estimated: 5-10ms per token validation
### Rodauth-OAuth
- More columns, but same queries
- Optional token hashing (slight overhead)
- More features = more options checked
- Estimated: 10-20ms per token validation
- Can be optimized: disable unused features
## Getting Started (If You Want to Explore)
1. **Review the code**
```bash
cd /Users/dkam/Development/clinch/tmp/rodauth-oauth
ls -la lib/rodauth/features/ # See all features
cat examples/oidc/authentication_server.rb # Full working example
```
2. **Run the example**
```bash
cd /Users/dkam/Development/clinch/tmp/rodauth-oauth/examples
ruby oidc/authentication_server.rb # Starts server on http://localhost:9292
```
3. **Read the key files**
- README.md: Overview
- MIGRATION-GUIDE-v1.md: Version migration (shows architecture)
- test/migrate/*.rb: Database schema
- examples/oidc/*.rb: Complete working implementation
## Next Steps
1. **If keeping your implementation:**
- Prioritize refresh token support
- Add token revocation endpoint
- Consider token hashing
2. **If exploring rodauth-oauth:**
- Run the example server
- Review the feature files
- Check if hybrid approach works for your org
3. **For either path:**
- Document your decision
- Plan feature roadmap
- Set up appropriate monitoring
---
**Bottom Line**: Rodauth-OAuth is the "production-grade" option if you need comprehensive OAuth/OIDC. Your implementation is fine if you keep features minimal and have maintenance bandwidth.

View File

@@ -91,8 +91,10 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
get "/oauth/authorize", params: auth_params
assert_response :bad_request
assert_match(/Invalid code_challenge_method/, @response.body)
# Should redirect back to client with error parameters (OAuth2 spec)
assert_response :redirect
assert_match(/error=invalid_request/, @response.location)
assert_match(/error_description=.*code_challenge_method/, @response.location)
end
test "authorization endpoint rejects invalid code_challenge format" do
@@ -108,8 +110,10 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
get "/oauth/authorize", params: auth_params
assert_response :bad_request
assert_match(/Invalid code_challenge format/, @response.body)
# Should redirect back to client with error parameters (OAuth2 spec)
assert_response :redirect
assert_match(/error=invalid_request/, @response.location)
assert_match(/error_description=.*code_challenge.*format/, @response.location)
end
test "token endpoint requires code_verifier when PKCE was used (S256)" do

View File

@@ -228,7 +228,11 @@ class OidcRefreshTokenControllerTest < ActionDispatch::IntegrationTest
assert_response :success
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"]
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

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

View File

@@ -1,8 +1,7 @@
require "test_helper"
class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
driven_by :rack_test
# Advanced integration tests for Forward Auth API
class ForwardAuthAdvancedTest < ActionDispatch::IntegrationTest
setup do
@user = users(:one)
@admin_user = users(:two)
@@ -24,7 +23,7 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
assert_response 302
location = response.location
assert_match %r{/signin}, location
assert_match %r{rd=https://app.example.com/dashboard}, 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]
@@ -33,7 +32,10 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
post "/signin", params: {email_address: @user.email_address, password: "password"}
assert_response 302
assert_redirected_to "https://app.example.com/dashboard"
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"}
@@ -146,40 +148,17 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
end
# Security System Tests
test "session security and isolation" do
# User A signs in
post "/signin", params: {email_address: @user.email_address, password: "password"}
user_a_session = cookies[:session_id]
# User B signs in
delete "/session"
post "/signin", params: {email_address: @admin_user.email_address, password: "password"}
user_b_session = cookies[:session_id]
# User A should still be able to access resources
get "/api/verify", headers: {
"X-Forwarded-Host" => "test.example.com",
"Cookie" => "_clinch_session_id=#{user_a_session}"
}
assert_response 200
assert_equal @user.email_address, response.headers["x-remote-user"]
# User B should be able to access resources
get "/api/verify", headers: {
"X-Forwarded-Host" => "test.example.com",
"Cookie" => "_clinch_session_id=#{user_b_session}"
}
assert_response 200
assert_equal @admin_user.email_address, response.headers["x-remote-user"]
# Sessions should be independent
assert_not_equal user_a_session, user_b_session
end
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 = cookies[:session_id]
session_id = Session.last.id
# Should work initially
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
@@ -199,42 +178,42 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
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"}
session_cookie = cookies[:session_id]
# Simulate multiple concurrent requests from different IPs
threads = []
# Make multiple sequential requests (threads don't work in integration tests)
results = []
10.times do |i|
threads << Thread.new do
start_time = Time.current
start_time = Time.current
get "/api/verify", headers: {
"X-Forwarded-Host" => "app#{i}.example.com",
"X-Forwarded-For" => "192.168.1.#{100 + i}",
"Cookie" => "_clinch_session_id=#{session_cookie}"
}
get "/api/verify", headers: {
"X-Forwarded-Host" => "app#{i}.example.com",
"X-Forwarded-For" => "192.168.1.#{100 + i}"
}
end_time = Time.current
end_time = Time.current
results << {
thread_id: i,
status: response.status,
user: response.headers["x-remote-user"],
duration: end_time - start_time
}
end
results << {
request_id: i,
status: response.status,
user: response.headers["x-remote-user"],
duration: end_time - start_time
}
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"
assert result[:duration] < 1.0, "Thread #{result[:thread_id]} was too slow"
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
@@ -285,22 +264,23 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
# Verify headers are correct
if app[:headers_config][:user].present?
assert_equal app[:headers_config][:user],
response.headers.keys.find { |k| k.include?("USER") },
"Wrong user header for #{app[:domain]}"
assert_equal @user.email_address, response.headers[app[:headers_config][:user]]
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-)/i) }
assert_empty auth_headers, "Should have no headers for #{app[:domain]}"
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", "sub.app.example.com"]},
{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"]}
]
@@ -329,12 +309,11 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
# Performance System Tests
test "system performance under load" do
# Create test application
Application.create!(name: "Load Test", slug: "loadtest", app_type: "forward_auth", domain_pattern: "loadtest.example.com", active: true)
# 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"}
session_cookie = cookies[:session_id]
# Performance test
start_time = Time.current
@@ -345,8 +324,7 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
request_start = Time.current
get "/api/verify", headers: {
"X-Forwarded-Host" => "app#{i}.loadtest.example.com",
"Cookie" => "_clinch_session_id=#{session_cookie}"
"X-Forwarded-Host" => "app#{i}.loadtest.example.com"
}
request_end = Time.current
@@ -370,35 +348,4 @@ class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
rps = request_count / total_time
assert rps > 10, "Requests per second #{rps} is too low"
end
# Error Recovery System Tests
test "graceful degradation with database issues" do
# Sign in first
post "/signin", params: {email_address: @user.email_address, password: "password"}
assert_response 302
# Simulate database connection issue by mocking
original_method = Session.method(:find_by)
# Mock database failure
Session.define_singleton_method(:find_by) do |id|
raise ActiveRecord::ConnectionNotEstablished, "Database connection lost"
end
begin
# Request should handle the error gracefully
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
# Should return 302 (redirect to login) rather than 500 error
assert_response 302, "Should gracefully handle database issues"
assert_equal "Invalid session", response.headers["x-auth-reason"]
ensure
# Restore original method
Session.define_singleton_method(:find_by, original_method)
end
# Normal operation should still work
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
assert_response 200
end
end

View File

@@ -1,7 +1,9 @@
require "test_helper"
require "webauthn/fake_client"
class WebauthnSecurityTest < ActionDispatch::SystemTestCase
# 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
# ====================
@@ -52,45 +54,39 @@ class WebauthnSecurityTest < ActionDispatch::SystemTestCase
end
# ====================
# USER HANDLE BINDING TESTS
# USER HANDLE SECURITY TESTS
# ====================
test "user handle is properly bound to WebAuthn credential" do
user = User.create!(email_address: "webauthn_handle_test@example.com", password: "password123")
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")
# Create a WebAuthn credential with user handle
user_handle = SecureRandom.uuid
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",
user_handle: user_handle
)
# Generate handles for both users
handle_a = user_a.webauthn_user_handle
handle_b = user_b.webauthn_user_handle
# Verify user handle is associated with the credential
assert_equal user_handle, credential.user_handle
# Sign in as User A
post signin_path, params: {email_address: user_a.email_address, password: "password123"}
assert_response :redirect
user.destroy
end
# Request WebAuthn challenge (for registration)
post webauthn_challenge_path, params: {email: user_a.email_address}
assert_response :success
test "WebAuthn authentication validates user handle" do
user = User.create!(email_address: "webauthn_handle_auth_test@example.com", password: "password123")
# Parse the JSON response
challenge_data = JSON.parse(response.body)
user_handle = 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",
user_handle: user_handle
)
# 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"]
# Sign in with WebAuthn
# The implementation should verify the user handle matches
# This test documents the expected behavior
# 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.destroy
user_a.destroy
user_b.destroy
end
# ====================
@@ -132,7 +128,10 @@ class WebauthnSecurityTest < ActionDispatch::SystemTestCase
nickname: "Test Key"
)
# Sign in with WebAuthn
# 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
@@ -228,8 +227,8 @@ class WebauthnSecurityTest < ActionDispatch::SystemTestCase
)
credential.reload
assert_equal "192.168.1.100", credential.last_ip_address
assert_equal "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", credential.last_user_agent
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
@@ -314,7 +313,7 @@ class WebauthnSecurityTest < ActionDispatch::SystemTestCase
test "WebAuthn can be required for authentication" do
user = User.create!(email_address: "webauthn_required_test@example.com", password: "password123")
user.update!(webauthn_enabled: true)
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"}
@@ -327,7 +326,7 @@ class WebauthnSecurityTest < ActionDispatch::SystemTestCase
test "WebAuthn can be used for passwordless authentication" do
user = User.create!(email_address: "webauthn_passwordless_test@example.com", password: "password123")
user.update!(webauthn_enabled: true)
user.update!(webauthn_required: true)
user.webauthn_credentials.create!(
external_id: Base64.urlsafe_encode64("passwordless_credential"),

View File

@@ -319,4 +319,35 @@ class UserTest < ActiveSupport::TestCase
# Note: parsed_backup_codes method and legacy tests removed
# All users now use BCrypt hashes stored in JSON column
# WebAuthn user handle tests
test "generates and persists unique webauthn user handle" do
user = User.create!(email_address: "webauthn_test@example.com", password: "password123")
# User should not have a webauthn_id initially
assert_nil user.webauthn_id
# Getting the user handle should generate and persist it
handle = user.webauthn_user_handle
assert_not_nil handle
assert_equal 86, handle.length # Base64-urlsafe-encoded 64 bytes (no padding)
# Reload and verify it was persisted
user.reload
assert_equal handle, user.webauthn_id
# Subsequent calls should return the same handle (stable)
assert_equal handle, user.webauthn_user_handle
end
test "webauthn user handles are unique across users" do
user1 = User.create!(email_address: "user1@example.com", password: "password123")
user2 = User.create!(email_address: "user2@example.com", password: "password123")
handle1 = user1.webauthn_user_handle
handle2 = user2.webauthn_user_handle
# Each user should get a unique handle
assert_not_equal handle1, handle2
end
end

View File

@@ -57,7 +57,7 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
end
test "should generate id token with required claims" do
token = @service.generate_id_token(@user, @application)
token = @service.generate_id_token(@user, @application, scopes: "openid email profile")
assert_not_nil token, "Should generate token"
assert token.length > 100, "Token should be substantial"
@@ -88,7 +88,7 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
admin_group = groups(:admin_group)
@user.groups << admin_group unless @user.groups.include?(admin_group)
token = @service.generate_id_token(@user, @application)
token = @service.generate_id_token(@user, @application, scopes: "openid groups")
decoded = JWT.decode(token, nil, false).first
assert_includes decoded["groups"], "Administrators", "Should include user's groups"
@@ -248,10 +248,10 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
end
test "should handle access token generation" do
token = @service.generate_id_token(@user, @application)
token = @service.generate_id_token(@user, @application, scopes: "openid email")
decoded = JWT.decode(token, nil, false).first
# ID tokens always include email_verified
# ID tokens include email_verified when email scope is requested
assert_includes decoded.keys, "email_verified"
assert_equal @user.id.to_s, decoded["sub"], "Should decode subject correctly"
assert_equal @application.client_id, decoded["aud"], "Should decode audience correctly"
@@ -278,7 +278,7 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
custom_claims: {app_groups: ["admin"], library_access: "all"}
)
token = @service.generate_id_token(user, app)
token = @service.generate_id_token(user, app, scopes: "openid email profile groups")
decoded = JWT.decode(token, nil, false).first
assert_equal ["admin"], decoded["app_groups"]
@@ -305,7 +305,7 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
custom_claims: {role: "admin", app_specific: true}
)
token = @service.generate_id_token(user, app)
token = @service.generate_id_token(user, app, scopes: "openid email profile groups")
decoded = JWT.decode(token, nil, false).first
# App-specific claim should win
@@ -330,7 +330,7 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
# User adds roles: ["admin"]
user.update!(custom_claims: {"roles" => ["admin"], "permissions" => ["write"]})
token = @service.generate_id_token(user, app)
token = @service.generate_id_token(user, app, scopes: "openid email profile groups")
decoded = JWT.decode(token, nil, false).first
# Roles should be combined (not overwritten)
@@ -360,7 +360,7 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
# User adds roles: ["admin"]
user.update!(custom_claims: {"roles" => ["admin"]})
token = @service.generate_id_token(user, app)
token = @service.generate_id_token(user, app, scopes: "openid email profile groups")
decoded = JWT.decode(token, nil, false).first
# All roles should be combined
@@ -382,7 +382,7 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
# User also has "user" role (duplicate)
user.update!(custom_claims: {"roles" => ["user", "admin"]})
token = @service.generate_id_token(user, app)
token = @service.generate_id_token(user, app, scopes: "openid email profile groups")
decoded = JWT.decode(token, nil, false).first
# "user" should only appear once
@@ -404,7 +404,7 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
# User overrides max_items and theme, adds to roles
user.update!(custom_claims: {"roles" => ["admin"], "max_items" => 100, "theme" => "dark"})
token = @service.generate_id_token(user, app)
token = @service.generate_id_token(user, app, scopes: "openid email profile groups")
decoded = JWT.decode(token, nil, false).first
# Arrays should be combined
@@ -438,7 +438,7 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
}
})
token = @service.generate_id_token(user, app)
token = @service.generate_id_token(user, app, scopes: "openid email profile groups")
decoded = JWT.decode(token, nil, false).first
# Nested hashes should be deep merged
@@ -467,7 +467,7 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
custom_claims: {"roles" => ["app_admin"]}
)
token = @service.generate_id_token(user, app)
token = @service.generate_id_token(user, app, scopes: "openid email profile groups")
decoded = JWT.decode(token, nil, false).first
# All three sources should be combined
@@ -562,4 +562,133 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
assert_includes decoded.keys, "azp", "Should include azp claim"
assert_equal @application.client_id, decoded["azp"], "azp should be the application's client_id"
end
# Scope-based claim filtering tests (OIDC Core compliance)
test "openid scope only should include minimal required claims" do
token = @service.generate_id_token(@user, @application, scopes: "openid")
decoded = JWT.decode(token, nil, false).first
# Required claims should always be present
assert_includes decoded.keys, "iss", "Should include issuer"
assert_includes decoded.keys, "sub", "Should include subject"
assert_includes decoded.keys, "aud", "Should include audience"
assert_includes decoded.keys, "exp", "Should include expiration"
assert_includes decoded.keys, "iat", "Should include issued at"
assert_includes decoded.keys, "azp", "Should include authorized party"
# Scope-dependent claims should NOT be present
refute_includes decoded.keys, "email", "Should not include email without email scope"
refute_includes decoded.keys, "email_verified", "Should not include email_verified without email scope"
refute_includes decoded.keys, "name", "Should not include name without profile scope"
refute_includes decoded.keys, "preferred_username", "Should not include preferred_username without profile scope"
refute_includes decoded.keys, "groups", "Should not include groups without groups scope"
end
test "email scope should include email claims" do
token = @service.generate_id_token(@user, @application, scopes: "openid email")
decoded = JWT.decode(token, nil, false).first
# Email claims should be present
assert_includes decoded.keys, "email", "Should include email with email scope"
assert_includes decoded.keys, "email_verified", "Should include email_verified with email scope"
assert_equal @user.email_address, decoded["email"]
assert_equal true, decoded["email_verified"]
# Profile claims should NOT be present
refute_includes decoded.keys, "name", "Should not include name without profile scope"
refute_includes decoded.keys, "preferred_username", "Should not include preferred_username without profile scope"
end
test "profile scope should include profile claims" do
token = @service.generate_id_token(@user, @application, scopes: "openid profile")
decoded = JWT.decode(token, nil, false).first
# Profile claims should be present
assert_includes decoded.keys, "name", "Should include name with profile scope"
assert_includes decoded.keys, "preferred_username", "Should include preferred_username with profile scope"
assert_equal @user.email_address, decoded["name"]
assert_equal @user.email_address, decoded["preferred_username"]
# Email claims should NOT be present
refute_includes decoded.keys, "email", "Should not include email without email scope"
refute_includes decoded.keys, "email_verified", "Should not include email_verified without email scope"
end
test "groups scope should include groups claim" do
admin_group = groups(:admin_group)
@user.groups << admin_group unless @user.groups.include?(admin_group)
token = @service.generate_id_token(@user, @application, scopes: "openid groups")
decoded = JWT.decode(token, nil, false).first
# Groups claim should be present
assert_includes decoded.keys, "groups", "Should include groups with groups scope"
assert_includes decoded["groups"], "Administrators"
# Email and profile claims should NOT be present
refute_includes decoded.keys, "email", "Should not include email without email scope"
refute_includes decoded.keys, "name", "Should not include name without profile scope"
end
test "groups scope should not include groups claim when user has no groups" do
# Ensure user has no groups
@user.groups.clear
token = @service.generate_id_token(@user, @application, scopes: "openid groups")
decoded = JWT.decode(token, nil, false).first
# Groups claim should not be present when user has no groups
refute_includes decoded.keys, "groups", "Should not include empty groups claim"
end
test "multiple scopes should include all requested claims" do
admin_group = groups(:admin_group)
@user.groups << admin_group unless @user.groups.include?(admin_group)
token = @service.generate_id_token(@user, @application, scopes: "openid email profile groups")
decoded = JWT.decode(token, nil, false).first
# All scope-based claims should be present
assert_includes decoded.keys, "email", "Should include email"
assert_includes decoded.keys, "email_verified", "Should include email_verified"
assert_includes decoded.keys, "name", "Should include name"
assert_includes decoded.keys, "preferred_username", "Should include preferred_username"
assert_includes decoded.keys, "groups", "Should include groups"
end
test "scope parameter should handle space-separated string" do
token = @service.generate_id_token(@user, @application, scopes: "openid email profile")
decoded = JWT.decode(token, nil, false).first
assert_includes decoded.keys, "email", "Should parse space-separated scopes"
assert_includes decoded.keys, "name", "Should parse space-separated scopes"
end
test "custom claims should always be merged regardless of scopes" do
user = users(:bob)
app = applications(:another_app)
# Add user custom claim
user.update!(custom_claims: {"custom_field" => "custom_value"})
# Request only openid scope (no email, profile, or groups)
token = @service.generate_id_token(user, app, scopes: "openid")
decoded = JWT.decode(token, nil, false).first
# Custom claims should be present even with minimal scopes
assert_equal "custom_value", decoded["custom_field"], "Custom claims should be included regardless of scopes"
# Standard claims should be filtered
refute_includes decoded.keys, "email", "Should not include email without email scope"
refute_includes decoded.keys, "name", "Should not include name without profile scope"
end
end