Compare commits
24 Commits
9b81aee490
...
0.8.6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e631f606e7 | ||
|
|
f4a697ae9b | ||
|
|
16e34ffaf0 | ||
|
|
0bb84f08d6 | ||
|
|
182682024d | ||
|
|
b517ebe809 | ||
|
|
dd8bd15a76 | ||
|
|
f67a73821c | ||
|
|
b09ddf6db5 | ||
|
|
abbb11a41d | ||
|
|
b2030df8c2 | ||
|
|
07cddf5823 | ||
|
|
46aa983189 | ||
|
|
d0d79ee1da | ||
|
|
2f6a2c7406 | ||
|
|
5137a25626 | ||
|
|
fed7c3cedb | ||
|
|
e288fcad7c | ||
|
|
c1c6e0112e | ||
|
|
7f834fb7fa | ||
|
|
ae99d3d9cf | ||
|
|
1afcd041f9 | ||
|
|
71198340d0 | ||
|
|
d597ca8810 |
28
.github/workflows/ci.yml
vendored
28
.github/workflows/ci.yml
vendored
@@ -41,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:
|
||||
|
||||
@@ -1 +1 @@
|
||||
3.4.6
|
||||
3.4.8
|
||||
|
||||
48
.trivyignore
Normal file
48
.trivyignore
Normal 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
65
Claude.md
Normal 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
|
||||
@@ -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
|
||||
|
||||
3
Gemfile
3
Gemfile
@@ -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
|
||||
|
||||
131
Gemfile.lock
131
Gemfile.lock
@@ -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
|
||||
|
||||
275
README.md
275
README.md
@@ -1,8 +1,10 @@
|
||||
# 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.
|
||||
|
||||
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**
|
||||
|
||||
Clinch gives you one place to manage users and lets any web app authenticate against it without managing its own users.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Clinch
|
||||
VERSION = "0.8.1"
|
||||
VERSION = "0.8.4"
|
||||
end
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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!**
|
||||
@@ -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!
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
269
test/controllers/oidc_userinfo_controller_test.rb
Normal file
269
test/controllers/oidc_userinfo_controller_test.rb
Normal 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
|
||||
2
test/fixtures/oidc_user_consents.yml
vendored
2
test/fixtures/oidc_user_consents.yml
vendored
@@ -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
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
require "test_helper"
|
||||
|
||||
# Note: This file tests API endpoints directly (post/get/assert_response)
|
||||
# so it should use IntegrationTest, not SystemTestCase
|
||||
class ForwardAuthSystemTest < ActionDispatch::IntegrationTest
|
||||
# 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::IntegrationTest
|
||||
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::IntegrationTest
|
||||
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::IntegrationTest
|
||||
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::IntegrationTest
|
||||
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::IntegrationTest
|
||||
|
||||
# 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::IntegrationTest
|
||||
|
||||
# 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::IntegrationTest
|
||||
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::IntegrationTest
|
||||
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
|
||||
@@ -54,45 +54,39 @@ class WebauthnSecurityTest < ActionDispatch::IntegrationTest
|
||||
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
|
||||
|
||||
# ====================
|
||||
@@ -134,7 +128,10 @@ class WebauthnSecurityTest < ActionDispatch::IntegrationTest
|
||||
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
|
||||
|
||||
@@ -230,8 +227,8 @@ class WebauthnSecurityTest < ActionDispatch::IntegrationTest
|
||||
)
|
||||
|
||||
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
|
||||
@@ -316,7 +313,7 @@ class WebauthnSecurityTest < ActionDispatch::IntegrationTest
|
||||
|
||||
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"}
|
||||
@@ -329,7 +326,7 @@ class WebauthnSecurityTest < ActionDispatch::IntegrationTest
|
||||
|
||||
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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user