Compare commits
40 Commits
9b81aee490
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
444ae6291c | ||
|
|
233fb723d5 | ||
|
|
cc6d4fcc65 | ||
|
|
5268f10eb3 | ||
|
|
5c5662eaab | ||
|
|
27d77ebf47 | ||
|
|
ba08158c85 | ||
|
|
a6480b0860 | ||
|
|
75cc223329 | ||
|
|
46ae65f4d2 | ||
|
|
95d0d844e9 | ||
|
|
524a7719c3 | ||
|
|
8110d547dd | ||
|
|
25e1043312 | ||
|
|
074a734c0c | ||
|
|
4a48012a82 | ||
|
|
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
|
- name: Scan for security vulnerabilities in JavaScript dependencies
|
||||||
run: bin/importmap audit
|
run: bin/importmap audit
|
||||||
|
|
||||||
|
scan_container:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
security-events: write # Required for uploading SARIF results
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- name: Build Docker image
|
||||||
|
run: docker build -t clinch:${{ github.sha }} .
|
||||||
|
|
||||||
|
- name: Run Trivy vulnerability scanner
|
||||||
|
uses: aquasecurity/trivy-action@master
|
||||||
|
with:
|
||||||
|
image-ref: clinch:${{ github.sha }}
|
||||||
|
format: 'sarif'
|
||||||
|
output: 'trivy-results.sarif'
|
||||||
|
severity: 'CRITICAL,HIGH'
|
||||||
|
scanners: 'vuln' # Only scan vulnerabilities, not secrets (avoids false positives in vendored gems)
|
||||||
|
|
||||||
|
- name: Upload Trivy results to GitHub Security tab
|
||||||
|
uses: github/codeql-action/upload-sarif@v3
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
sarif_file: 'trivy-results.sarif'
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
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
|
# For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html
|
||||||
|
|
||||||
# Make sure RUBY_VERSION matches the Ruby version in .ruby-version
|
# Make sure RUBY_VERSION matches the Ruby version in .ruby-version
|
||||||
ARG RUBY_VERSION=3.4.6
|
ARG RUBY_VERSION=3.4.8
|
||||||
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
|
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
|
||||||
|
|
||||||
LABEL org.opencontainers.image.source=https://github.com/dkam/clinch
|
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
|
# Rails app lives here
|
||||||
WORKDIR /rails
|
WORKDIR /rails
|
||||||
|
|
||||||
# Install base packages
|
# Install base packages and upgrade to latest security patches
|
||||||
RUN apt-get update -qq && \
|
RUN apt-get update -qq && \
|
||||||
|
apt-get upgrade -y && \
|
||||||
apt-get install --no-install-recommends -y curl libjemalloc2 libvips sqlite3 && \
|
apt-get install --no-install-recommends -y curl libjemalloc2 libvips sqlite3 && \
|
||||||
ln -s /usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2 /usr/local/lib/libjemalloc.so && \
|
ln -s /usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2 /usr/local/lib/libjemalloc.so && \
|
||||||
rm -rf /var/lib/apt/lists /var/cache/apt/archives
|
rm -rf /var/lib/apt/lists /var/cache/apt/archives
|
||||||
|
|||||||
3
Gemfile
3
Gemfile
@@ -90,4 +90,7 @@ group :test do
|
|||||||
|
|
||||||
# Code coverage analysis
|
# Code coverage analysis
|
||||||
gem "simplecov", require: false
|
gem "simplecov", require: false
|
||||||
|
|
||||||
|
# Pin minitest to < 6.0 until Rails 8.1 supports the new API
|
||||||
|
gem "minitest", "< 6.0"
|
||||||
end
|
end
|
||||||
|
|||||||
131
Gemfile.lock
131
Gemfile.lock
@@ -1,7 +1,7 @@
|
|||||||
GEM
|
GEM
|
||||||
remote: https://rubygems.org/
|
remote: https://rubygems.org/
|
||||||
specs:
|
specs:
|
||||||
action_text-trix (2.1.15)
|
action_text-trix (2.1.16)
|
||||||
railties
|
railties
|
||||||
actioncable (8.1.1)
|
actioncable (8.1.1)
|
||||||
actionpack (= 8.1.1)
|
actionpack (= 8.1.1)
|
||||||
@@ -80,14 +80,14 @@ GEM
|
|||||||
android_key_attestation (0.3.0)
|
android_key_attestation (0.3.0)
|
||||||
ast (2.4.3)
|
ast (2.4.3)
|
||||||
base64 (0.3.0)
|
base64 (0.3.0)
|
||||||
bcrypt (3.1.20)
|
bcrypt (3.1.21)
|
||||||
bcrypt_pbkdf (1.1.1)
|
bcrypt_pbkdf (1.1.2)
|
||||||
bigdecimal (3.3.1)
|
bigdecimal (4.0.1)
|
||||||
bindata (2.5.1)
|
bindata (2.5.1)
|
||||||
bindex (0.8.1)
|
bindex (0.8.1)
|
||||||
bootsnap (1.19.0)
|
bootsnap (1.20.1)
|
||||||
msgpack (~> 1.2)
|
msgpack (~> 1.2)
|
||||||
brakeman (7.1.1)
|
brakeman (7.1.2)
|
||||||
racc
|
racc
|
||||||
builder (3.3.0)
|
builder (3.3.0)
|
||||||
bundler-audit (0.9.3)
|
bundler-audit (0.9.3)
|
||||||
@@ -106,37 +106,37 @@ GEM
|
|||||||
childprocess (5.1.0)
|
childprocess (5.1.0)
|
||||||
logger (~> 1.5)
|
logger (~> 1.5)
|
||||||
chunky_png (1.4.0)
|
chunky_png (1.4.0)
|
||||||
concurrent-ruby (1.3.5)
|
concurrent-ruby (1.3.6)
|
||||||
connection_pool (2.5.5)
|
connection_pool (3.0.2)
|
||||||
cose (1.3.1)
|
cose (1.3.1)
|
||||||
cbor (~> 0.5.9)
|
cbor (~> 0.5.9)
|
||||||
openssl-signature_algorithm (~> 1.0)
|
openssl-signature_algorithm (~> 1.0)
|
||||||
crass (1.0.6)
|
crass (1.0.6)
|
||||||
date (3.5.0)
|
date (3.5.1)
|
||||||
debug (1.11.0)
|
debug (1.11.1)
|
||||||
irb (~> 1.10)
|
irb (~> 1.10)
|
||||||
reline (>= 0.3.8)
|
reline (>= 0.3.8)
|
||||||
docile (1.4.1)
|
docile (1.4.1)
|
||||||
dotenv (3.1.8)
|
dotenv (3.2.0)
|
||||||
drb (2.2.3)
|
drb (2.2.3)
|
||||||
ed25519 (1.4.0)
|
ed25519 (1.4.0)
|
||||||
erb (6.0.0)
|
erb (6.0.1)
|
||||||
erubi (1.13.1)
|
erubi (1.13.1)
|
||||||
et-orbi (1.4.0)
|
et-orbi (1.4.0)
|
||||||
tzinfo
|
tzinfo
|
||||||
ffi (1.17.2-aarch64-linux-gnu)
|
ffi (1.17.3-aarch64-linux-gnu)
|
||||||
ffi (1.17.2-aarch64-linux-musl)
|
ffi (1.17.3-aarch64-linux-musl)
|
||||||
ffi (1.17.2-arm-linux-gnu)
|
ffi (1.17.3-arm-linux-gnu)
|
||||||
ffi (1.17.2-arm-linux-musl)
|
ffi (1.17.3-arm-linux-musl)
|
||||||
ffi (1.17.2-arm64-darwin)
|
ffi (1.17.3-arm64-darwin)
|
||||||
ffi (1.17.2-x86_64-linux-gnu)
|
ffi (1.17.3-x86_64-linux-gnu)
|
||||||
ffi (1.17.2-x86_64-linux-musl)
|
ffi (1.17.3-x86_64-linux-musl)
|
||||||
fugit (1.12.1)
|
fugit (1.12.1)
|
||||||
et-orbi (~> 1.4)
|
et-orbi (~> 1.4)
|
||||||
raabro (~> 1.4)
|
raabro (~> 1.4)
|
||||||
globalid (1.3.0)
|
globalid (1.3.0)
|
||||||
activesupport (>= 6.1)
|
activesupport (>= 6.1)
|
||||||
i18n (1.14.7)
|
i18n (1.14.8)
|
||||||
concurrent-ruby (~> 1.0)
|
concurrent-ruby (~> 1.0)
|
||||||
image_processing (1.14.0)
|
image_processing (1.14.0)
|
||||||
mini_magick (>= 4.9.5, < 6)
|
mini_magick (>= 4.9.5, < 6)
|
||||||
@@ -145,18 +145,18 @@ GEM
|
|||||||
actionpack (>= 6.0.0)
|
actionpack (>= 6.0.0)
|
||||||
activesupport (>= 6.0.0)
|
activesupport (>= 6.0.0)
|
||||||
railties (>= 6.0.0)
|
railties (>= 6.0.0)
|
||||||
io-console (0.8.1)
|
io-console (0.8.2)
|
||||||
irb (1.15.3)
|
irb (1.16.0)
|
||||||
pp (>= 0.6.0)
|
pp (>= 0.6.0)
|
||||||
rdoc (>= 4.0.0)
|
rdoc (>= 4.0.0)
|
||||||
reline (>= 0.4.2)
|
reline (>= 0.4.2)
|
||||||
jbuilder (2.14.1)
|
jbuilder (2.14.1)
|
||||||
actionview (>= 7.0.0)
|
actionview (>= 7.0.0)
|
||||||
activesupport (>= 7.0.0)
|
activesupport (>= 7.0.0)
|
||||||
json (2.16.0)
|
json (2.18.0)
|
||||||
jwt (3.1.2)
|
jwt (3.1.2)
|
||||||
base64
|
base64
|
||||||
kamal (2.9.0)
|
kamal (2.10.1)
|
||||||
activesupport (>= 7.0)
|
activesupport (>= 7.0)
|
||||||
base64 (~> 0.2)
|
base64 (~> 0.2)
|
||||||
bcrypt_pbkdf (~> 1.0)
|
bcrypt_pbkdf (~> 1.0)
|
||||||
@@ -176,7 +176,7 @@ GEM
|
|||||||
launchy (>= 2.2, < 4)
|
launchy (>= 2.2, < 4)
|
||||||
lint_roller (1.1.0)
|
lint_roller (1.1.0)
|
||||||
logger (1.7.0)
|
logger (1.7.0)
|
||||||
loofah (2.24.1)
|
loofah (2.25.0)
|
||||||
crass (~> 1.0.2)
|
crass (~> 1.0.2)
|
||||||
nokogiri (>= 1.12.0)
|
nokogiri (>= 1.12.0)
|
||||||
mail (2.9.0)
|
mail (2.9.0)
|
||||||
@@ -190,9 +190,9 @@ GEM
|
|||||||
mini_magick (5.3.1)
|
mini_magick (5.3.1)
|
||||||
logger
|
logger
|
||||||
mini_mime (1.1.5)
|
mini_mime (1.1.5)
|
||||||
minitest (5.26.2)
|
minitest (5.27.0)
|
||||||
msgpack (1.8.0)
|
msgpack (1.8.0)
|
||||||
net-imap (0.5.12)
|
net-imap (0.6.2)
|
||||||
date
|
date
|
||||||
net-protocol
|
net-protocol
|
||||||
net-pop (0.1.2)
|
net-pop (0.1.2)
|
||||||
@@ -207,21 +207,21 @@ GEM
|
|||||||
net-protocol
|
net-protocol
|
||||||
net-ssh (7.3.0)
|
net-ssh (7.3.0)
|
||||||
nio4r (2.7.5)
|
nio4r (2.7.5)
|
||||||
nokogiri (1.18.10-aarch64-linux-gnu)
|
nokogiri (1.19.0-aarch64-linux-gnu)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.18.10-aarch64-linux-musl)
|
nokogiri (1.19.0-aarch64-linux-musl)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.18.10-arm-linux-gnu)
|
nokogiri (1.19.0-arm-linux-gnu)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.18.10-arm-linux-musl)
|
nokogiri (1.19.0-arm-linux-musl)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.18.10-arm64-darwin)
|
nokogiri (1.19.0-arm64-darwin)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.18.10-x86_64-linux-gnu)
|
nokogiri (1.19.0-x86_64-linux-gnu)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.18.10-x86_64-linux-musl)
|
nokogiri (1.19.0-x86_64-linux-musl)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
openssl (3.3.2)
|
openssl (4.0.0)
|
||||||
openssl-signature_algorithm (1.3.0)
|
openssl-signature_algorithm (1.3.0)
|
||||||
openssl (> 2.0)
|
openssl (> 2.0)
|
||||||
ostruct (0.6.3)
|
ostruct (0.6.3)
|
||||||
@@ -232,12 +232,12 @@ GEM
|
|||||||
pp (0.6.3)
|
pp (0.6.3)
|
||||||
prettyprint
|
prettyprint
|
||||||
prettyprint (0.2.0)
|
prettyprint (0.2.0)
|
||||||
prism (1.6.0)
|
prism (1.7.0)
|
||||||
propshaft (1.3.1)
|
propshaft (1.3.1)
|
||||||
actionpack (>= 7.0.0)
|
actionpack (>= 7.0.0)
|
||||||
activesupport (>= 7.0.0)
|
activesupport (>= 7.0.0)
|
||||||
rack
|
rack
|
||||||
psych (5.2.6)
|
psych (5.3.1)
|
||||||
date
|
date
|
||||||
stringio
|
stringio
|
||||||
public_suffix (7.0.0)
|
public_suffix (7.0.0)
|
||||||
@@ -251,7 +251,7 @@ GEM
|
|||||||
rack (>= 3.0.0)
|
rack (>= 3.0.0)
|
||||||
rack-test (2.2.0)
|
rack-test (2.2.0)
|
||||||
rack (>= 1.3)
|
rack (>= 1.3)
|
||||||
rackup (2.2.1)
|
rackup (2.3.1)
|
||||||
rack (>= 3)
|
rack (>= 3)
|
||||||
rails (8.1.1)
|
rails (8.1.1)
|
||||||
actioncable (= 8.1.1)
|
actioncable (= 8.1.1)
|
||||||
@@ -285,7 +285,7 @@ GEM
|
|||||||
zeitwerk (~> 2.6)
|
zeitwerk (~> 2.6)
|
||||||
rainbow (3.1.1)
|
rainbow (3.1.1)
|
||||||
rake (13.3.1)
|
rake (13.3.1)
|
||||||
rdoc (6.16.1)
|
rdoc (7.0.3)
|
||||||
erb
|
erb
|
||||||
psych (>= 4.0.0)
|
psych (>= 4.0.0)
|
||||||
tsort
|
tsort
|
||||||
@@ -309,22 +309,22 @@ GEM
|
|||||||
rubocop-ast (>= 1.47.1, < 2.0)
|
rubocop-ast (>= 1.47.1, < 2.0)
|
||||||
ruby-progressbar (~> 1.7)
|
ruby-progressbar (~> 1.7)
|
||||||
unicode-display_width (>= 2.4.0, < 4.0)
|
unicode-display_width (>= 2.4.0, < 4.0)
|
||||||
rubocop-ast (1.48.0)
|
rubocop-ast (1.49.0)
|
||||||
parser (>= 3.3.7.2)
|
parser (>= 3.3.7.2)
|
||||||
prism (~> 1.4)
|
prism (~> 1.7)
|
||||||
rubocop-performance (1.26.1)
|
rubocop-performance (1.26.1)
|
||||||
lint_roller (~> 1.1)
|
lint_roller (~> 1.1)
|
||||||
rubocop (>= 1.75.0, < 2.0)
|
rubocop (>= 1.75.0, < 2.0)
|
||||||
rubocop-ast (>= 1.47.1, < 2.0)
|
rubocop-ast (>= 1.47.1, < 2.0)
|
||||||
ruby-progressbar (1.13.0)
|
ruby-progressbar (1.13.0)
|
||||||
ruby-vips (2.2.5)
|
ruby-vips (2.3.0)
|
||||||
ffi (~> 1.12)
|
ffi (~> 1.12)
|
||||||
logger
|
logger
|
||||||
rubyzip (3.2.2)
|
rubyzip (3.2.2)
|
||||||
safety_net_attestation (0.5.0)
|
safety_net_attestation (0.5.0)
|
||||||
jwt (>= 2.0, < 4.0)
|
jwt (>= 2.0, < 4.0)
|
||||||
securerandom (0.4.1)
|
securerandom (0.4.1)
|
||||||
selenium-webdriver (4.38.0)
|
selenium-webdriver (4.39.0)
|
||||||
base64 (~> 0.2)
|
base64 (~> 0.2)
|
||||||
logger (~> 1.4)
|
logger (~> 1.4)
|
||||||
rexml (~> 3.2, >= 3.2.5)
|
rexml (~> 3.2, >= 3.2.5)
|
||||||
@@ -358,14 +358,14 @@ GEM
|
|||||||
fugit (~> 1.11)
|
fugit (~> 1.11)
|
||||||
railties (>= 7.1)
|
railties (>= 7.1)
|
||||||
thor (>= 1.3.1)
|
thor (>= 1.3.1)
|
||||||
sqlite3 (2.8.1-aarch64-linux-gnu)
|
sqlite3 (2.9.0-aarch64-linux-gnu)
|
||||||
sqlite3 (2.8.1-aarch64-linux-musl)
|
sqlite3 (2.9.0-aarch64-linux-musl)
|
||||||
sqlite3 (2.8.1-arm-linux-gnu)
|
sqlite3 (2.9.0-arm-linux-gnu)
|
||||||
sqlite3 (2.8.1-arm-linux-musl)
|
sqlite3 (2.9.0-arm-linux-musl)
|
||||||
sqlite3 (2.8.1-arm64-darwin)
|
sqlite3 (2.9.0-arm64-darwin)
|
||||||
sqlite3 (2.8.1-x86_64-linux-gnu)
|
sqlite3 (2.9.0-x86_64-linux-gnu)
|
||||||
sqlite3 (2.8.1-x86_64-linux-musl)
|
sqlite3 (2.9.0-x86_64-linux-musl)
|
||||||
sshkit (1.24.0)
|
sshkit (1.25.0)
|
||||||
base64
|
base64
|
||||||
logger
|
logger
|
||||||
net-scp (>= 1.1.2)
|
net-scp (>= 1.1.2)
|
||||||
@@ -386,22 +386,22 @@ GEM
|
|||||||
rubocop-performance (~> 1.26.0)
|
rubocop-performance (~> 1.26.0)
|
||||||
stimulus-rails (1.3.4)
|
stimulus-rails (1.3.4)
|
||||||
railties (>= 6.0.0)
|
railties (>= 6.0.0)
|
||||||
stringio (3.1.8)
|
stringio (3.2.0)
|
||||||
tailwindcss-rails (4.4.0)
|
tailwindcss-rails (4.4.0)
|
||||||
railties (>= 7.0.0)
|
railties (>= 7.0.0)
|
||||||
tailwindcss-ruby (~> 4.0)
|
tailwindcss-ruby (~> 4.0)
|
||||||
tailwindcss-ruby (4.1.16)
|
tailwindcss-ruby (4.1.18)
|
||||||
tailwindcss-ruby (4.1.16-aarch64-linux-gnu)
|
tailwindcss-ruby (4.1.18-aarch64-linux-gnu)
|
||||||
tailwindcss-ruby (4.1.16-aarch64-linux-musl)
|
tailwindcss-ruby (4.1.18-aarch64-linux-musl)
|
||||||
tailwindcss-ruby (4.1.16-arm64-darwin)
|
tailwindcss-ruby (4.1.18-arm64-darwin)
|
||||||
tailwindcss-ruby (4.1.16-x86_64-linux-gnu)
|
tailwindcss-ruby (4.1.18-x86_64-linux-gnu)
|
||||||
tailwindcss-ruby (4.1.16-x86_64-linux-musl)
|
tailwindcss-ruby (4.1.18-x86_64-linux-musl)
|
||||||
thor (1.4.0)
|
thor (1.4.0)
|
||||||
thruster (0.1.16)
|
thruster (0.1.17)
|
||||||
thruster (0.1.16-aarch64-linux)
|
thruster (0.1.17-aarch64-linux)
|
||||||
thruster (0.1.16-arm64-darwin)
|
thruster (0.1.17-arm64-darwin)
|
||||||
thruster (0.1.16-x86_64-linux)
|
thruster (0.1.17-x86_64-linux)
|
||||||
timeout (0.4.4)
|
timeout (0.6.0)
|
||||||
tpm-key_attestation (0.14.1)
|
tpm-key_attestation (0.14.1)
|
||||||
bindata (~> 2.4)
|
bindata (~> 2.4)
|
||||||
openssl (> 2.0)
|
openssl (> 2.0)
|
||||||
@@ -437,7 +437,7 @@ GEM
|
|||||||
websocket-extensions (0.1.5)
|
websocket-extensions (0.1.5)
|
||||||
xpath (3.2.0)
|
xpath (3.2.0)
|
||||||
nokogiri (~> 1.8)
|
nokogiri (~> 1.8)
|
||||||
zeitwerk (2.7.3)
|
zeitwerk (2.7.4)
|
||||||
|
|
||||||
PLATFORMS
|
PLATFORMS
|
||||||
aarch64-linux
|
aarch64-linux
|
||||||
@@ -463,6 +463,7 @@ DEPENDENCIES
|
|||||||
jwt (~> 3.1)
|
jwt (~> 3.1)
|
||||||
kamal
|
kamal
|
||||||
letter_opener
|
letter_opener
|
||||||
|
minitest (< 6.0)
|
||||||
propshaft
|
propshaft
|
||||||
public_suffix (~> 7.0)
|
public_suffix (~> 7.0)
|
||||||
puma (>= 5.0)
|
puma (>= 5.0)
|
||||||
@@ -487,4 +488,4 @@ DEPENDENCIES
|
|||||||
webauthn (~> 3.0)
|
webauthn (~> 3.0)
|
||||||
|
|
||||||
BUNDLED WITH
|
BUNDLED WITH
|
||||||
2.7.2
|
4.0.3
|
||||||
|
|||||||
280
README.md
280
README.md
@@ -1,7 +1,9 @@
|
|||||||
# Clinch
|
# Clinch
|
||||||
|
## Position and Control for your Authentication
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> This software is experimental. If you'd like to try it out, find bugs, security flaws and improvements, please do.
|
> This software is experimental. If you'd like to try it out, find bugs, security flaws and improvements, please do.
|
||||||
|
|
||||||
|
We do these things not because they're easy, but because we thought they'd be easy.
|
||||||
|
|
||||||
**A lightweight, self-hosted identity & SSO / IpD portal**
|
**A lightweight, self-hosted identity & SSO / IpD portal**
|
||||||
|
|
||||||
@@ -11,6 +13,8 @@ Clinch gives you one place to manage users and lets any web app authenticate aga
|
|||||||
|
|
||||||
Do you host your own web apps? MeTube, Kavita, Audiobookshelf, Gitea, Grafana, Proxmox? Rather than managing all those separate user accounts, set everyone up on Clinch and let it do the authentication and user management.
|
Do you host your own web apps? MeTube, Kavita, Audiobookshelf, Gitea, Grafana, Proxmox? Rather than managing all those separate user accounts, set everyone up on Clinch and let it do the authentication and user management.
|
||||||
|
|
||||||
|
Clinch runs as a single Docker container, using SQLite as the database, the job queue (Solid Queue) and the shared cache (Solid Cache). The webserver, Puma, runs the job queue in-process, avoiding the need for another container.
|
||||||
|
|
||||||
Clinch sits in a sweet spot between two excellent open-source identity solutions:
|
Clinch sits in a sweet spot between two excellent open-source identity solutions:
|
||||||
|
|
||||||
**[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.
|
**[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.
|
||||||
@@ -71,6 +75,9 @@ Apps that speak OIDC use the OIDC flow.
|
|||||||
Apps that only need "who is it?", or you want available from the internet behind authentication (MeTube, Jellyfin) use ForwardAuth.
|
Apps that only need "who is it?", or you want available from the internet behind authentication (MeTube, Jellyfin) use ForwardAuth.
|
||||||
|
|
||||||
#### OpenID Connect (OIDC)
|
#### OpenID Connect (OIDC)
|
||||||
|
|
||||||
|
**[OpenID Connect Conformance](https://www.certification.openid.net/plan-detail.html?plan=FbQNTJuYVzrzs&public=true)** - Clinch passes the official OpenID Connect conformance tests (valid as of [v0.8.6](https://github.com/dkam/clinch/releases/tag/0.8.6)).
|
||||||
|
|
||||||
Standard OAuth2/OIDC provider with endpoints:
|
Standard OAuth2/OIDC provider with endpoints:
|
||||||
- `/.well-known/openid-configuration` - Discovery endpoint
|
- `/.well-known/openid-configuration` - Discovery endpoint
|
||||||
- `/authorize` - Authorization endpoint with PKCE support
|
- `/authorize` - Authorization endpoint with PKCE support
|
||||||
@@ -257,6 +264,24 @@ Configure different claims for different applications on a per-user basis:
|
|||||||
- Proxy redirects to Clinch login page
|
- Proxy redirects to Clinch login page
|
||||||
- After login, redirect back to original URL
|
- After login, redirect back to original URL
|
||||||
|
|
||||||
|
#### Race Condition Handling
|
||||||
|
|
||||||
|
After successful login, you may notice an `fa_token` query parameter appended to redirect URLs (e.g., `https://app.example.com/dashboard?fa_token=...`). This solves a timing issue:
|
||||||
|
|
||||||
|
**The Problem:**
|
||||||
|
1. User signs in → session cookie is set
|
||||||
|
2. Browser gets redirected to protected resource
|
||||||
|
3. Browser may not have processed the `Set-Cookie` header yet
|
||||||
|
4. Reverse proxy checks `/api/verify` → no cookie yet → auth fails ❌
|
||||||
|
|
||||||
|
**The Solution:**
|
||||||
|
- A one-time token (`fa_token`) is added to the redirect URL as a query parameter
|
||||||
|
- `/api/verify` checks for this token first, before checking cookies
|
||||||
|
- Token is cached for 60 seconds and deleted immediately after use
|
||||||
|
- This gives the browser's cookie handling time to catch up
|
||||||
|
|
||||||
|
This is transparent to end users and requires no configuration.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Setup & Installation
|
## Setup & Installation
|
||||||
@@ -282,56 +307,207 @@ bin/rails db:migrate
|
|||||||
bin/dev
|
bin/dev
|
||||||
```
|
```
|
||||||
|
|
||||||
### Docker Deployment
|
---
|
||||||
|
|
||||||
|
## Production Deployment
|
||||||
|
|
||||||
|
### Docker Compose (Recommended)
|
||||||
|
|
||||||
|
Create a `docker-compose.yml` file:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
clinch:
|
||||||
|
image: ghcr.io/dkam/clinch:latest
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:3000:3000" # Bind to localhost only (reverse proxy on same host)
|
||||||
|
# Use "3000:3000" if reverse proxy is in Docker network or different host
|
||||||
|
environment:
|
||||||
|
# Rails Configuration
|
||||||
|
RAILS_ENV: production
|
||||||
|
SECRET_KEY_BASE: ${SECRET_KEY_BASE}
|
||||||
|
|
||||||
|
# Application Configuration
|
||||||
|
CLINCH_HOST: ${CLINCH_HOST}
|
||||||
|
CLINCH_FROM_EMAIL: ${CLINCH_FROM_EMAIL:-noreply@example.com}
|
||||||
|
|
||||||
|
# SMTP Configuration
|
||||||
|
SMTP_ADDRESS: ${SMTP_ADDRESS}
|
||||||
|
SMTP_PORT: ${SMTP_PORT}
|
||||||
|
SMTP_DOMAIN: ${SMTP_DOMAIN}
|
||||||
|
SMTP_USERNAME: ${SMTP_USERNAME}
|
||||||
|
SMTP_PASSWORD: ${SMTP_PASSWORD}
|
||||||
|
SMTP_AUTHENTICATION: ${SMTP_AUTHENTICATION:-plain}
|
||||||
|
SMTP_ENABLE_STARTTLS: ${SMTP_ENABLE_STARTTLS:-true}
|
||||||
|
|
||||||
|
# OIDC Configuration (optional - generates temporary key if not provided)
|
||||||
|
OIDC_PRIVATE_KEY: ${OIDC_PRIVATE_KEY}
|
||||||
|
|
||||||
|
# Optional Configuration
|
||||||
|
FORCE_SSL: ${FORCE_SSL:-false}
|
||||||
|
volumes:
|
||||||
|
- ./storage:/rails/storage
|
||||||
|
restart: unless-stopped
|
||||||
|
```
|
||||||
|
|
||||||
|
Create a `.env` file in the same directory:
|
||||||
|
|
||||||
|
**Generate required secrets first:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Build image
|
# Generate SECRET_KEY_BASE (required)
|
||||||
docker build -t clinch .
|
openssl rand -hex 64
|
||||||
|
|
||||||
# Run container
|
# Generate OIDC private key (optional - auto-generated if not provided)
|
||||||
docker run -p 3000:3000 \
|
openssl genpkey -algorithm RSA -out private_key.pem -pkeyopt rsa_keygen_bits:2048
|
||||||
-v clinch-storage:/rails/storage \
|
cat private_key.pem # Copy the output into OIDC_PRIVATE_KEY below
|
||||||
-e SECRET_KEY_BASE=your-secret-key \
|
|
||||||
-e SMTP_ADDRESS=smtp.example.com \
|
|
||||||
-e SMTP_PORT=587 \
|
|
||||||
-e SMTP_USERNAME=your-username \
|
|
||||||
-e SMTP_PASSWORD=your-password \
|
|
||||||
clinch
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Then create `.env`:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Rails Secret (REQUIRED)
|
||||||
|
SECRET_KEY_BASE=paste-output-from-openssl-rand-hex-64-here
|
||||||
|
|
||||||
|
# Application URLs (REQUIRED)
|
||||||
|
CLINCH_HOST=https://auth.yourdomain.com
|
||||||
|
CLINCH_FROM_EMAIL=noreply@yourdomain.com
|
||||||
|
|
||||||
|
# SMTP Settings (REQUIRED for invitations and password resets)
|
||||||
|
SMTP_ADDRESS=smtp.example.com
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_DOMAIN=yourdomain.com
|
||||||
|
SMTP_USERNAME=your-smtp-username
|
||||||
|
SMTP_PASSWORD=your-smtp-password
|
||||||
|
|
||||||
|
# OIDC Private Key (OPTIONAL - generates temporary key if not provided)
|
||||||
|
# For production, generate a persistent key and paste the ENTIRE contents here
|
||||||
|
OIDC_PRIVATE_KEY=
|
||||||
|
|
||||||
|
# Optional: Force SSL redirects (only if NOT behind a reverse proxy handling SSL)
|
||||||
|
FORCE_SSL=false
|
||||||
|
```
|
||||||
|
|
||||||
|
Start Clinch:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
**First Run:**
|
||||||
|
1. Visit `http://localhost:3000` (or your configured domain)
|
||||||
|
2. Complete the first-run wizard to create your admin account
|
||||||
|
3. Configure applications and invite users
|
||||||
|
|
||||||
|
**Upgrading:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Pull latest image
|
||||||
|
docker compose pull
|
||||||
|
|
||||||
|
# Restart with new image (migrations run automatically)
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
**Logs:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# View logs
|
||||||
|
docker compose logs -f clinch
|
||||||
|
|
||||||
|
# View last 100 lines
|
||||||
|
docker compose logs --tail=100 clinch
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backup & Restore
|
||||||
|
|
||||||
|
Clinch stores all persistent data in the `storage/` directory (or `/rails/storage` in Docker):
|
||||||
|
- SQLite database (`production.sqlite3`)
|
||||||
|
- Uploaded files via ActiveStorage (application icons)
|
||||||
|
|
||||||
|
**Database Backup:**
|
||||||
|
|
||||||
|
Use SQLite's `VACUUM INTO` command for safe, atomic backups of a running database:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Local development
|
||||||
|
sqlite3 storage/production.sqlite3 "VACUUM INTO 'backup.sqlite3';"
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates an optimized copy of the database that's safe to make even while Clinch is running.
|
||||||
|
|
||||||
|
**Full Backup (Database + Uploads):**
|
||||||
|
|
||||||
|
For complete backups including uploaded files, backup the database and uploads separately:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Backup database (safe while running)
|
||||||
|
sqlite3 storage/production.sqlite3 "VACUUM INTO 'backup-$(date +%Y%m%d).sqlite3';"
|
||||||
|
|
||||||
|
# 2. Backup uploaded files (ActiveStorage files are immutable)
|
||||||
|
tar -czf uploads-backup-$(date +%Y%m%d).tar.gz storage/uploads/
|
||||||
|
|
||||||
|
# Docker Compose equivalent
|
||||||
|
docker compose exec clinch sqlite3 /rails/storage/production.sqlite3 "VACUUM INTO '/rails/storage/backup-$(date +%Y%m%d).sqlite3';"
|
||||||
|
docker compose exec clinch tar -czf /rails/storage/uploads-backup-$(date +%Y%m%d).tar.gz /rails/storage/uploads/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Restore:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Stop Clinch first
|
||||||
|
# Then restore database
|
||||||
|
cp backup-YYYYMMDD.sqlite3 storage/production.sqlite3
|
||||||
|
|
||||||
|
# Restore uploads
|
||||||
|
tar -xzf uploads-backup-YYYYMMDD.tar.gz -C storage/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Docker Volume Backup:**
|
||||||
|
|
||||||
|
**Option 1: While Running (Online Backup)**
|
||||||
|
|
||||||
|
a) **Mapped volumes** (recommended, e.g., `-v /host/path:/rails/storage`):
|
||||||
|
```bash
|
||||||
|
# Database backup (safe while running)
|
||||||
|
sqlite3 /host/path/production.sqlite3 "VACUUM INTO '/host/path/backup-$(date +%Y%m%d).sqlite3';"
|
||||||
|
|
||||||
|
# Then sync to off-server storage
|
||||||
|
rsync -av /host/path/backup-*.sqlite3 /host/path/uploads/ remote:/backups/clinch/
|
||||||
|
```
|
||||||
|
|
||||||
|
b) **Docker volumes** (e.g., using named volumes in compose):
|
||||||
|
```bash
|
||||||
|
# Database backup (safe while running)
|
||||||
|
docker compose exec clinch sqlite3 /rails/storage/production.sqlite3 "VACUUM INTO '/rails/storage/backup.sqlite3';"
|
||||||
|
|
||||||
|
# Copy out of container
|
||||||
|
docker compose cp clinch:/rails/storage/backup.sqlite3 ./backup-$(date +%Y%m%d).sqlite3
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option 2: While Stopped (Offline Backup)**
|
||||||
|
|
||||||
|
If Docker is stopped, you can copy the entire storage:
|
||||||
|
```bash
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
# For mapped volumes
|
||||||
|
tar -czf clinch-backup-$(date +%Y%m%d).tar.gz /host/path/
|
||||||
|
|
||||||
|
# For docker volumes
|
||||||
|
docker run --rm -v clinch_storage:/data -v $(pwd):/backup ubuntu \
|
||||||
|
tar czf /backup/clinch-backup-$(date +%Y%m%d).tar.gz /data
|
||||||
|
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important:** Do not use tar/snapshots on a running database - use `VACUUM INTO` instead or stop the container first.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
### Environment Variables
|
All configuration is handled via environment variables (see the `.env` file in the Docker Compose section above).
|
||||||
|
|
||||||
Create a `.env` file (see `.env.example`):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Rails
|
|
||||||
SECRET_KEY_BASE=generate-with-bin-rails-secret
|
|
||||||
RAILS_ENV=production
|
|
||||||
|
|
||||||
# Database
|
|
||||||
# SQLite database stored in storage/ directory (Docker volume mount point)
|
|
||||||
|
|
||||||
# SMTP (for sending emails)
|
|
||||||
SMTP_ADDRESS=smtp.example.com
|
|
||||||
SMTP_PORT=587
|
|
||||||
SMTP_DOMAIN=example.com
|
|
||||||
SMTP_USERNAME=your-username
|
|
||||||
SMTP_PASSWORD=your-password
|
|
||||||
SMTP_AUTHENTICATION=plain
|
|
||||||
SMTP_ENABLE_STARTTLS=true
|
|
||||||
|
|
||||||
# Application
|
|
||||||
CLINCH_HOST=https://auth.example.com
|
|
||||||
CLINCH_FROM_EMAIL=noreply@example.com
|
|
||||||
|
|
||||||
# OIDC (optional - generates temporary key in development)
|
|
||||||
# Generate with: openssl genpkey -algorithm RSA -out private_key.pem -pkeyopt rsa_keygen_bits:2048
|
|
||||||
OIDC_PRIVATE_KEY=<contents-of-private-key.pem>
|
|
||||||
```
|
|
||||||
|
|
||||||
### First Run
|
### First Run
|
||||||
1. Visit Clinch at `http://localhost:3000` (or your configured domain)
|
1. Visit Clinch at `http://localhost:3000` (or your configured domain)
|
||||||
@@ -556,12 +732,30 @@ bin/bundler-audit check --update # Dependency vulnerability scan
|
|||||||
bin/importmap audit # JavaScript dependency 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:**
|
**CI/CD Integration:**
|
||||||
All security scans run automatically on every pull request and push to main via GitHub Actions.
|
All security scans run automatically on every pull request and push to main via GitHub Actions.
|
||||||
|
|
||||||
**Security Tools:**
|
**Security Tools:**
|
||||||
- **Brakeman** - Static analysis for Rails security vulnerabilities
|
- **Brakeman** - Static analysis for Rails security vulnerabilities
|
||||||
- **bundler-audit** - Checks gems for known CVEs
|
- **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
|
- **SimpleCov** - Code coverage tracking
|
||||||
- **RuboCop** - Code style and quality enforcement
|
- **RuboCop** - Code style and quality enforcement
|
||||||
|
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ class ActiveSessionsController < ApplicationController
|
|||||||
Rails.logger.info "ActiveSessionsController: Logged out from #{application.name} - revoked #{revoked_access_tokens} access tokens and #{revoked_refresh_tokens} refresh tokens"
|
Rails.logger.info "ActiveSessionsController: Logged out from #{application.name} - revoked #{revoked_access_tokens} access tokens and #{revoked_refresh_tokens} refresh tokens"
|
||||||
|
|
||||||
# Keep the consent intact - this is the key difference from revoke_consent
|
# Keep the consent intact - this is the key difference from revoke_consent
|
||||||
redirect_to root_path, notice: "Successfully logged out of #{application.name}."
|
redirect_to root_path, notice: "Revoked access tokens for #{application.name}. Re-authentication will be required on next use."
|
||||||
end
|
end
|
||||||
|
|
||||||
def revoke_all_consents
|
def revoke_all_consents
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ module Admin
|
|||||||
permitted = params.require(:application).permit(
|
permitted = params.require(:application).permit(
|
||||||
:name, :slug, :app_type, :active, :redirect_uris, :description, :metadata,
|
:name, :slug, :app_type, :active, :redirect_uris, :description, :metadata,
|
||||||
:domain_pattern, :landing_url, :access_token_ttl, :refresh_token_ttl, :id_token_ttl,
|
:domain_pattern, :landing_url, :access_token_ttl, :refresh_token_ttl, :id_token_ttl,
|
||||||
:icon, :backchannel_logout_uri, :is_public_client, :require_pkce
|
:icon, :backchannel_logout_uri, :is_public_client, :require_pkce, :skip_consent
|
||||||
)
|
)
|
||||||
|
|
||||||
# Handle headers_config - it comes as a JSON string from the text area
|
# Handle headers_config - it comes as a JSON string from the text area
|
||||||
|
|||||||
@@ -88,6 +88,8 @@ module Api
|
|||||||
case key
|
case key
|
||||||
when :user, :email, :name
|
when :user, :email, :name
|
||||||
[header_name, user.email_address]
|
[header_name, user.email_address]
|
||||||
|
when :username
|
||||||
|
[header_name, user.username] if user.username.present?
|
||||||
when :groups
|
when :groups
|
||||||
user.groups.any? ? [header_name, user.groups.pluck(:name).join(",")] : nil
|
user.groups.any? ? [header_name, user.groups.pluck(:name).join(",")] : nil
|
||||||
when :admin
|
when :admin
|
||||||
|
|||||||
@@ -9,4 +9,33 @@ class ApplicationController < ActionController::Base
|
|||||||
|
|
||||||
# CSRF protection
|
# CSRF protection
|
||||||
protect_from_forgery with: :exception
|
protect_from_forgery with: :exception
|
||||||
|
|
||||||
|
helper_method :remove_query_param
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
# Remove a query parameter from a URL using proper URI parsing
|
||||||
|
# More robust than regex - handles URL encoding, edge cases, etc.
|
||||||
|
#
|
||||||
|
# @param url [String] The URL to modify
|
||||||
|
# @param param_name [String] The query parameter name to remove
|
||||||
|
# @return [String] The URL with the parameter removed
|
||||||
|
#
|
||||||
|
# @example
|
||||||
|
# remove_query_param("https://example.com?foo=bar&baz=qux", "foo")
|
||||||
|
# # => "https://example.com?baz=qux"
|
||||||
|
def remove_query_param(url, param_name)
|
||||||
|
uri = URI.parse(url)
|
||||||
|
return url unless uri.query
|
||||||
|
|
||||||
|
# Parse query string into hash
|
||||||
|
params = CGI.parse(uri.query)
|
||||||
|
params.delete(param_name)
|
||||||
|
|
||||||
|
# Rebuild query string (empty string if no params left)
|
||||||
|
uri.query = params.any? ? URI.encode_www_form(params) : nil
|
||||||
|
uri.to_s
|
||||||
|
rescue URI::InvalidURIError
|
||||||
|
url
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ module Authentication
|
|||||||
end
|
end
|
||||||
|
|
||||||
def after_authentication_url
|
def after_authentication_url
|
||||||
session[:return_to_after_authenticating]
|
|
||||||
session.delete(:return_to_after_authenticating) || root_url
|
session.delete(:return_to_after_authenticating) || root_url
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -52,12 +51,24 @@ module Authentication
|
|||||||
# Extract root domain for cross-subdomain cookies (required for forward auth)
|
# Extract root domain for cross-subdomain cookies (required for forward auth)
|
||||||
domain = extract_root_domain(request.host)
|
domain = extract_root_domain(request.host)
|
||||||
|
|
||||||
cookie_options = {
|
# Set cookie options based on environment
|
||||||
value: session.id,
|
# Production: Use SameSite=None to allow cross-site cookies (needed for OIDC conformance testing)
|
||||||
httponly: true,
|
# Development: Use SameSite=Lax since HTTPS might not be available
|
||||||
same_site: :lax,
|
cookie_options = if Rails.env.production?
|
||||||
secure: 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
|
# Set domain for cross-subdomain authentication if we can extract it
|
||||||
cookie_options[:domain] = domain if domain.present?
|
cookie_options[:domain] = domain if domain.present?
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ class InvitationsController < ApplicationController
|
|||||||
|
|
||||||
allow_unauthenticated_access
|
allow_unauthenticated_access
|
||||||
before_action :set_user_by_invitation_token, only: %i[show update]
|
before_action :set_user_by_invitation_token, only: %i[show update]
|
||||||
|
rate_limit to: 10, within: 10.minutes, only: :update, with: -> { redirect_to signin_path, alert: "Too many attempts. Try again later." }
|
||||||
|
|
||||||
def show
|
def show
|
||||||
# Show the password setup form
|
# Show the password setup form
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
class OidcController < ApplicationController
|
class OidcController < ApplicationController
|
||||||
# Discovery and JWKS endpoints are public
|
# Discovery and JWKS endpoints are public
|
||||||
allow_unauthenticated_access only: [:discovery, :jwks, :token, :revoke, :userinfo, :logout]
|
# authorize is also unauthenticated to handle prompt=none and prompt=login specially
|
||||||
skip_before_action :verify_authenticity_token, only: [:token, :revoke, :logout]
|
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 limiting to prevent brute force and abuse
|
||||||
rate_limit to: 60, within: 1.minute, only: [:token, :revoke], with: -> {
|
rate_limit to: 60, within: 1.minute, only: [:token, :revoke], with: -> {
|
||||||
@@ -30,10 +31,22 @@ class OidcController < ApplicationController
|
|||||||
id_token_signing_alg_values_supported: ["RS256"],
|
id_token_signing_alg_values_supported: ["RS256"],
|
||||||
scopes_supported: ["openid", "profile", "email", "groups", "offline_access"],
|
scopes_supported: ["openid", "profile", "email", "groups", "offline_access"],
|
||||||
token_endpoint_auth_methods_supported: ["client_secret_post", "client_secret_basic"],
|
token_endpoint_auth_methods_supported: ["client_secret_post", "client_secret_basic"],
|
||||||
claims_supported: ["sub", "email", "email_verified", "name", "preferred_username", "groups", "admin", "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"],
|
code_challenge_methods_supported: ["plain", "S256"],
|
||||||
backchannel_logout_supported: true,
|
backchannel_logout_supported: true,
|
||||||
backchannel_logout_session_supported: true
|
backchannel_logout_session_supported: true,
|
||||||
|
request_parameter_supported: false,
|
||||||
|
claims_parameter_supported: true
|
||||||
}
|
}
|
||||||
|
|
||||||
render json: config
|
render json: config
|
||||||
@@ -56,32 +69,14 @@ class OidcController < ApplicationController
|
|||||||
code_challenge = params[:code_challenge]
|
code_challenge = params[:code_challenge]
|
||||||
code_challenge_method = params[:code_challenge_method] || "plain"
|
code_challenge_method = params[:code_challenge_method] || "plain"
|
||||||
|
|
||||||
# Validate required parameters
|
# Validate client_id first (required before we can look up the application)
|
||||||
unless client_id.present? && redirect_uri.present? && response_type == "code"
|
# OAuth2 RFC 6749 Section 4.1.2.1: If client_id is missing/invalid, show error page (don't redirect)
|
||||||
error_details = []
|
unless client_id.present?
|
||||||
error_details << "client_id is required" unless client_id.present?
|
render plain: "Invalid request: client_id is required", status: :bad_request
|
||||||
error_details << "redirect_uri is required" unless redirect_uri.present?
|
|
||||||
error_details << "response_type must be 'code'" unless response_type == "code"
|
|
||||||
|
|
||||||
render plain: "Invalid request: #{error_details.join(", ")}", status: :bad_request
|
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Validate PKCE parameters if present
|
# Find the application by client_id
|
||||||
if code_challenge.present?
|
|
||||||
unless %w[plain S256].include?(code_challenge_method)
|
|
||||||
render plain: "Invalid code_challenge_method: must be 'plain' or 'S256'", status: :bad_request
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
# Validate code challenge format (base64url-encoded, 43-128 characters)
|
|
||||||
unless code_challenge.match?(/\A[A-Za-z0-9\-_]{43,128}\z/)
|
|
||||||
render plain: "Invalid code_challenge format: must be 43-128 characters of base64url encoding", status: :bad_request
|
|
||||||
return
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Find the application
|
|
||||||
@application = Application.find_by(client_id: client_id, app_type: "oidc")
|
@application = Application.find_by(client_id: client_id, app_type: "oidc")
|
||||||
unless @application
|
unless @application
|
||||||
# Log all OIDC applications for debugging
|
# Log all OIDC applications for debugging
|
||||||
@@ -99,7 +94,14 @@ class OidcController < ApplicationController
|
|||||||
return
|
return
|
||||||
end
|
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)
|
unless @application.parsed_redirect_uris.include?(redirect_uri)
|
||||||
Rails.logger.error "OAuth: Invalid request - redirect URI mismatch. Expected: #{@application.parsed_redirect_uris}, Got: #{redirect_uri}"
|
Rails.logger.error "OAuth: Invalid request - redirect URI mismatch. Expected: #{@application.parsed_redirect_uris}, Got: #{redirect_uri}"
|
||||||
|
|
||||||
@@ -114,6 +116,85 @@ class OidcController < ApplicationController
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# At this point we have a valid client_id and redirect_uri
|
||||||
|
# All subsequent errors should redirect back to the client with error parameters
|
||||||
|
# per OAuth2 RFC 6749 Section 4.1.2.1
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# Parse claims parameter (JSON string) for OIDC claims request
|
||||||
|
# Per OIDC Core §5.5: The claims parameter is a JSON object that requests
|
||||||
|
# specific claims to be returned in the id_token and/or userinfo
|
||||||
|
claims_parameter = params[:claims]
|
||||||
|
parsed_claims = parse_claims_parameter(claims_parameter) if claims_parameter.present?
|
||||||
|
|
||||||
|
# Validate claims parameter format if present
|
||||||
|
if claims_parameter.present? && parsed_claims.nil?
|
||||||
|
Rails.logger.error "OAuth: Invalid claims parameter format"
|
||||||
|
error_uri = "#{redirect_uri}?error=invalid_request"
|
||||||
|
error_uri += "&error_description=#{CGI.escape("Invalid claims parameter: must be valid JSON")}"
|
||||||
|
error_uri += "&state=#{CGI.escape(state)}" if state.present?
|
||||||
|
redirect_to error_uri, allow_other_host: true
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Validate that requested claims are covered by granted scopes
|
||||||
|
if parsed_claims.present?
|
||||||
|
validation_result = validate_claims_against_scopes(parsed_claims, requested_scopes)
|
||||||
|
unless validation_result[:valid]
|
||||||
|
Rails.logger.error "OAuth: Claims parameter requests claims not covered by scopes: #{validation_result[:errors]}"
|
||||||
|
error_uri = "#{redirect_uri}?error=invalid_scope"
|
||||||
|
error_uri += "&error_description=#{CGI.escape("Claims parameter requests claims not covered by granted scopes")}"
|
||||||
|
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)
|
# Check if application is active (now we can safely redirect with error)
|
||||||
unless @application.active?
|
unless @application.active?
|
||||||
Rails.logger.error "OAuth: Application is not active: #{@application.name}"
|
Rails.logger.error "OAuth: Application is not active: #{@application.name}"
|
||||||
@@ -125,7 +206,17 @@ class OidcController < ApplicationController
|
|||||||
|
|
||||||
# Check if user is authenticated
|
# Check if user is authenticated
|
||||||
unless 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] = {
|
session[:oauth_params] = {
|
||||||
client_id: client_id,
|
client_id: client_id,
|
||||||
redirect_uri: redirect_uri,
|
redirect_uri: redirect_uri,
|
||||||
@@ -133,12 +224,62 @@ class OidcController < ApplicationController
|
|||||||
nonce: nonce,
|
nonce: nonce,
|
||||||
scope: scope,
|
scope: scope,
|
||||||
code_challenge: code_challenge,
|
code_challenge: code_challenge,
|
||||||
code_challenge_method: code_challenge_method
|
code_challenge_method: code_challenge_method,
|
||||||
|
claims_requests: parsed_claims&.to_json
|
||||||
}
|
}
|
||||||
|
# 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"
|
redirect_to signin_path, alert: "Please sign in to continue"
|
||||||
return
|
return
|
||||||
end
|
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 = remove_query_param(request.url, "prompt")
|
||||||
|
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 the return URL in Rails session, then destroy the Session record
|
||||||
|
|
||||||
|
# Store return URL before destroying anything
|
||||||
|
# Remove max_age from return URL to prevent infinite re-auth loop
|
||||||
|
return_url = remove_query_param(request.url, "max_age")
|
||||||
|
session[:return_to_after_authenticating] = return_url
|
||||||
|
|
||||||
|
# Destroy the Session record and clear its cookie
|
||||||
|
Current.session&.destroy!
|
||||||
|
cookies.delete(:session_id)
|
||||||
|
Current.session = nil
|
||||||
|
|
||||||
|
redirect_to signin_path, alert: "Please sign in to continue"
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# Get the authenticated user
|
# Get the authenticated user
|
||||||
user = Current.session.user
|
user = Current.session.user
|
||||||
|
|
||||||
@@ -150,9 +291,41 @@ class OidcController < ApplicationController
|
|||||||
|
|
||||||
requested_scopes = scope.split(" ")
|
requested_scopes = scope.split(" ")
|
||||||
|
|
||||||
|
# Check if application is configured to skip consent
|
||||||
|
# If so, automatically create consent and proceed without showing consent screen
|
||||||
|
if @application.skip_consent?
|
||||||
|
# Create or update consent record automatically for trusted applications
|
||||||
|
consent = OidcUserConsent.find_or_initialize_by(user: user, application: @application)
|
||||||
|
consent.scopes_granted = requested_scopes.join(" ")
|
||||||
|
consent.claims_requests = parsed_claims || {}
|
||||||
|
consent.granted_at = Time.current
|
||||||
|
consent.save!
|
||||||
|
|
||||||
|
# Generate authorization code directly
|
||||||
|
auth_code = OidcAuthorizationCode.create!(
|
||||||
|
application: @application,
|
||||||
|
user: user,
|
||||||
|
redirect_uri: redirect_uri,
|
||||||
|
scope: scope,
|
||||||
|
nonce: nonce,
|
||||||
|
code_challenge: code_challenge,
|
||||||
|
code_challenge_method: code_challenge_method,
|
||||||
|
claims_requests: parsed_claims || {},
|
||||||
|
auth_time: Current.session.created_at.to_i,
|
||||||
|
acr: Current.session.acr,
|
||||||
|
expires_at: 10.minutes.from_now
|
||||||
|
)
|
||||||
|
|
||||||
|
# Redirect back to client with authorization code (plaintext)
|
||||||
|
redirect_uri = "#{redirect_uri}?code=#{auth_code.plaintext_code}"
|
||||||
|
redirect_uri += "&state=#{CGI.escape(state)}" if state.present?
|
||||||
|
redirect_to redirect_uri, allow_other_host: true
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
# Check if user has already granted consent for these scopes
|
# Check if user has already granted consent for these scopes
|
||||||
existing_consent = user.has_oidc_consent?(@application, requested_scopes)
|
existing_consent = user.has_oidc_consent?(@application, requested_scopes)
|
||||||
if existing_consent
|
if existing_consent && claims_match_consent?(parsed_claims, existing_consent)
|
||||||
# User has already consented, generate authorization code directly
|
# User has already consented, generate authorization code directly
|
||||||
auth_code = OidcAuthorizationCode.create!(
|
auth_code = OidcAuthorizationCode.create!(
|
||||||
application: @application,
|
application: @application,
|
||||||
@@ -162,6 +335,7 @@ class OidcController < ApplicationController
|
|||||||
nonce: nonce,
|
nonce: nonce,
|
||||||
code_challenge: code_challenge,
|
code_challenge: code_challenge,
|
||||||
code_challenge_method: code_challenge_method,
|
code_challenge_method: code_challenge_method,
|
||||||
|
claims_requests: parsed_claims || {},
|
||||||
auth_time: Current.session.created_at.to_i,
|
auth_time: Current.session.created_at.to_i,
|
||||||
acr: Current.session.acr,
|
acr: Current.session.acr,
|
||||||
expires_at: 10.minutes.from_now
|
expires_at: 10.minutes.from_now
|
||||||
@@ -182,7 +356,8 @@ class OidcController < ApplicationController
|
|||||||
nonce: nonce,
|
nonce: nonce,
|
||||||
scope: scope,
|
scope: scope,
|
||||||
code_challenge: code_challenge,
|
code_challenge: code_challenge,
|
||||||
code_challenge_method: code_challenge_method
|
code_challenge_method: code_challenge_method,
|
||||||
|
claims_requests: parsed_claims&.to_json
|
||||||
}
|
}
|
||||||
|
|
||||||
# Render consent page with dynamic CSP for OAuth redirect
|
# Render consent page with dynamic CSP for OAuth redirect
|
||||||
@@ -247,8 +422,15 @@ class OidcController < ApplicationController
|
|||||||
|
|
||||||
# Record user consent
|
# Record user consent
|
||||||
requested_scopes = oauth_params["scope"].split(" ")
|
requested_scopes = oauth_params["scope"].split(" ")
|
||||||
|
parsed_claims = begin
|
||||||
|
JSON.parse(oauth_params["claims_requests"])
|
||||||
|
rescue
|
||||||
|
{}
|
||||||
|
end
|
||||||
|
|
||||||
consent = OidcUserConsent.find_or_initialize_by(user: user, application: application)
|
consent = OidcUserConsent.find_or_initialize_by(user: user, application: application)
|
||||||
consent.scopes_granted = requested_scopes.join(" ")
|
consent.scopes_granted = requested_scopes.join(" ")
|
||||||
|
consent.claims_requests = parsed_claims
|
||||||
consent.granted_at = Time.current
|
consent.granted_at = Time.current
|
||||||
consent.save!
|
consent.save!
|
||||||
|
|
||||||
@@ -261,6 +443,7 @@ class OidcController < ApplicationController
|
|||||||
nonce: oauth_params["nonce"],
|
nonce: oauth_params["nonce"],
|
||||||
code_challenge: oauth_params["code_challenge"],
|
code_challenge: oauth_params["code_challenge"],
|
||||||
code_challenge_method: oauth_params["code_challenge_method"],
|
code_challenge_method: oauth_params["code_challenge_method"],
|
||||||
|
claims_requests: parsed_claims,
|
||||||
auth_time: Current.session.created_at.to_i,
|
auth_time: Current.session.created_at.to_i,
|
||||||
acr: Current.session.acr,
|
acr: Current.session.acr,
|
||||||
expires_at: 10.minutes.from_now
|
expires_at: 10.minutes.from_now
|
||||||
@@ -278,6 +461,16 @@ class OidcController < ApplicationController
|
|||||||
|
|
||||||
# POST /oauth/token
|
# POST /oauth/token
|
||||||
def token
|
def token
|
||||||
|
# Reject claims parameter - per OIDC security, claims parameter is only valid
|
||||||
|
# in authorization requests, not at the token endpoint
|
||||||
|
if params[:claims].present?
|
||||||
|
render json: {
|
||||||
|
error: "invalid_request",
|
||||||
|
error_description: "claims parameter is not allowed at the token endpoint"
|
||||||
|
}, status: :bad_request
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
grant_type = params[:grant_type]
|
grant_type = params[:grant_type]
|
||||||
|
|
||||||
case grant_type
|
case grant_type
|
||||||
@@ -419,6 +612,8 @@ class OidcController < ApplicationController
|
|||||||
|
|
||||||
# Generate ID token (JWT) with pairwise SID, at_hash, auth_time, and acr
|
# 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)
|
# auth_time and acr come from the authorization code (captured at /authorize time)
|
||||||
|
# scopes determine which claims are included (per OIDC Core spec)
|
||||||
|
# claims_requests parameter filters which claims are included
|
||||||
id_token = OidcJwtService.generate_id_token(
|
id_token = OidcJwtService.generate_id_token(
|
||||||
user,
|
user,
|
||||||
application,
|
application,
|
||||||
@@ -426,9 +621,15 @@ class OidcController < ApplicationController
|
|||||||
nonce: auth_code.nonce,
|
nonce: auth_code.nonce,
|
||||||
access_token: access_token_record.plaintext_token,
|
access_token: access_token_record.plaintext_token,
|
||||||
auth_time: auth_code.auth_time,
|
auth_time: auth_code.auth_time,
|
||||||
acr: auth_code.acr
|
acr: auth_code.acr,
|
||||||
|
scopes: auth_code.scope,
|
||||||
|
claims_requests: auth_code.parsed_claims_requests
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# RFC6749-5.1: Token endpoint MUST return Cache-Control: no-store
|
||||||
|
response.headers["Cache-Control"] = "no-store"
|
||||||
|
response.headers["Pragma"] = "no-cache"
|
||||||
|
|
||||||
# Return tokens
|
# Return tokens
|
||||||
render json: {
|
render json: {
|
||||||
access_token: access_token_record.plaintext_token, # Opaque token
|
access_token: access_token_record.plaintext_token, # Opaque token
|
||||||
@@ -547,15 +748,23 @@ class OidcController < ApplicationController
|
|||||||
|
|
||||||
# Generate new ID token (JWT with pairwise SID, at_hash, auth_time, acr; no nonce for refresh grants)
|
# 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)
|
# 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)
|
||||||
|
# claims_requests parameter filters which claims are included (from original consent)
|
||||||
id_token = OidcJwtService.generate_id_token(
|
id_token = OidcJwtService.generate_id_token(
|
||||||
user,
|
user,
|
||||||
application,
|
application,
|
||||||
consent: consent,
|
consent: consent,
|
||||||
access_token: new_access_token.plaintext_token,
|
access_token: new_access_token.plaintext_token,
|
||||||
auth_time: refresh_token_record.auth_time,
|
auth_time: refresh_token_record.auth_time,
|
||||||
acr: refresh_token_record.acr
|
acr: refresh_token_record.acr,
|
||||||
|
scopes: refresh_token_record.scope,
|
||||||
|
claims_requests: consent.parsed_claims_requests
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 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
|
# Return new tokens
|
||||||
render json: {
|
render json: {
|
||||||
access_token: new_access_token.plaintext_token, # Opaque token
|
access_token: new_access_token.plaintext_token, # Opaque token
|
||||||
@@ -569,17 +778,22 @@ class OidcController < ApplicationController
|
|||||||
render json: {error: "invalid_grant"}, status: :bad_request
|
render json: {error: "invalid_grant"}, status: :bad_request
|
||||||
end
|
end
|
||||||
|
|
||||||
# GET /oauth/userinfo
|
# GET/POST /oauth/userinfo
|
||||||
|
# OIDC Core spec: UserInfo endpoint MUST support GET, SHOULD support POST
|
||||||
def userinfo
|
def userinfo
|
||||||
# Extract access token from Authorization header
|
# Extract access token from Authorization header or POST body
|
||||||
auth_header = request.headers["Authorization"]
|
# RFC 6750: Bearer token can be in Authorization header, request body, or query string
|
||||||
unless auth_header&.start_with?("Bearer ")
|
token = if request.headers["Authorization"]&.start_with?("Bearer ")
|
||||||
|
request.headers["Authorization"].sub("Bearer ", "")
|
||||||
|
elsif request.params["access_token"].present?
|
||||||
|
request.params["access_token"]
|
||||||
|
end
|
||||||
|
|
||||||
|
unless token
|
||||||
head :unauthorized
|
head :unauthorized
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
token = auth_header.sub("Bearer ", "")
|
|
||||||
|
|
||||||
# Find and validate access token (opaque token with BCrypt hashing)
|
# Find and validate access token (opaque token with BCrypt hashing)
|
||||||
access_token = OidcAccessToken.find_by_token(token)
|
access_token = OidcAccessToken.find_by_token(token)
|
||||||
unless access_token&.active?
|
unless access_token&.active?
|
||||||
@@ -605,18 +819,48 @@ class OidcController < ApplicationController
|
|||||||
consent = OidcUserConsent.find_by(user: user, application: access_token.application)
|
consent = OidcUserConsent.find_by(user: user, application: access_token.application)
|
||||||
subject = consent&.sid || user.id.to_s
|
subject = consent&.sid || user.id.to_s
|
||||||
|
|
||||||
# Return user claims
|
# Parse scopes from access token (space-separated string)
|
||||||
|
requested_scopes = access_token.scope.to_s.split
|
||||||
|
|
||||||
|
# Get claims_requests from consent (if available) for UserInfo context
|
||||||
|
userinfo_claims = consent&.parsed_claims_requests&.dig("userinfo") || {}
|
||||||
|
|
||||||
|
# Return user claims (filter by scope per OIDC Core spec)
|
||||||
|
# Required claims (always included - cannot be filtered by claims parameter)
|
||||||
claims = {
|
claims = {
|
||||||
sub: subject,
|
sub: subject
|
||||||
email: user.email_address,
|
|
||||||
email_verified: true,
|
|
||||||
preferred_username: user.email_address,
|
|
||||||
name: user.name.presence || user.email_address
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Add groups if user has any
|
# Email claims (only if 'email' scope requested AND requested in claims parameter)
|
||||||
if user.groups.any?
|
if requested_scopes.include?("email")
|
||||||
claims[:groups] = user.groups.pluck(:name)
|
if should_include_claim_for_userinfo?("email", userinfo_claims)
|
||||||
|
claims[:email] = user.email_address
|
||||||
|
end
|
||||||
|
if should_include_claim_for_userinfo?("email_verified", userinfo_claims)
|
||||||
|
claims[:email_verified] = true
|
||||||
|
end
|
||||||
|
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")
|
||||||
|
if should_include_claim_for_userinfo?("preferred_username", userinfo_claims)
|
||||||
|
claims[:preferred_username] = user.username.presence || user.email_address
|
||||||
|
end
|
||||||
|
if should_include_claim_for_userinfo?("name", userinfo_claims)
|
||||||
|
claims[:name] = user.name.presence || user.email_address
|
||||||
|
end
|
||||||
|
if should_include_claim_for_userinfo?("updated_at", userinfo_claims)
|
||||||
|
claims[:updated_at] = user.updated_at.to_i
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Groups claim (only if 'groups' scope requested AND requested in claims parameter)
|
||||||
|
if requested_scopes.include?("groups") && user.groups.any?
|
||||||
|
if should_include_claim_for_userinfo?("groups", userinfo_claims)
|
||||||
|
claims[:groups] = user.groups.pluck(:name)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Merge custom claims from groups
|
# Merge custom claims from groups
|
||||||
@@ -631,6 +875,16 @@ class OidcController < ApplicationController
|
|||||||
application = access_token.application
|
application = access_token.application
|
||||||
claims.merge!(application.custom_claims_for_user(user))
|
claims.merge!(application.custom_claims_for_user(user))
|
||||||
|
|
||||||
|
# Filter custom claims based on claims parameter
|
||||||
|
# If claims parameter is present, only include requested custom claims
|
||||||
|
if userinfo_claims.any?
|
||||||
|
claims = filter_custom_claims_for_userinfo(claims, userinfo_claims)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Security: Don't cache user data responses
|
||||||
|
response.headers["Cache-Control"] = "no-store"
|
||||||
|
response.headers["Pragma"] = "no-cache"
|
||||||
|
|
||||||
render json: claims
|
render json: claims
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -775,12 +1029,12 @@ class OidcController < ApplicationController
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
# Validate code verifier format (base64url-encoded, 43-128 characters)
|
# 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/)
|
unless code_verifier.match?(/\A[A-Za-z0-9.\-_~]{43,128}\z/)
|
||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
error: "invalid_request",
|
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
|
status: :bad_request
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
@@ -896,4 +1150,133 @@ class OidcController < ApplicationController
|
|||||||
# Log error but don't block logout
|
# Log error but don't block logout
|
||||||
Rails.logger.error "OidcController: Failed to enqueue backchannel logout: #{e.class} - #{e.message}"
|
Rails.logger.error "OidcController: Failed to enqueue backchannel logout: #{e.class} - #{e.message}"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Parse claims parameter JSON string
|
||||||
|
# Per OIDC Core §5.5: The claims parameter is a JSON object containing
|
||||||
|
# id_token and/or userinfo keys, each mapping to claim requests
|
||||||
|
def parse_claims_parameter(claims_string)
|
||||||
|
return {} if claims_string.blank?
|
||||||
|
|
||||||
|
parsed = JSON.parse(claims_string)
|
||||||
|
return nil unless parsed.is_a?(Hash)
|
||||||
|
|
||||||
|
# Validate structure: can have id_token, userinfo, or both
|
||||||
|
valid_keys = parsed.keys & ["id_token", "userinfo"]
|
||||||
|
return nil if valid_keys.empty?
|
||||||
|
|
||||||
|
# Validate each claim request has proper structure
|
||||||
|
valid_keys.each do |key|
|
||||||
|
next unless parsed[key].is_a?(Hash)
|
||||||
|
|
||||||
|
parsed[key].each do |_claim_name, claim_spec|
|
||||||
|
# Claim spec can be null (requested), true (essential), or a hash with specific keys
|
||||||
|
next if claim_spec.nil? || claim_spec == true || claim_spec == false
|
||||||
|
next if claim_spec.is_a?(Hash) && claim_spec.keys.all? { |k| ["essential", "value", "values"].include?(k) }
|
||||||
|
|
||||||
|
# Invalid claim specification
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
parsed
|
||||||
|
rescue JSON::ParserError
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
# Validate that requested claims are covered by granted scopes
|
||||||
|
# Per OIDC Core §5.5: Claims can only be requested if the corresponding scope is granted
|
||||||
|
def validate_claims_against_scopes(parsed_claims, granted_scopes)
|
||||||
|
granted = Array(granted_scopes).map(&:to_s)
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# Standard claim-to-scope mapping
|
||||||
|
claim_scope_mapping = {
|
||||||
|
"email" => "email",
|
||||||
|
"email_verified" => "email",
|
||||||
|
"preferred_username" => "profile",
|
||||||
|
"name" => "profile",
|
||||||
|
"updated_at" => "profile",
|
||||||
|
"groups" => "groups"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check both id_token and userinfo claims
|
||||||
|
["id_token", "userinfo"].each do |context|
|
||||||
|
next unless parsed_claims[context]&.is_a?(Hash)
|
||||||
|
|
||||||
|
parsed_claims[context].each do |claim_name, _claim_spec|
|
||||||
|
# Skip custom claims (not in standard mapping)
|
||||||
|
# Custom claims are allowed since they're configured in the IdP
|
||||||
|
next unless claim_scope_mapping.key?(claim_name)
|
||||||
|
|
||||||
|
required_scope = claim_scope_mapping[claim_name]
|
||||||
|
unless granted.include?(required_scope)
|
||||||
|
errors << "#{claim_name} requires #{required_scope} scope"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if errors.any?
|
||||||
|
{valid: false, errors: errors}
|
||||||
|
else
|
||||||
|
{valid: true}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if claims match existing consent
|
||||||
|
# For MVP: treat any claims request as requiring new consent if consent has no claims stored
|
||||||
|
def claims_match_consent?(parsed_claims, consent)
|
||||||
|
return true if parsed_claims.nil? || parsed_claims.empty?
|
||||||
|
|
||||||
|
# If consent has no claims stored, this is a new claims request
|
||||||
|
# Require fresh consent
|
||||||
|
return false if consent.parsed_claims_requests.empty?
|
||||||
|
|
||||||
|
# If both have claims, they must match exactly
|
||||||
|
consent.parsed_claims_requests == parsed_claims
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if a claim should be included in UserInfo response
|
||||||
|
# Returns true if no claims filtering or claim is explicitly requested
|
||||||
|
def should_include_claim_for_userinfo?(claim_name, userinfo_claims)
|
||||||
|
return true if userinfo_claims.empty?
|
||||||
|
userinfo_claims.key?(claim_name)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Filter custom claims for UserInfo endpoint
|
||||||
|
# Removes claims not explicitly requested
|
||||||
|
# Applies value/values filtering if specified
|
||||||
|
def filter_custom_claims_for_userinfo(claims, userinfo_claims)
|
||||||
|
# Get all claim names that are NOT standard OIDC claims
|
||||||
|
standard_claims = %w[sub email email_verified name preferred_username updated_at groups]
|
||||||
|
custom_claim_names = claims.keys.map(&:to_s) - standard_claims
|
||||||
|
|
||||||
|
filtered = claims.dup
|
||||||
|
|
||||||
|
custom_claim_names.each do |claim_name|
|
||||||
|
claim_sym = claim_name.to_sym
|
||||||
|
|
||||||
|
unless userinfo_claims.key?(claim_name) || userinfo_claims.key?(claim_sym)
|
||||||
|
filtered.delete(claim_sym)
|
||||||
|
next
|
||||||
|
end
|
||||||
|
|
||||||
|
# Apply value/values filtering if specified
|
||||||
|
claim_spec = userinfo_claims[claim_name] || userinfo_claims[claim_sym]
|
||||||
|
next unless claim_spec.is_a?(Hash)
|
||||||
|
|
||||||
|
current_value = filtered[claim_sym]
|
||||||
|
|
||||||
|
# Check value constraint
|
||||||
|
if claim_spec["value"].present?
|
||||||
|
filtered.delete(claim_sym) unless current_value == claim_spec["value"]
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check values constraint (array of allowed values)
|
||||||
|
if claim_spec["values"].is_a?(Array)
|
||||||
|
filtered.delete(claim_sym) unless claim_spec["values"].include?(current_value)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
filtered
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ class PasswordsController < ApplicationController
|
|||||||
allow_unauthenticated_access
|
allow_unauthenticated_access
|
||||||
before_action :set_user_by_token, only: %i[edit update]
|
before_action :set_user_by_token, only: %i[edit update]
|
||||||
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_password_path, alert: "Try again later." }
|
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_password_path, alert: "Try again later." }
|
||||||
|
rate_limit to: 10, within: 10.minutes, only: :update, with: -> { redirect_to new_password_path, alert: "Too many attempts. Try again later." }
|
||||||
|
|
||||||
def new
|
def new
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -14,6 +14,20 @@ class SessionsController < ApplicationController
|
|||||||
return
|
return
|
||||||
end
|
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|
|
respond_to do |format|
|
||||||
format.html # render HTML login page
|
format.html # render HTML login page
|
||||||
format.json { render json: {error: "Authentication required"}, status: :unauthorized }
|
format.json { render json: {error: "Authentication required"}, status: :unauthorized }
|
||||||
@@ -73,7 +87,10 @@ class SessionsController < ApplicationController
|
|||||||
|
|
||||||
# Sign in successful (password only)
|
# Sign in successful (password only)
|
||||||
start_new_session_for user, acr: "1"
|
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
|
end
|
||||||
|
|
||||||
def verify_totp
|
def verify_totp
|
||||||
|
|||||||
45
app/lib/duration_parser.rb
Normal file
45
app/lib/duration_parser.rb
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
class DurationParser
|
||||||
|
UNITS = {
|
||||||
|
"s" => 1, # seconds
|
||||||
|
"m" => 60, # minutes
|
||||||
|
"h" => 3600, # hours
|
||||||
|
"d" => 86400, # days
|
||||||
|
"w" => 604800, # weeks
|
||||||
|
"M" => 2592000, # months (30 days)
|
||||||
|
"y" => 31536000 # years (365 days)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Parse a duration string into seconds
|
||||||
|
# Accepts formats: "1h", "30m", "1d", "1M" (month), "3600" (plain number)
|
||||||
|
# Returns integer seconds or nil if invalid
|
||||||
|
# Case-sensitive: 1s, 1m, 1h, 1d, 1w, 1M (month), 1y
|
||||||
|
def self.parse(input)
|
||||||
|
# Handle integers directly
|
||||||
|
return input if input.is_a?(Integer)
|
||||||
|
|
||||||
|
# Convert to string and strip whitespace
|
||||||
|
str = input.to_s.strip
|
||||||
|
|
||||||
|
# Return nil for blank input
|
||||||
|
return nil if str.blank?
|
||||||
|
|
||||||
|
# Try to parse as plain number (already in seconds)
|
||||||
|
if str.match?(/^\d+$/)
|
||||||
|
return str.to_i
|
||||||
|
end
|
||||||
|
|
||||||
|
# Try to parse with unit (e.g., "1h", "30m", "1M")
|
||||||
|
# Allow optional space between number and unit
|
||||||
|
# Case-sensitive to avoid confusion (1m = minute, 1M = month)
|
||||||
|
match = str.match(/^(\d+)\s*([smhdwMy])$/)
|
||||||
|
return nil unless match
|
||||||
|
|
||||||
|
number = match[1].to_i
|
||||||
|
unit = match[2]
|
||||||
|
|
||||||
|
multiplier = UNITS[unit]
|
||||||
|
return nil unless multiplier
|
||||||
|
|
||||||
|
number * multiplier
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -5,6 +5,23 @@ class Application < ApplicationRecord
|
|||||||
# When true, no client_secret will be generated (public client)
|
# When true, no client_secret will be generated (public client)
|
||||||
attr_accessor :is_public_client
|
attr_accessor :is_public_client
|
||||||
|
|
||||||
|
# Virtual setters for TTL fields - accept human-friendly durations
|
||||||
|
# e.g., "1h", "30m", "1d", or plain numbers "3600"
|
||||||
|
def access_token_ttl=(value)
|
||||||
|
parsed = DurationParser.parse(value)
|
||||||
|
super(parsed)
|
||||||
|
end
|
||||||
|
|
||||||
|
def refresh_token_ttl=(value)
|
||||||
|
parsed = DurationParser.parse(value)
|
||||||
|
super(parsed)
|
||||||
|
end
|
||||||
|
|
||||||
|
def id_token_ttl=(value)
|
||||||
|
parsed = DurationParser.parse(value)
|
||||||
|
super(parsed)
|
||||||
|
end
|
||||||
|
|
||||||
has_one_attached :icon
|
has_one_attached :icon
|
||||||
|
|
||||||
# Fix SVG content type after attachment
|
# Fix SVG content type after attachment
|
||||||
@@ -39,7 +56,7 @@ class Application < ApplicationRecord
|
|||||||
|
|
||||||
# Token TTL validations (for OIDC apps)
|
# Token TTL validations (for OIDC apps)
|
||||||
validates :access_token_ttl, numericality: {greater_than_or_equal_to: 300, less_than_or_equal_to: 86400}, if: :oidc? # 5 min - 24 hours
|
validates :access_token_ttl, numericality: {greater_than_or_equal_to: 300, less_than_or_equal_to: 86400}, if: :oidc? # 5 min - 24 hours
|
||||||
validates :refresh_token_ttl, numericality: {greater_than_or_equal_to: 86400, less_than_or_equal_to: 7776000}, if: :oidc? # 1 day - 90 days
|
validates :refresh_token_ttl, numericality: {greater_than_or_equal_to: 300, less_than_or_equal_to: 7776000}, if: :oidc? # 5 min - 90 days
|
||||||
validates :id_token_ttl, numericality: {greater_than_or_equal_to: 300, less_than_or_equal_to: 86400}, if: :oidc? # 5 min - 24 hours
|
validates :id_token_ttl, numericality: {greater_than_or_equal_to: 300, less_than_or_equal_to: 86400}, if: :oidc? # 5 min - 24 hours
|
||||||
|
|
||||||
normalizes :slug, with: ->(slug) { slug.strip.downcase }
|
normalizes :slug, with: ->(slug) { slug.strip.downcase }
|
||||||
@@ -59,6 +76,7 @@ class Application < ApplicationRecord
|
|||||||
user: "X-Remote-User",
|
user: "X-Remote-User",
|
||||||
email: "X-Remote-Email",
|
email: "X-Remote-Email",
|
||||||
name: "X-Remote-Name",
|
name: "X-Remote-Name",
|
||||||
|
username: "X-Remote-Username",
|
||||||
groups: "X-Remote-Groups",
|
groups: "X-Remote-Groups",
|
||||||
admin: "X-Remote-Admin"
|
admin: "X-Remote-Admin"
|
||||||
}.freeze
|
}.freeze
|
||||||
@@ -178,6 +196,8 @@ class Application < ApplicationRecord
|
|||||||
headers[header_name] = user.email_address
|
headers[header_name] = user.email_address
|
||||||
when :name
|
when :name
|
||||||
headers[header_name] = user.name.presence || user.email_address
|
headers[header_name] = user.name.presence || user.email_address
|
||||||
|
when :username
|
||||||
|
headers[header_name] = user.username if user.username.present?
|
||||||
when :groups
|
when :groups
|
||||||
headers[header_name] = user.groups.pluck(:name).join(",") if user.groups.any?
|
headers[header_name] = user.groups.pluck(:name).join(",") if user.groups.any?
|
||||||
when :admin
|
when :admin
|
||||||
|
|||||||
@@ -44,6 +44,12 @@ class OidcAuthorizationCode < ApplicationRecord
|
|||||||
code_challenge.present?
|
code_challenge.present?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Parse claims_requests JSON field
|
||||||
|
def parsed_claims_requests
|
||||||
|
return {} if claims_requests.blank?
|
||||||
|
claims_requests.is_a?(Hash) ? claims_requests : {}
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def generate_code
|
def generate_code
|
||||||
|
|||||||
@@ -50,6 +50,12 @@ class OidcUserConsent < ApplicationRecord
|
|||||||
find_by(sid: sid)
|
find_by(sid: sid)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Parse claims_requests JSON field
|
||||||
|
def parsed_claims_requests
|
||||||
|
return {} if claims_requests.blank?
|
||||||
|
claims_requests.is_a?(Hash) ? claims_requests : {}
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def set_granted_at
|
def set_granted_at
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
class WebauthnCredential < ApplicationRecord
|
class WebauthnCredential < ApplicationRecord
|
||||||
belongs_to :user
|
belongs_to :user
|
||||||
|
|
||||||
|
# Set default authenticator_type if not provided
|
||||||
|
after_initialize :set_default_authenticator_type, if: :new_record?
|
||||||
|
|
||||||
# Validations
|
# Validations
|
||||||
validates :external_id, presence: true, uniqueness: true
|
validates :external_id, presence: true, uniqueness: true
|
||||||
validates :public_key, presence: true
|
validates :public_key, presence: true
|
||||||
@@ -77,6 +80,10 @@ class WebauthnCredential < ApplicationRecord
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def set_default_authenticator_type
|
||||||
|
self.authenticator_type ||= "cross-platform"
|
||||||
|
end
|
||||||
|
|
||||||
def time_ago_in_words(time)
|
def time_ago_in_words(time)
|
||||||
seconds = Time.current - time
|
seconds = Time.current - time
|
||||||
minutes = seconds / 60
|
minutes = seconds / 60
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ class OidcJwtService
|
|||||||
|
|
||||||
class << self
|
class << self
|
||||||
# Generate an ID token (JWT) for the user
|
# Generate an ID token (JWT) for the user
|
||||||
def generate_id_token(user, application, consent: nil, nonce: nil, 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", claims_requests: {})
|
||||||
now = Time.current.to_i
|
now = Time.current.to_i
|
||||||
# Use application's configured ID token TTL (defaults to 1 hour)
|
# Use application's configured ID token TTL (defaults to 1 hour)
|
||||||
ttl = application.id_token_expiry_seconds
|
ttl = application.id_token_expiry_seconds
|
||||||
@@ -11,18 +11,44 @@ class OidcJwtService
|
|||||||
# Use pairwise SID from consent if available, fallback to user ID
|
# Use pairwise SID from consent if available, fallback to user ID
|
||||||
subject = consent&.sid || user.id.to_s
|
subject = consent&.sid || user.id.to_s
|
||||||
|
|
||||||
|
# Parse scopes (space-separated string)
|
||||||
|
requested_scopes = scopes.to_s.split
|
||||||
|
|
||||||
|
# Parse claims_requests parameter for id_token context
|
||||||
|
id_token_claims = claims_requests["id_token"] || {}
|
||||||
|
|
||||||
|
# Required claims (always included per OIDC Core spec)
|
||||||
payload = {
|
payload = {
|
||||||
iss: issuer_url,
|
iss: issuer_url,
|
||||||
sub: subject,
|
sub: subject,
|
||||||
aud: application.client_id,
|
aud: application.client_id,
|
||||||
exp: now + ttl,
|
exp: now + ttl,
|
||||||
iat: now,
|
iat: now
|
||||||
email: user.email_address,
|
|
||||||
email_verified: true,
|
|
||||||
preferred_username: user.username.presence || user.email_address,
|
|
||||||
name: user.name.presence || user.email_address
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Email claims (only if 'email' scope requested AND either no claims filter OR email requested)
|
||||||
|
if requested_scopes.include?("email")
|
||||||
|
if should_include_claim?("email", id_token_claims)
|
||||||
|
payload[:email] = user.email_address
|
||||||
|
end
|
||||||
|
if should_include_claim?("email_verified", id_token_claims)
|
||||||
|
payload[:email_verified] = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Profile claims (only if 'profile' scope requested)
|
||||||
|
if requested_scopes.include?("profile")
|
||||||
|
if should_include_claim?("preferred_username", id_token_claims)
|
||||||
|
payload[:preferred_username] = user.username.presence || user.email_address
|
||||||
|
end
|
||||||
|
if should_include_claim?("name", id_token_claims)
|
||||||
|
payload[:name] = user.name.presence || user.email_address
|
||||||
|
end
|
||||||
|
if should_include_claim?("updated_at", id_token_claims)
|
||||||
|
payload[:updated_at] = user.updated_at.to_i
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# Add nonce if provided (OIDC requires this for implicit flow)
|
# Add nonce if provided (OIDC requires this for implicit flow)
|
||||||
payload[:nonce] = nonce if nonce.present?
|
payload[:nonce] = nonce if nonce.present?
|
||||||
|
|
||||||
@@ -44,12 +70,15 @@ class OidcJwtService
|
|||||||
payload[:at_hash] = at_hash
|
payload[:at_hash] = at_hash
|
||||||
end
|
end
|
||||||
|
|
||||||
# Add groups if user has any
|
# Groups claims (only if 'groups' scope requested AND requested in claims parameter)
|
||||||
if user.groups.any?
|
if requested_scopes.include?("groups") && user.groups.any?
|
||||||
payload[:groups] = user.groups.pluck(:name)
|
if should_include_claim?("groups", id_token_claims)
|
||||||
|
payload[:groups] = user.groups.pluck(:name)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Merge custom claims from groups (arrays are combined, not overwritten)
|
# Merge custom claims from groups (arrays are combined, not overwritten)
|
||||||
|
# Note: Custom claims from groups are always merged (not scope-dependent)
|
||||||
user.groups.each do |group|
|
user.groups.each do |group|
|
||||||
payload = deep_merge_claims(payload, group.parsed_custom_claims)
|
payload = deep_merge_claims(payload, group.parsed_custom_claims)
|
||||||
end
|
end
|
||||||
@@ -60,6 +89,12 @@ class OidcJwtService
|
|||||||
# Merge app-specific custom claims (highest priority, arrays are combined)
|
# Merge app-specific custom claims (highest priority, arrays are combined)
|
||||||
payload = deep_merge_claims(payload, application.custom_claims_for_user(user))
|
payload = deep_merge_claims(payload, application.custom_claims_for_user(user))
|
||||||
|
|
||||||
|
# Filter custom claims based on claims parameter
|
||||||
|
# If claims parameter is present, only include requested custom claims
|
||||||
|
if id_token_claims.any?
|
||||||
|
payload = filter_custom_claims(payload, id_token_claims)
|
||||||
|
end
|
||||||
|
|
||||||
JWT.encode(payload, private_key, "RS256", {kid: key_id, typ: "JWT"})
|
JWT.encode(payload, private_key, "RS256", {kid: key_id, typ: "JWT"})
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -172,5 +207,69 @@ class OidcJwtService
|
|||||||
def key_id
|
def key_id
|
||||||
@key_id ||= Digest::SHA256.hexdigest(public_key.to_pem)[0..15]
|
@key_id ||= Digest::SHA256.hexdigest(public_key.to_pem)[0..15]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Check if a claim should be included based on claims parameter
|
||||||
|
# Returns true if:
|
||||||
|
# - No claims parameter specified (include all scope-based claims)
|
||||||
|
# - Claim is explicitly requested (even with null spec or essential: true)
|
||||||
|
def should_include_claim?(claim_name, id_token_claims)
|
||||||
|
# No claims parameter = include all scope-based claims
|
||||||
|
return true if id_token_claims.empty?
|
||||||
|
|
||||||
|
# Check if claim is requested
|
||||||
|
return false unless id_token_claims.key?(claim_name)
|
||||||
|
|
||||||
|
# Claim specification can be:
|
||||||
|
# - null (requested)
|
||||||
|
# - true (essential, requested)
|
||||||
|
# - false (not requested)
|
||||||
|
# - Hash with essential/value/values
|
||||||
|
|
||||||
|
claim_spec = id_token_claims[claim_name]
|
||||||
|
return true if claim_spec.nil? || claim_spec == true
|
||||||
|
return false if claim_spec == false
|
||||||
|
|
||||||
|
# If it's a hash, the claim is requested (filtering happens later)
|
||||||
|
true if claim_spec.is_a?(Hash)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Filter custom claims based on claims parameter
|
||||||
|
# Removes claims not explicitly requested
|
||||||
|
# Applies value/values filtering if specified
|
||||||
|
def filter_custom_claims(payload, id_token_claims)
|
||||||
|
# Get all claim names that are NOT standard OIDC claims
|
||||||
|
standard_claims = %w[iss sub aud exp iat nbf jti nonce azp at_hash auth_time acr email email_verified name preferred_username updated_at groups]
|
||||||
|
custom_claim_names = payload.keys.map(&:to_s) - standard_claims
|
||||||
|
|
||||||
|
filtered = payload.dup
|
||||||
|
|
||||||
|
custom_claim_names.each do |claim_name|
|
||||||
|
claim_sym = claim_name.to_sym
|
||||||
|
|
||||||
|
# If claim is not requested, remove it
|
||||||
|
unless id_token_claims.key?(claim_name) || id_token_claims.key?(claim_sym)
|
||||||
|
filtered.delete(claim_sym)
|
||||||
|
next
|
||||||
|
end
|
||||||
|
|
||||||
|
# Apply value/values filtering if specified
|
||||||
|
claim_spec = id_token_claims[claim_name] || id_token_claims[claim_sym]
|
||||||
|
next unless claim_spec.is_a?(Hash)
|
||||||
|
|
||||||
|
current_value = filtered[claim_sym]
|
||||||
|
|
||||||
|
# Check value constraint
|
||||||
|
if claim_spec["value"].present?
|
||||||
|
filtered.delete(claim_sym) unless current_value == claim_spec["value"]
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check values constraint (array of allowed values)
|
||||||
|
if claim_spec["values"].is_a?(Array)
|
||||||
|
filtered.delete(claim_sym) unless claim_spec["values"].include?(current_value)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
filtered
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -153,6 +153,26 @@
|
|||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
|
<!-- OAuth2/OIDC Flow Information -->
|
||||||
|
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 space-y-3">
|
||||||
|
<div>
|
||||||
|
<h4 class="text-sm font-semibold text-gray-900 mb-2">OAuth2 Flow</h4>
|
||||||
|
<p class="text-sm text-gray-700">
|
||||||
|
Clinch uses the <code class="bg-white px-1.5 py-0.5 rounded text-xs font-mono">authorization_code</code> flow with <code class="bg-white px-1.5 py-0.5 rounded text-xs font-mono">response_type=code</code> (the modern, secure standard).
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-600 mt-1">
|
||||||
|
Deprecated flows like Implicit (<code class="bg-white px-1 rounded text-xs font-mono">id_token</code>, <code class="bg-white px-1 rounded text-xs font-mono">token</code>) are not supported for security reasons.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-t border-blue-200 pt-3">
|
||||||
|
<h4 class="text-sm font-semibold text-gray-900 mb-2">Client Authentication</h4>
|
||||||
|
<p class="text-sm text-gray-700">
|
||||||
|
Clinch supports both <code class="bg-white px-1.5 py-0.5 rounded text-xs font-mono">client_secret_basic</code> (HTTP Basic Auth) and <code class="bg-white px-1.5 py-0.5 rounded text-xs font-mono">client_secret_post</code> (POST parameters) authentication methods.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- PKCE Requirement (only for confidential clients) -->
|
<!-- PKCE Requirement (only for confidential clients) -->
|
||||||
<div id="pkce-options" data-application-form-target="pkceOptions" class="<%= 'hidden' if application.persisted? && application.public_client? %>">
|
<div id="pkce-options" data-application-form-target="pkceOptions" class="<%= 'hidden' if application.persisted? && application.public_client? %>">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
@@ -165,6 +185,16 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Skip Consent -->
|
||||||
|
<div class="flex items-center">
|
||||||
|
<%= form.check_box :skip_consent, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %>
|
||||||
|
<%= form.label :skip_consent, "Skip Consent Screen", class: "ml-2 block text-sm font-medium text-gray-900" %>
|
||||||
|
</div>
|
||||||
|
<p class="ml-6 text-sm text-gray-500">
|
||||||
|
Automatically grant consent for all users. Useful for first-party or trusted applications.
|
||||||
|
<br><span class="text-xs text-amber-600">Only enable for applications you fully trust. Consent is still recorded in the database.</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<%= form.label :redirect_uris, "Redirect URIs", class: "block text-sm font-medium text-gray-700" %>
|
<%= form.label :redirect_uris, "Redirect URIs", class: "block text-sm font-medium text-gray-700" %>
|
||||||
<%= form.text_area :redirect_uris, rows: 4, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono", placeholder: "https://example.com/callback\nhttps://app.example.com/auth/callback" %>
|
<%= form.text_area :redirect_uris, rows: 4, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono", placeholder: "https://example.com/callback\nhttps://app.example.com/auth/callback" %>
|
||||||
@@ -187,43 +217,90 @@
|
|||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<%= form.label :access_token_ttl, "Access Token TTL (seconds)", class: "block text-sm font-medium text-gray-700" %>
|
<%= form.label :access_token_ttl, "Access Token TTL", class: "block text-sm font-medium text-gray-700" %>
|
||||||
<%= form.number_field :access_token_ttl, value: application.access_token_ttl || 3600, min: 300, max: 86400, step: 60, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
<%= form.text_field :access_token_ttl,
|
||||||
|
value: application.access_token_ttl || "1h",
|
||||||
|
placeholder: "e.g., 1h, 30m, 3600",
|
||||||
|
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono" %>
|
||||||
<p class="mt-1 text-xs text-gray-500">
|
<p class="mt-1 text-xs text-gray-500">
|
||||||
Range: 5 min - 24 hours
|
Range: 5m - 24h
|
||||||
<br>Default: 1 hour (3600s)
|
<br>Default: 1h
|
||||||
<br>Current: <span class="font-medium"><%= application.access_token_ttl_human || "1 hour" %></span>
|
<% if application.access_token_ttl.present? %>
|
||||||
|
<br>Current: <span class="font-medium"><%= application.access_token_ttl_human %> (<%= application.access_token_ttl %>s)</span>
|
||||||
|
<% end %>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<%= form.label :refresh_token_ttl, "Refresh Token TTL (seconds)", class: "block text-sm font-medium text-gray-700" %>
|
<%= form.label :refresh_token_ttl, "Refresh Token TTL", class: "block text-sm font-medium text-gray-700" %>
|
||||||
<%= form.number_field :refresh_token_ttl, value: application.refresh_token_ttl || 2592000, min: 86400, max: 7776000, step: 86400, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
<%= form.text_field :refresh_token_ttl,
|
||||||
|
value: application.refresh_token_ttl || "30d",
|
||||||
|
placeholder: "e.g., 30d, 1M, 2592000",
|
||||||
|
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono" %>
|
||||||
<p class="mt-1 text-xs text-gray-500">
|
<p class="mt-1 text-xs text-gray-500">
|
||||||
Range: 1 day - 90 days
|
Range: 5m - 90d
|
||||||
<br>Default: 30 days (2592000s)
|
<br>Default: 30d
|
||||||
<br>Current: <span class="font-medium"><%= application.refresh_token_ttl_human || "30 days" %></span>
|
<% if application.refresh_token_ttl.present? %>
|
||||||
|
<br>Current: <span class="font-medium"><%= application.refresh_token_ttl_human %> (<%= application.refresh_token_ttl %>s)</span>
|
||||||
|
<% end %>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<%= form.label :id_token_ttl, "ID Token TTL (seconds)", class: "block text-sm font-medium text-gray-700" %>
|
<%= form.label :id_token_ttl, "ID Token TTL", class: "block text-sm font-medium text-gray-700" %>
|
||||||
<%= form.number_field :id_token_ttl, value: application.id_token_ttl || 3600, min: 300, max: 86400, step: 60, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
<%= form.text_field :id_token_ttl,
|
||||||
|
value: application.id_token_ttl || "1h",
|
||||||
|
placeholder: "e.g., 1h, 30m, 3600",
|
||||||
|
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono" %>
|
||||||
<p class="mt-1 text-xs text-gray-500">
|
<p class="mt-1 text-xs text-gray-500">
|
||||||
Range: 5 min - 24 hours
|
Range: 5m - 24h
|
||||||
<br>Default: 1 hour (3600s)
|
<br>Default: 1h
|
||||||
<br>Current: <span class="font-medium"><%= application.id_token_ttl_human || "1 hour" %></span>
|
<% if application.id_token_ttl.present? %>
|
||||||
|
<br>Current: <span class="font-medium"><%= application.id_token_ttl_human %> (<%= application.id_token_ttl %>s)</span>
|
||||||
|
<% end %>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<details class="mt-3">
|
<details class="mt-3">
|
||||||
<summary class="cursor-pointer text-sm text-blue-600 hover:text-blue-800">Understanding Token Types</summary>
|
<summary class="cursor-pointer text-sm text-blue-600 hover:text-blue-800">Understanding Token Types & Session Length</summary>
|
||||||
<div class="mt-2 ml-4 space-y-2 text-sm text-gray-600">
|
<div class="mt-2 ml-4 space-y-3 text-sm text-gray-600">
|
||||||
<p><strong>Access Token:</strong> Used to access protected resources (APIs). Shorter lifetime = more secure. Users won't notice automatic refreshes.</p>
|
<div>
|
||||||
<p><strong>Refresh Token:</strong> Used to get new access tokens without re-authentication. Longer lifetime = better UX (less re-logins).</p>
|
<p class="font-medium text-gray-900 mb-1">Token Types:</p>
|
||||||
<p><strong>ID Token:</strong> Contains user identity information (JWT). Should match access token lifetime in most cases.</p>
|
<p><strong>Access Token:</strong> Used to access protected resources (APIs). Shorter lifetime = more secure. Users won't notice automatic refreshes.</p>
|
||||||
<p class="text-xs italic mt-2">💡 Tip: Banking apps use 5-15 min access tokens. Internal tools use 1-4 hours.</p>
|
<p><strong>Refresh Token:</strong> Used to get new access tokens without re-authentication. Each refresh issues a new refresh token (token rotation).</p>
|
||||||
|
<p><strong>ID Token:</strong> Contains user identity information (JWT). Should match access token lifetime in most cases.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-t border-gray-200 pt-2">
|
||||||
|
<p class="font-medium text-gray-900 mb-1">How Session Length Works:</p>
|
||||||
|
<p><strong>Refresh Token TTL = Maximum Inactivity Period</strong></p>
|
||||||
|
<p class="ml-3">Because refresh tokens are automatically rotated (new token = new expiry), active users can stay logged in indefinitely. The TTL controls how long they can be <em>inactive</em> before requiring re-authentication.</p>
|
||||||
|
|
||||||
|
<p class="mt-2"><strong>Example:</strong> Refresh TTL = 30 days</p>
|
||||||
|
<ul class="ml-6 list-disc space-y-1 text-xs">
|
||||||
|
<li>User logs in on Day 0, uses app daily → stays logged in forever (tokens keep rotating)</li>
|
||||||
|
<li>User logs in on Day 0, stops using app → must re-login after 30 days of inactivity</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-t border-gray-200 pt-2">
|
||||||
|
<p class="font-medium text-gray-900 mb-1">Forcing Re-Authentication:</p>
|
||||||
|
<p class="ml-3 text-xs">Because of token rotation, there's no way to force periodic re-authentication using TTL settings alone. Active users can stay logged in indefinitely by refreshing tokens before they expire.</p>
|
||||||
|
|
||||||
|
<p class="mt-2 ml-3 text-xs"><strong>To enforce absolute session limits:</strong> Clients can include the <code class="bg-gray-100 px-1 rounded">max_age</code> parameter in their authorization requests to require re-authentication after a specific time, regardless of token rotation.</p>
|
||||||
|
|
||||||
|
<p class="mt-2 ml-3 text-xs"><strong>Example:</strong> A banking app might set <code class="bg-gray-100 px-1 rounded">max_age=900</code> (15 minutes) in the authorization request to force re-authentication every 15 minutes, even if refresh tokens are still valid.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-t border-gray-200 pt-2">
|
||||||
|
<p class="font-medium text-gray-900 mb-1">Common Configurations:</p>
|
||||||
|
<ul class="ml-3 space-y-1 text-xs">
|
||||||
|
<li><strong>Banking/High Security:</strong> Access TTL = <code class="bg-gray-100 px-1 rounded">5m</code>, Refresh TTL = <code class="bg-gray-100 px-1 rounded">5m</code> → Re-auth every 5 minutes</li>
|
||||||
|
<li><strong>Corporate Tools:</strong> Access TTL = <code class="bg-gray-100 px-1 rounded">1h</code>, Refresh TTL = <code class="bg-gray-100 px-1 rounded">8h</code> → Re-auth after 8 hours inactive</li>
|
||||||
|
<li><strong>Personal Apps:</strong> Access TTL = <code class="bg-gray-100 px-1 rounded">1h</code>, Refresh TTL = <code class="bg-gray-100 px-1 rounded">30d</code> → Re-auth after 30 days inactive</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
@@ -253,10 +330,10 @@
|
|||||||
<p class="font-medium">Optional: Customize header names sent to your application.</p>
|
<p class="font-medium">Optional: Customize header names sent to your application.</p>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<button type="button" data-action="json-validator#format" class="text-xs bg-gray-100 hover:bg-gray-200 px-2 py-1 rounded">Format JSON</button>
|
<button type="button" data-action="json-validator#format" class="text-xs bg-gray-100 hover:bg-gray-200 px-2 py-1 rounded">Format JSON</button>
|
||||||
<button type="button" data-action="json-validator#insertSample" data-json-sample='{"user": "Remote-User", "groups": "Remote-Groups", "email": "Remote-Email", "name": "Remote-Name", "admin": "Remote-Admin"}' class="text-xs bg-blue-100 hover:bg-blue-200 text-blue-700 px-2 py-1 rounded">Insert Example</button>
|
<button type="button" data-action="json-validator#insertSample" data-json-sample='{"user": "Remote-User", "groups": "Remote-Groups", "email": "Remote-Email", "name": "Remote-Name", "username": "Remote-Username", "admin": "Remote-Admin"}' class="text-xs bg-blue-100 hover:bg-blue-200 text-blue-700 px-2 py-1 rounded">Insert Example</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p><strong>Default headers:</strong> X-Remote-User, X-Remote-Email, X-Remote-Name, X-Remote-Groups, X-Remote-Admin</p>
|
<p><strong>Default headers:</strong> X-Remote-User, X-Remote-Email, X-Remote-Name, X-Remote-Username, X-Remote-Groups, X-Remote-Admin</p>
|
||||||
<div data-json-validator-target="status" class="text-xs font-medium"></div>
|
<div data-json-validator-target="status" class="text-xs font-medium"></div>
|
||||||
<details class="mt-2">
|
<details class="mt-2">
|
||||||
<summary class="cursor-pointer text-blue-600 hover:text-blue-800">Show available header keys and what data they send</summary>
|
<summary class="cursor-pointer text-blue-600 hover:text-blue-800">Show available header keys and what data they send</summary>
|
||||||
@@ -264,9 +341,10 @@
|
|||||||
<p><code class="bg-gray-100 px-1 rounded">user</code> - User's email address</p>
|
<p><code class="bg-gray-100 px-1 rounded">user</code> - User's email address</p>
|
||||||
<p><code class="bg-gray-100 px-1 rounded">email</code> - User's email address</p>
|
<p><code class="bg-gray-100 px-1 rounded">email</code> - User's email address</p>
|
||||||
<p><code class="bg-gray-100 px-1 rounded">name</code> - User's display name (falls back to email if not set)</p>
|
<p><code class="bg-gray-100 px-1 rounded">name</code> - User's display name (falls back to email if not set)</p>
|
||||||
|
<p><code class="bg-gray-100 px-1 rounded">username</code> - User's login username (only sent if set)</p>
|
||||||
<p><code class="bg-gray-100 px-1 rounded">groups</code> - Comma-separated list of group names (e.g., "admin,developers")</p>
|
<p><code class="bg-gray-100 px-1 rounded">groups</code> - Comma-separated list of group names (e.g., "admin,developers")</p>
|
||||||
<p><code class="bg-gray-100 px-1 rounded">admin</code> - "true" or "false" indicating admin status</p>
|
<p><code class="bg-gray-100 px-1 rounded">admin</code> - "true" or "false" indicating admin status</p>
|
||||||
<p class="mt-2 italic">Example: <code class="bg-gray-100 px-1 rounded">{"user": "Remote-User", "groups": "Remote-Groups"}</code></p>
|
<p class="mt-2 italic">Example: <code class="bg-gray-100 px-1 rounded">{"user": "Remote-User", "groups": "Remote-Groups", "username": "Remote-Username"}</code></p>
|
||||||
<p class="italic">Need custom user fields? Add them to user's custom_claims for OIDC tokens</p>
|
<p class="italic">Need custom user fields? Add them to user's custom_claims for OIDC tokens</p>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
@@ -215,7 +215,7 @@
|
|||||||
<code class="block bg-gray-100 px-3 py-2 rounded font-mono text-xs whitespace-pre-wrap"><%= JSON.pretty_generate(@application.headers_config) %></code>
|
<code class="block bg-gray-100 px-3 py-2 rounded font-mono text-xs whitespace-pre-wrap"><%= JSON.pretty_generate(@application.headers_config) %></code>
|
||||||
<% else %>
|
<% else %>
|
||||||
<div class="bg-gray-100 px-3 py-2 rounded text-xs text-gray-500">
|
<div class="bg-gray-100 px-3 py-2 rounded text-xs text-gray-500">
|
||||||
Using default headers: X-Remote-User, X-Remote-Email, X-Remote-Name, X-Remote-Groups, X-Remote-Admin
|
Using default headers: X-Remote-User, X-Remote-Email, X-Remote-Name, X-Remote-Username, X-Remote-Groups, X-Remote-Admin
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</dd>
|
</dd>
|
||||||
|
|||||||
@@ -147,9 +147,9 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<% if app.user_has_active_session?(@user) %>
|
<% if app.user_has_active_session?(@user) %>
|
||||||
<%= button_to "Logout", logout_from_app_active_sessions_path(application_id: app.id), method: :delete,
|
<%= button_to "Require Re-Auth", logout_from_app_active_sessions_path(application_id: app.id), method: :delete,
|
||||||
class: "w-full flex justify-center items-center px-4 py-2 border border-orange-300 text-sm font-medium rounded-md text-orange-700 bg-white hover:bg-orange-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-orange-500 transition",
|
class: "w-full flex justify-center items-center px-4 py-2 border border-orange-300 text-sm font-medium rounded-md text-orange-700 bg-white hover:bg-orange-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-orange-500 transition",
|
||||||
form: { data: { turbo_confirm: "This will log you out of #{app.name}. You can sign back in without re-authorizing. Continue?" } } %>
|
form: { data: { turbo_confirm: "This will revoke #{app.name}'s access tokens. The next time #{app.name} needs to authenticate, you'll sign in again (no re-authorization needed). Continue?" } } %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
autofocus: true,
|
autofocus: true,
|
||||||
autocomplete: "username",
|
autocomplete: "username",
|
||||||
placeholder: "your@email.com",
|
placeholder: "your@email.com",
|
||||||
value: params[:email_address],
|
value: @login_hint || params[:email_address],
|
||||||
data: { action: "blur->webauthn#checkWebAuthnSupport change->webauthn#checkWebAuthnSupport" },
|
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" %>
|
class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ Rails.application.configure do
|
|||||||
|
|
||||||
# Use Solid Queue for background jobs
|
# Use Solid Queue for background jobs
|
||||||
config.active_job.queue_adapter = :solid_queue
|
config.active_job.queue_adapter = :solid_queue
|
||||||
|
config.solid_queue.connects_to = {database: {writing: :queue}}
|
||||||
|
|
||||||
# Ignore bad email addresses and do not raise email delivery errors.
|
# Ignore bad email addresses and do not raise email delivery errors.
|
||||||
# Set this to true and configure the email server for immediate delivery to raise delivery errors.
|
# Set this to true and configure the email server for immediate delivery to raise delivery errors.
|
||||||
|
|||||||
@@ -4,5 +4,5 @@
|
|||||||
# Use this to limit dissemination of sensitive information.
|
# Use this to limit dissemination of sensitive information.
|
||||||
# See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors.
|
# See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors.
|
||||||
Rails.application.config.filter_parameters += [
|
Rails.application.config.filter_parameters += [
|
||||||
:passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :cvv, :cvc
|
:passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :cvv, :cvc, :backup
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
module Clinch
|
module Clinch
|
||||||
VERSION = "0.8.1"
|
VERSION = "0.8.7"
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -26,11 +26,11 @@ Rails.application.routes.draw do
|
|||||||
# OIDC (OpenID Connect) routes
|
# OIDC (OpenID Connect) routes
|
||||||
get "/.well-known/openid-configuration", to: "oidc#discovery"
|
get "/.well-known/openid-configuration", to: "oidc#discovery"
|
||||||
get "/.well-known/jwks.json", to: "oidc#jwks"
|
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/authorize/consent", to: "oidc#consent", as: :oauth_consent
|
||||||
post "/oauth/token", to: "oidc#token"
|
post "/oauth/token", to: "oidc#token"
|
||||||
post "/oauth/revoke", to: "oidc#revoke"
|
post "/oauth/revoke", to: "oidc#revoke"
|
||||||
get "/oauth/userinfo", to: "oidc#userinfo"
|
match "/oauth/userinfo", to: "oidc#userinfo", via: [:get, :post]
|
||||||
get "/logout", to: "oidc#logout"
|
get "/logout", to: "oidc#logout"
|
||||||
|
|
||||||
# ForwardAuth / Trusted Header SSO
|
# ForwardAuth / Trusted Header SSO
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
class AddSkipConsentToApplications < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
add_column :applications, :skip_consent, :boolean, default: false, null: false
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
class AddClaimsRequestsToOidcUserConsents < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
add_column :oidc_user_consents, :claims_requests, :json, default: {}, null: false
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
class AddClaimsRequestsToOidcAuthorizationCodes < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
add_column :oidc_authorization_codes, :claims_requests, :json, default: {}, null: false
|
||||||
|
end
|
||||||
|
end
|
||||||
5
db/schema.rb
generated
5
db/schema.rb
generated
@@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[8.1].define(version: 2025_12_31_060112) do
|
ActiveRecord::Schema[8.1].define(version: 2026_01_05_000809) do
|
||||||
create_table "active_storage_attachments", force: :cascade do |t|
|
create_table "active_storage_attachments", force: :cascade do |t|
|
||||||
t.bigint "blob_id", null: false
|
t.bigint "blob_id", null: false
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
@@ -78,6 +78,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_12_31_060112) do
|
|||||||
t.text "redirect_uris"
|
t.text "redirect_uris"
|
||||||
t.integer "refresh_token_ttl", default: 2592000
|
t.integer "refresh_token_ttl", default: 2592000
|
||||||
t.boolean "require_pkce", default: true, null: false
|
t.boolean "require_pkce", default: true, null: false
|
||||||
|
t.boolean "skip_consent", default: false, null: false
|
||||||
t.string "slug", null: false
|
t.string "slug", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
t.index ["active"], name: "index_applications_on_active"
|
t.index ["active"], name: "index_applications_on_active"
|
||||||
@@ -116,6 +117,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_12_31_060112) do
|
|||||||
t.string "acr"
|
t.string "acr"
|
||||||
t.integer "application_id", null: false
|
t.integer "application_id", null: false
|
||||||
t.integer "auth_time"
|
t.integer "auth_time"
|
||||||
|
t.json "claims_requests", default: {}, null: false
|
||||||
t.string "code_challenge"
|
t.string "code_challenge"
|
||||||
t.string "code_challenge_method"
|
t.string "code_challenge_method"
|
||||||
t.string "code_hmac", null: false
|
t.string "code_hmac", null: false
|
||||||
@@ -160,6 +162,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_12_31_060112) do
|
|||||||
|
|
||||||
create_table "oidc_user_consents", force: :cascade do |t|
|
create_table "oidc_user_consents", force: :cascade do |t|
|
||||||
t.integer "application_id", null: false
|
t.integer "application_id", null: false
|
||||||
|
t.json "claims_requests", default: {}, null: false
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "granted_at", null: false
|
t.datetime "granted_at", null: false
|
||||||
t.text "scopes_granted", null: false
|
t.text "scopes_granted", null: false
|
||||||
|
|||||||
@@ -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
|
- [x] **importmap audit** - JavaScript dependency scanning
|
||||||
- CI: Runs on every PR and push to main
|
- 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
|
- [x] **Test Coverage** - SimpleCov integration
|
||||||
- Command: `COVERAGE=1 bin/rails test`
|
- Command: `COVERAGE=1 bin/rails test`
|
||||||
- Coverage report: `coverage/index.html`
|
- Coverage report: `coverage/index.html`
|
||||||
@@ -44,7 +56,8 @@ This checklist ensures Clinch meets security, quality, and documentation standar
|
|||||||
- [x] Authorization code flow with PKCE support
|
- [x] Authorization code flow with PKCE support
|
||||||
- [x] Refresh token rotation
|
- [x] Refresh token rotation
|
||||||
- [x] Token family tracking (detects replay attacks)
|
- [x] Token family tracking (detects replay attacks)
|
||||||
- [x] All tokens HMAC-SHA256 hashed in database
|
- [x] All tokens and authorization codes HMAC-SHA256 hashed in database
|
||||||
|
- [x] TOTP secrets AES-256-GCM encrypted at rest (Rails credentials)
|
||||||
- [x] Configurable token expiry (access, refresh, ID)
|
- [x] Configurable token expiry (access, refresh, ID)
|
||||||
- [x] One-time use authorization codes
|
- [x] One-time use authorization codes
|
||||||
- [x] Pairwise subject identifiers (privacy)
|
- [x] Pairwise subject identifiers (privacy)
|
||||||
@@ -118,8 +131,7 @@ This checklist ensures Clinch meets security, quality, and documentation standar
|
|||||||
|
|
||||||
## Code Quality
|
## Code Quality
|
||||||
|
|
||||||
- [x] **RuboCop** - Code style and linting
|
- [x] **StandardRB** - Code style and linting
|
||||||
- Configuration: Rails Omakase
|
|
||||||
- CI: Runs on every PR and push to main
|
- CI: Runs on every PR and push to main
|
||||||
|
|
||||||
- [x] **Documentation** - Comprehensive README
|
- [x] **Documentation** - Comprehensive README
|
||||||
@@ -136,7 +148,7 @@ This checklist ensures Clinch meets security, quality, and documentation standar
|
|||||||
- [ ] Document required vs. optional configuration
|
- [ ] Document required vs. optional configuration
|
||||||
- [ ] Provide sensible defaults
|
- [ ] Provide sensible defaults
|
||||||
- [ ] Validate production SMTP configuration
|
- [ ] Validate production SMTP configuration
|
||||||
- [ ] Ensure OIDC private key generation process is documented
|
- [x] Ensure OIDC private key generation process is documented
|
||||||
|
|
||||||
### Database
|
### Database
|
||||||
- [x] Migrations are idempotent
|
- [x] Migrations are idempotent
|
||||||
@@ -146,31 +158,32 @@ This checklist ensures Clinch meets security, quality, and documentation standar
|
|||||||
|
|
||||||
### Performance
|
### Performance
|
||||||
- [ ] Review N+1 queries
|
- [ ] Review N+1 queries
|
||||||
- [ ] Add database indexes where needed
|
- [x] Add database indexes where needed
|
||||||
- [ ] Test with realistic data volumes
|
- [ ] Test with realistic data volumes
|
||||||
- [ ] Review token cleanup job performance
|
- [ ] Review token cleanup job performance
|
||||||
|
|
||||||
### Deployment
|
### Deployment
|
||||||
- [x] Docker support
|
- [x] Docker support
|
||||||
- [x] Docker Compose example
|
- [x] Docker Compose example
|
||||||
- [ ] Production deployment guide
|
- [x] Production deployment guide (Docker Compose with .env configuration, upgrading, logs)
|
||||||
- [ ] Backup and restore documentation
|
- [x] Backup and restore documentation
|
||||||
- [ ] Migration strategy documentation
|
|
||||||
|
|
||||||
## Security Hardening
|
## Security Hardening
|
||||||
|
|
||||||
### Headers & CSP
|
### Headers & CSP
|
||||||
- [ ] Review Content Security Policy
|
- [x] Content Security Policy (comprehensive policy in config/initializers/content_security_policy.rb)
|
||||||
- [ ] HSTS configuration
|
- [x] X-Frame-Options (DENY in production config)
|
||||||
- [ ] X-Frame-Options
|
- [x] X-Content-Type-Options (nosniff - Rails default)
|
||||||
- [ ] X-Content-Type-Options
|
- [x] Referrer-Policy (strict-origin-when-cross-origin in production config)
|
||||||
- [ ] Referrer-Policy
|
|
||||||
|
|
||||||
### Rate Limiting
|
### Rate Limiting
|
||||||
- [ ] Login attempt rate limiting
|
- [x] Login attempt rate limiting (20/3min on sessions#create)
|
||||||
- [ ] API endpoint rate limiting
|
- [x] TOTP verification rate limiting (10/3min on sessions#verify_totp)
|
||||||
- [ ] Token endpoint rate limiting
|
- [x] WebAuthn rate limiting (10/1min on webauthn endpoints, 10/3min on session endpoints)
|
||||||
- [ ] Password reset rate limiting
|
- [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
|
### Secrets Management
|
||||||
- [x] No secrets in code
|
- [x] No secrets in code
|
||||||
@@ -180,39 +193,49 @@ This checklist ensures Clinch meets security, quality, and documentation standar
|
|||||||
|
|
||||||
### Logging & Monitoring
|
### Logging & Monitoring
|
||||||
- [x] Sentry integration (optional)
|
- [x] Sentry integration (optional)
|
||||||
- [ ] Document what should be logged
|
- [x] Parameter filtering configured (passwords, tokens, secrets, backup codes, emails filtered from logs)
|
||||||
- [ ] Document what should NOT be logged (tokens, passwords)
|
|
||||||
- [ ] Audit log for admin actions
|
- [ ] Audit log for admin actions
|
||||||
|
|
||||||
## Known Limitations & Risks
|
## Known Limitations & Risks
|
||||||
|
|
||||||
### Documented Risks
|
### Documented Risks
|
||||||
- [ ] Document that ForwardAuth requires same-domain setup
|
- [x] Document that ForwardAuth requires same-domain setup
|
||||||
- [ ] Document HTTPS requirement for production
|
- [ ] Document HTTPS requirement for production
|
||||||
- [ ] Document backup code security (single-use, store securely)
|
- [ ] Document backup code security (single-use, store securely)
|
||||||
- [ ] Document admin password security requirements
|
- [ ] Document admin password security requirements
|
||||||
|
|
||||||
### Future Security Enhancements
|
### Future Security Enhancements (Post-Beta)
|
||||||
- [ ] Rate limiting on authentication endpoints
|
- [x] Rate limiting on authentication endpoints (comprehensive coverage implemented)
|
||||||
- [ ] Account lockout after N failed attempts
|
- [ ] Account lockout after N failed attempts (rate limiting provides similar protection)
|
||||||
- [ ] Admin audit logging
|
- [ ] Admin audit logging
|
||||||
- [ ] Security event notifications
|
- [ ] Security event notifications (email/webhook alerts for suspicious activity)
|
||||||
- [ ] Brute force detection
|
- [ ] Advanced brute force detection (pattern analysis beyond rate limiting)
|
||||||
- [ ] Suspicious login detection
|
- [ ] Suspicious login detection (geolocation, device fingerprinting)
|
||||||
- [ ] IP allowlist/blocklist
|
- [ ] IP allowlist/blocklist
|
||||||
|
|
||||||
## External Security Review
|
## Protocol Conformance & Security Review
|
||||||
|
|
||||||
- [ ] Consider bug bounty or security audit
|
**Protocol Conformance (Completed):**
|
||||||
- [ ] Penetration testing for OIDC flows
|
- [x] **OpenID Connect Conformance Testing** - [48/48 tests passed](https://www.certification.openid.net/log-detail.html?log=TZ8vOG0kf35lUiD)
|
||||||
- [ ] WebAuthn implementation review
|
- OIDC authorization code flow ✅
|
||||||
- [ ] Token security review
|
- 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
|
## Documentation for Users
|
||||||
|
|
||||||
- [ ] Security best practices guide
|
- [ ] Security best practices guide
|
||||||
- [ ] Incident response guide
|
- [ ] Incident response guide
|
||||||
- [ ] Backup and disaster recovery guide
|
- [x] Backup and disaster recovery guide
|
||||||
- [ ] Upgrade guide
|
- [ ] Upgrade guide
|
||||||
- [ ] Breaking change policy
|
- [ ] Breaking change policy
|
||||||
|
|
||||||
@@ -225,44 +248,57 @@ To move from "experimental" to "Beta", the following must be completed:
|
|||||||
- [x] All tests passing
|
- [x] All tests passing
|
||||||
- [x] Core features implemented and tested
|
- [x] Core features implemented and tested
|
||||||
- [x] Basic documentation complete
|
- [x] Basic documentation complete
|
||||||
- [ ] At least one external security review or penetration test
|
- [x] Backup/restore documentation
|
||||||
- [ ] Production deployment guide
|
- [x] Production deployment guide
|
||||||
- [ ] Backup/restore documentation
|
- [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):**
|
**Important (Should have for Beta):**
|
||||||
- [ ] Rate limiting on auth endpoints
|
- [x] Rate limiting on auth endpoints
|
||||||
- [ ] Security headers configuration documented
|
- [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
|
- [ ] Admin audit logging
|
||||||
- [ ] Known limitations documented
|
|
||||||
|
|
||||||
**Nice to have (Can defer to post-Beta):**
|
**Nice to have (Can defer to post-Beta):**
|
||||||
- [ ] Bug bounty program
|
- [ ] Bug bounty program
|
||||||
- [ ] Advanced monitoring/alerting
|
- [ ] 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
|
## Status Summary
|
||||||
|
|
||||||
**Current Status:** Pre-Beta / Experimental
|
**Current Status:** Ready for Beta Release 🎉
|
||||||
|
|
||||||
**Strengths:**
|
**Strengths:**
|
||||||
- ✅ Comprehensive security tooling in place
|
- ✅ 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)
|
- ✅ Modern security features (PKCE, token rotation, WebAuthn)
|
||||||
- ✅ Clean security scans (brakeman, bundler-audit)
|
- ✅ Clean security scans (brakeman, bundler-audit, Trivy)
|
||||||
- ✅ Well-documented codebase
|
- ✅ Well-documented codebase
|
||||||
|
- ✅ **OpenID Connect Conformance certified** - 48/48 tests passed
|
||||||
|
|
||||||
**Before Beta Release:**
|
**All Critical Requirements Met:**
|
||||||
- 🔶 External security review recommended
|
- All automated security scans passing ✅
|
||||||
- 🔶 Rate limiting implementation needed
|
- All tests passing (374 tests, 1542 assertions) ✅
|
||||||
- 🔶 Production deployment documentation
|
- Core features implemented and tested ✅
|
||||||
- 🔶 Security hardening checklist completion
|
- Documentation complete ✅
|
||||||
|
- Production deployment guide ✅
|
||||||
|
- Protocol conformance validation complete ✅
|
||||||
|
|
||||||
**Recommendation:** Consider Beta status after:
|
**Optional for Post-Beta:**
|
||||||
1. External security review or penetration testing
|
- Admin audit logging
|
||||||
2. Rate limiting implementation
|
- Traditional security audit/penetration test
|
||||||
3. Production hardening documentation
|
- Bug bounty program
|
||||||
4. 1-2 months of real-world testing
|
- 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.
|
|
||||||
@@ -279,7 +279,7 @@ module Api
|
|||||||
rd: evil_url # Ensure the rd parameter is preserved in login
|
rd: evil_url # Ensure the rd parameter is preserved in login
|
||||||
}
|
}
|
||||||
|
|
||||||
assert_response 302
|
assert_response 303
|
||||||
# Should NOT redirect to evil URL after successful authentication
|
# Should NOT redirect to evil URL after successful authentication
|
||||||
refute_match evil_url, response.location, "Should not redirect to evil URL after authentication"
|
refute_match evil_url, response.location, "Should not redirect to evil URL after authentication"
|
||||||
# Should redirect to the legitimate URL (not the evil one)
|
# Should redirect to the legitimate URL (not the evil one)
|
||||||
|
|||||||
394
test/controllers/oidc_claims_security_test.rb
Normal file
394
test/controllers/oidc_claims_security_test.rb
Normal file
@@ -0,0 +1,394 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class OidcClaimsSecurityTest < ActionDispatch::IntegrationTest
|
||||||
|
setup do
|
||||||
|
@user = User.create!(email_address: "claims_security_test@example.com", password: "password123")
|
||||||
|
@application = Application.create!(
|
||||||
|
name: "Claims Security Test App",
|
||||||
|
slug: "claims-security-test-app",
|
||||||
|
app_type: "oidc",
|
||||||
|
redirect_uris: ["http://localhost:4000/callback"].to_json,
|
||||||
|
active: true,
|
||||||
|
require_pkce: false
|
||||||
|
)
|
||||||
|
|
||||||
|
# Store the plain text client secret for testing
|
||||||
|
@application.generate_new_client_secret!
|
||||||
|
@plain_client_secret = @application.client_secret
|
||||||
|
@application.save!
|
||||||
|
end
|
||||||
|
|
||||||
|
def teardown
|
||||||
|
# Delete in correct order to avoid foreign key constraints
|
||||||
|
OidcRefreshToken.where(application: @application).delete_all
|
||||||
|
OidcAccessToken.where(application: @application).delete_all
|
||||||
|
OidcAuthorizationCode.where(application: @application).delete_all
|
||||||
|
OidcUserConsent.where(application: @application).delete_all
|
||||||
|
@user.destroy
|
||||||
|
@application.destroy
|
||||||
|
end
|
||||||
|
|
||||||
|
# ====================
|
||||||
|
# CLAIMS PARAMETER ESCALATION ATTACKS
|
||||||
|
# ====================
|
||||||
|
|
||||||
|
test "rejects claims parameter during authorization code exchange" do
|
||||||
|
# Create consent with minimal scopes (no profile, email, or admin access)
|
||||||
|
OidcUserConsent.create!(
|
||||||
|
user: @user,
|
||||||
|
application: @application,
|
||||||
|
scopes_granted: "openid",
|
||||||
|
granted_at: Time.current,
|
||||||
|
sid: "test-sid-123"
|
||||||
|
)
|
||||||
|
|
||||||
|
auth_code = OidcAuthorizationCode.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
|
scope: "openid",
|
||||||
|
expires_at: 10.minutes.from_now
|
||||||
|
)
|
||||||
|
|
||||||
|
# ATTEMPT: Inject claims parameter during token exchange (ATTACK!)
|
||||||
|
# The client is trying to request 'admin' claim that they never got consent for
|
||||||
|
post "/oauth/token", params: {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: auth_code.plaintext_code,
|
||||||
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
|
claims: '{"id_token":{"admin":{"essential":true}}}' # ← ATTACK!
|
||||||
|
}, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||||
|
}
|
||||||
|
|
||||||
|
# SHOULD: Reject the claims parameter - it's only allowed in authorization requests
|
||||||
|
assert_response :bad_request
|
||||||
|
error = JSON.parse(response.body)
|
||||||
|
assert_equal "invalid_request", error["error"], "Should reject claims parameter at token endpoint"
|
||||||
|
assert_match(/claims.*not allowed|unsupported parameter/i, error["error_description"], "Error should mention claims parameter not allowed")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects claims parameter during authorization code exchange with profile escalation" do
|
||||||
|
# Create consent with ONLY openid scope (no profile scope)
|
||||||
|
OidcUserConsent.create!(
|
||||||
|
user: @user,
|
||||||
|
application: @application,
|
||||||
|
scopes_granted: "openid",
|
||||||
|
granted_at: Time.current,
|
||||||
|
sid: "test-sid-123"
|
||||||
|
)
|
||||||
|
|
||||||
|
auth_code = OidcAuthorizationCode.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
|
scope: "openid",
|
||||||
|
expires_at: 10.minutes.from_now
|
||||||
|
)
|
||||||
|
|
||||||
|
# ATTEMPT: Try to get profile claims via claims parameter without profile scope
|
||||||
|
post "/oauth/token", params: {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: auth_code.plaintext_code,
|
||||||
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
|
claims: '{"id_token":{"name":null,"email":{"essential":true}}}'
|
||||||
|
}, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||||
|
}
|
||||||
|
|
||||||
|
# SHOULD: Reject the claims parameter
|
||||||
|
assert_response :bad_request
|
||||||
|
error = JSON.parse(response.body)
|
||||||
|
assert_equal "invalid_request", error["error"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects claims parameter during refresh token grant" do
|
||||||
|
access_token = OidcAccessToken.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
scope: "openid"
|
||||||
|
)
|
||||||
|
|
||||||
|
refresh_token = OidcRefreshToken.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
oidc_access_token: access_token,
|
||||||
|
scope: "openid"
|
||||||
|
)
|
||||||
|
|
||||||
|
plaintext_refresh_token = refresh_token.token
|
||||||
|
|
||||||
|
# ATTEMPT: Inject claims parameter during refresh (ATTACK!)
|
||||||
|
# Trying to escalate to admin claims during refresh
|
||||||
|
post "/oauth/token", params: {
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
refresh_token: plaintext_refresh_token,
|
||||||
|
claims: '{"id_token":{"admin":true,"role":{"essential":true}}}' # ← ATTACK!
|
||||||
|
}, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||||
|
}
|
||||||
|
|
||||||
|
# SHOULD: Reject the claims parameter
|
||||||
|
assert_response :bad_request
|
||||||
|
error = JSON.parse(response.body)
|
||||||
|
assert_equal "invalid_request", error["error"], "Should reject claims parameter at refresh token endpoint"
|
||||||
|
assert_match(/claims.*not allowed|unsupported parameter/i, error["error_description"])
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects claims parameter during refresh token grant with custom claims escalation" do
|
||||||
|
# Setup: User has a custom claim at user level
|
||||||
|
@user.update!(custom_claims: {"role" => "user"})
|
||||||
|
|
||||||
|
access_token = OidcAccessToken.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
scope: "openid"
|
||||||
|
)
|
||||||
|
|
||||||
|
refresh_token = OidcRefreshToken.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
oidc_access_token: access_token,
|
||||||
|
scope: "openid"
|
||||||
|
)
|
||||||
|
|
||||||
|
plaintext_refresh_token = refresh_token.token
|
||||||
|
|
||||||
|
# ATTEMPT: Try to escalate role to admin via claims parameter
|
||||||
|
post "/oauth/token", params: {
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
refresh_token: plaintext_refresh_token,
|
||||||
|
claims: '{"id_token":{"role":{"value":"admin"}}}' # ← ATTACK! Trying to override role value
|
||||||
|
}, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||||
|
}
|
||||||
|
|
||||||
|
# SHOULD: Reject the claims parameter
|
||||||
|
assert_response :bad_request
|
||||||
|
error = JSON.parse(response.body)
|
||||||
|
assert_equal "invalid_request", error["error"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "allows token exchange without claims parameter" do
|
||||||
|
# Create consent
|
||||||
|
OidcUserConsent.create!(
|
||||||
|
user: @user,
|
||||||
|
application: @application,
|
||||||
|
scopes_granted: "openid profile",
|
||||||
|
granted_at: Time.current,
|
||||||
|
sid: "test-sid-123"
|
||||||
|
)
|
||||||
|
|
||||||
|
auth_code = OidcAuthorizationCode.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
|
scope: "openid profile",
|
||||||
|
expires_at: 10.minutes.from_now
|
||||||
|
)
|
||||||
|
|
||||||
|
# Normal token exchange WITHOUT claims parameter should work fine
|
||||||
|
post "/oauth/token", params: {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: auth_code.plaintext_code,
|
||||||
|
redirect_uri: "http://localhost:4000/callback"
|
||||||
|
}, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
response_body = JSON.parse(response.body)
|
||||||
|
assert response_body.key?("access_token")
|
||||||
|
assert response_body.key?("id_token")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "allows refresh without claims parameter" do
|
||||||
|
# Create consent for this application
|
||||||
|
OidcUserConsent.create!(
|
||||||
|
user: @user,
|
||||||
|
application: @application,
|
||||||
|
scopes_granted: "openid profile",
|
||||||
|
granted_at: Time.current,
|
||||||
|
sid: "test-sid-refresh-456"
|
||||||
|
)
|
||||||
|
|
||||||
|
access_token = OidcAccessToken.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
scope: "openid profile"
|
||||||
|
)
|
||||||
|
|
||||||
|
refresh_token = OidcRefreshToken.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
oidc_access_token: access_token,
|
||||||
|
scope: "openid profile"
|
||||||
|
)
|
||||||
|
|
||||||
|
plaintext_refresh_token = refresh_token.token
|
||||||
|
|
||||||
|
# Normal refresh WITHOUT claims parameter should work fine
|
||||||
|
post "/oauth/token", params: {
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
refresh_token: plaintext_refresh_token
|
||||||
|
}, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
response_body = JSON.parse(response.body)
|
||||||
|
assert response_body.key?("access_token")
|
||||||
|
assert response_body.key?("id_token")
|
||||||
|
end
|
||||||
|
|
||||||
|
# ====================
|
||||||
|
# CLAIMS PARAMETER IS AUTHORIZATION-ONLY
|
||||||
|
# ====================
|
||||||
|
|
||||||
|
test "claims parameter is only valid in authorization request per OIDC spec" do
|
||||||
|
# Per OIDC Core spec section 18.2.1, claims parameter usage location is "Authorization Request"
|
||||||
|
# This test verifies that claims parameter cannot be used at token endpoint
|
||||||
|
|
||||||
|
OidcUserConsent.create!(
|
||||||
|
user: @user,
|
||||||
|
application: @application,
|
||||||
|
scopes_granted: "openid",
|
||||||
|
granted_at: Time.current,
|
||||||
|
sid: "test-sid-123"
|
||||||
|
)
|
||||||
|
|
||||||
|
auth_code = OidcAuthorizationCode.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
|
scope: "openid",
|
||||||
|
expires_at: 10.minutes.from_now
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test various attempts to inject claims parameter
|
||||||
|
malicious_claims = [
|
||||||
|
'{"id_token":{"admin":true}}',
|
||||||
|
'{"id_token":{"email":{"essential":true}}}',
|
||||||
|
'{"userinfo":{"groups":{"values":["admin"]}}}',
|
||||||
|
'{"id_token":{"custom_claim":"custom_value"}}',
|
||||||
|
"invalid-json"
|
||||||
|
]
|
||||||
|
|
||||||
|
malicious_claims.each do |claims_value|
|
||||||
|
post "/oauth/token", params: {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: auth_code.plaintext_code,
|
||||||
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
|
claims: claims_value
|
||||||
|
}, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||||
|
}
|
||||||
|
|
||||||
|
# All should be rejected
|
||||||
|
assert_response :bad_request, "Claims parameter '#{claims_value}' should be rejected"
|
||||||
|
error = JSON.parse(response.body)
|
||||||
|
assert_equal "invalid_request", error["error"]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# ====================
|
||||||
|
# VERIFY CONSENT-BASED ACCESS IS ENFORCED
|
||||||
|
# ====================
|
||||||
|
|
||||||
|
test "token endpoint respects scopes granted during authorization" do
|
||||||
|
# Create consent with ONLY openid scope (no email, profile, etc.)
|
||||||
|
OidcUserConsent.create!(
|
||||||
|
user: @user,
|
||||||
|
application: @application,
|
||||||
|
scopes_granted: "openid",
|
||||||
|
granted_at: Time.current,
|
||||||
|
sid: "test-sid-123"
|
||||||
|
)
|
||||||
|
|
||||||
|
auth_code = OidcAuthorizationCode.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
|
scope: "openid",
|
||||||
|
expires_at: 10.minutes.from_now
|
||||||
|
)
|
||||||
|
|
||||||
|
# Exchange code for tokens
|
||||||
|
post "/oauth/token", params: {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: auth_code.plaintext_code,
|
||||||
|
redirect_uri: "http://localhost:4000/callback"
|
||||||
|
}, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
response_body = JSON.parse(response.body)
|
||||||
|
id_token = response_body["id_token"]
|
||||||
|
|
||||||
|
# Decode ID token to check claims
|
||||||
|
decoded = JWT.decode(id_token, nil, false).first
|
||||||
|
|
||||||
|
# Should only have required claims, not email/profile
|
||||||
|
assert_includes decoded.keys, "iss"
|
||||||
|
assert_includes decoded.keys, "sub"
|
||||||
|
assert_includes decoded.keys, "aud"
|
||||||
|
assert_includes decoded.keys, "exp"
|
||||||
|
assert_includes decoded.keys, "iat"
|
||||||
|
|
||||||
|
# Should NOT have claims that weren't consented to
|
||||||
|
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"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "refresh token preserves original scopes granted during authorization" do
|
||||||
|
# Create consent with specific scopes
|
||||||
|
OidcUserConsent.create!(
|
||||||
|
user: @user,
|
||||||
|
application: @application,
|
||||||
|
scopes_granted: "openid email",
|
||||||
|
granted_at: Time.current,
|
||||||
|
sid: "test-sid-refresh-123"
|
||||||
|
)
|
||||||
|
|
||||||
|
access_token = OidcAccessToken.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
scope: "openid email"
|
||||||
|
)
|
||||||
|
|
||||||
|
refresh_token = OidcRefreshToken.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
oidc_access_token: access_token,
|
||||||
|
scope: "openid email"
|
||||||
|
)
|
||||||
|
|
||||||
|
plaintext_refresh_token = refresh_token.token
|
||||||
|
|
||||||
|
# Refresh the token
|
||||||
|
post "/oauth/token", params: {
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
refresh_token: plaintext_refresh_token
|
||||||
|
}, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
response_body = JSON.parse(response.body)
|
||||||
|
id_token = response_body["id_token"]
|
||||||
|
|
||||||
|
# Decode ID token to verify scopes are preserved
|
||||||
|
decoded = JWT.decode(id_token, nil, false).first
|
||||||
|
|
||||||
|
# Should have email claims (from original consent)
|
||||||
|
assert_includes decoded.keys, "email", "Should preserve email scope from original consent"
|
||||||
|
assert_includes decoded.keys, "email_verified", "Should preserve email_verified scope from original consent"
|
||||||
|
|
||||||
|
# Should NOT have profile claims (not in original consent)
|
||||||
|
refute_includes decoded.keys, "name", "Should not add profile claims that weren't consented to"
|
||||||
|
refute_includes decoded.keys, "preferred_username", "Should not add preferred_username that wasn't consented to"
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -91,8 +91,10 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
get "/oauth/authorize", params: auth_params
|
get "/oauth/authorize", params: auth_params
|
||||||
|
|
||||||
assert_response :bad_request
|
# Should redirect back to client with error parameters (OAuth2 spec)
|
||||||
assert_match(/Invalid code_challenge_method/, @response.body)
|
assert_response :redirect
|
||||||
|
assert_match(/error=invalid_request/, @response.location)
|
||||||
|
assert_match(/error_description=.*code_challenge_method/, @response.location)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "authorization endpoint rejects invalid code_challenge format" do
|
test "authorization endpoint rejects invalid code_challenge format" do
|
||||||
@@ -108,8 +110,10 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
get "/oauth/authorize", params: auth_params
|
get "/oauth/authorize", params: auth_params
|
||||||
|
|
||||||
assert_response :bad_request
|
# Should redirect back to client with error parameters (OAuth2 spec)
|
||||||
assert_match(/Invalid code_challenge format/, @response.body)
|
assert_response :redirect
|
||||||
|
assert_match(/error=invalid_request/, @response.location)
|
||||||
|
assert_match(/error_description=.*code_challenge.*format/, @response.location)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "token endpoint requires code_verifier when PKCE was used (S256)" do
|
test "token endpoint requires code_verifier when PKCE was used (S256)" do
|
||||||
|
|||||||
236
test/controllers/oidc_prompt_login_test.rb
Normal file
236
test/controllers/oidc_prompt_login_test.rb
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class OidcPromptLoginTest < ActionDispatch::IntegrationTest
|
||||||
|
setup do
|
||||||
|
@user = users(:alice)
|
||||||
|
@application = applications(:kavita_app)
|
||||||
|
@client_secret = SecureRandom.urlsafe_base64(48)
|
||||||
|
@application.client_secret = @client_secret
|
||||||
|
@application.save!
|
||||||
|
|
||||||
|
# Pre-authorize the application so we skip consent screen
|
||||||
|
consent = OidcUserConsent.find_or_initialize_by(
|
||||||
|
user: @user,
|
||||||
|
application: @application
|
||||||
|
)
|
||||||
|
consent.scopes_granted ||= "openid profile email"
|
||||||
|
consent.save!
|
||||||
|
end
|
||||||
|
|
||||||
|
teardown do
|
||||||
|
# Clean up
|
||||||
|
OidcAccessToken.where(user: @user, application: @application).destroy_all
|
||||||
|
OidcAuthorizationCode.where(user: @user, application: @application).destroy_all
|
||||||
|
end
|
||||||
|
|
||||||
|
test "max_age requires re-authentication when session is too old" do
|
||||||
|
# Sign in to create a session
|
||||||
|
post "/signin", params: {
|
||||||
|
email_address: @user.email_address,
|
||||||
|
password: "password"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :redirect
|
||||||
|
follow_redirect!
|
||||||
|
assert_response :success
|
||||||
|
|
||||||
|
# Get first auth_time
|
||||||
|
get "/oauth/authorize", params: {
|
||||||
|
client_id: @application.client_id,
|
||||||
|
redirect_uri: @application.parsed_redirect_uris.first,
|
||||||
|
response_type: "code",
|
||||||
|
scope: "openid",
|
||||||
|
state: "first-state",
|
||||||
|
nonce: "first-nonce"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :redirect
|
||||||
|
first_redirect_url = response.location
|
||||||
|
first_code = CGI.parse(URI(first_redirect_url).query)["code"].first
|
||||||
|
|
||||||
|
# Exchange for tokens and extract auth_time
|
||||||
|
post "/oauth/token", params: {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: first_code,
|
||||||
|
redirect_uri: @application.parsed_redirect_uris.first,
|
||||||
|
client_id: @application.client_id,
|
||||||
|
client_secret: @client_secret
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
first_tokens = JSON.parse(response.body)
|
||||||
|
first_id_token = OidcJwtService.decode_id_token(first_tokens["id_token"])
|
||||||
|
first_auth_time = first_id_token[0]["auth_time"]
|
||||||
|
|
||||||
|
# Wait a bit (simulate time passing - in real scenario this would be actual seconds)
|
||||||
|
# Then request with max_age=0 (means session must be brand new)
|
||||||
|
get "/oauth/authorize", params: {
|
||||||
|
client_id: @application.client_id,
|
||||||
|
redirect_uri: @application.parsed_redirect_uris.first,
|
||||||
|
response_type: "code",
|
||||||
|
scope: "openid",
|
||||||
|
state: "second-state",
|
||||||
|
nonce: "second-nonce",
|
||||||
|
max_age: "0" # Requires session to be 0 seconds old (i.e., brand new)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Should redirect to sign in because session is too old
|
||||||
|
assert_response :redirect
|
||||||
|
assert_redirected_to(/signin/)
|
||||||
|
|
||||||
|
# Sign in again
|
||||||
|
post "/signin", params: {
|
||||||
|
email_address: @user.email_address,
|
||||||
|
password: "password"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :redirect
|
||||||
|
follow_redirect!
|
||||||
|
|
||||||
|
# Should receive authorization code
|
||||||
|
assert_response :redirect
|
||||||
|
second_redirect_url = response.location
|
||||||
|
second_code = CGI.parse(URI(second_redirect_url).query)["code"].first
|
||||||
|
|
||||||
|
assert second_code.present?, "Should receive authorization code after re-authentication"
|
||||||
|
|
||||||
|
# Exchange second authorization code for tokens
|
||||||
|
post "/oauth/token", params: {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: second_code,
|
||||||
|
redirect_uri: @application.parsed_redirect_uris.first,
|
||||||
|
client_id: @application.client_id,
|
||||||
|
client_secret: @client_secret
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
second_tokens = JSON.parse(response.body)
|
||||||
|
second_id_token = OidcJwtService.decode_id_token(second_tokens["id_token"])
|
||||||
|
second_auth_time = second_id_token[0]["auth_time"]
|
||||||
|
|
||||||
|
# The second auth_time should be >= the first (re-authentication occurred)
|
||||||
|
# Note: May be equal if both occur in the same second (test timing edge case)
|
||||||
|
assert second_auth_time >= first_auth_time,
|
||||||
|
"max_age=0 should result in a re-authentication. " \
|
||||||
|
"First: #{first_auth_time}, Second: #{second_auth_time}"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "prompt=none returns login_required error when not authenticated" do
|
||||||
|
# Don't sign in - user is not authenticated
|
||||||
|
|
||||||
|
# Request authorization with prompt=none
|
||||||
|
get "/oauth/authorize", params: {
|
||||||
|
client_id: @application.client_id,
|
||||||
|
redirect_uri: @application.parsed_redirect_uris.first,
|
||||||
|
response_type: "code",
|
||||||
|
scope: "openid",
|
||||||
|
state: "test-state",
|
||||||
|
prompt: "none"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Should redirect with error=login_required (NOT to sign-in page)
|
||||||
|
assert_response :redirect
|
||||||
|
redirect_url = response.location
|
||||||
|
|
||||||
|
# Parse the redirect URL
|
||||||
|
uri = URI.parse(redirect_url)
|
||||||
|
query_params = uri.query ? CGI.parse(uri.query) : {}
|
||||||
|
|
||||||
|
assert_equal "login_required", query_params["error"]&.first,
|
||||||
|
"Should return login_required error for prompt=none when not authenticated"
|
||||||
|
assert_equal "test-state", query_params["state"]&.first,
|
||||||
|
"Should return state parameter"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "prompt=login forces re-authentication with new auth_time" do
|
||||||
|
# First authentication
|
||||||
|
post "/signin", params: {
|
||||||
|
email_address: @user.email_address,
|
||||||
|
password: "password"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :redirect
|
||||||
|
follow_redirect!
|
||||||
|
assert_response :success
|
||||||
|
|
||||||
|
# Get first authorization code
|
||||||
|
get "/oauth/authorize", params: {
|
||||||
|
client_id: @application.client_id,
|
||||||
|
redirect_uri: @application.parsed_redirect_uris.first,
|
||||||
|
response_type: "code",
|
||||||
|
scope: "openid",
|
||||||
|
state: "first-state",
|
||||||
|
nonce: "first-nonce"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :redirect
|
||||||
|
first_redirect_url = response.location
|
||||||
|
first_code = CGI.parse(URI(first_redirect_url).query)["code"].first
|
||||||
|
|
||||||
|
# Exchange for tokens and extract auth_time from ID token
|
||||||
|
post "/oauth/token", params: {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: first_code,
|
||||||
|
redirect_uri: @application.parsed_redirect_uris.first,
|
||||||
|
client_id: @application.client_id,
|
||||||
|
client_secret: @client_secret
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
first_tokens = JSON.parse(response.body)
|
||||||
|
first_id_token = OidcJwtService.decode_id_token(first_tokens["id_token"])
|
||||||
|
first_auth_time = first_id_token[0]["auth_time"]
|
||||||
|
|
||||||
|
# Now request authorization again with prompt=login
|
||||||
|
get "/oauth/authorize", params: {
|
||||||
|
client_id: @application.client_id,
|
||||||
|
redirect_uri: @application.parsed_redirect_uris.first,
|
||||||
|
response_type: "code",
|
||||||
|
scope: "openid",
|
||||||
|
state: "second-state",
|
||||||
|
nonce: "second-nonce",
|
||||||
|
prompt: "login"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Should redirect to sign in
|
||||||
|
assert_response :redirect
|
||||||
|
assert_redirected_to(/signin/)
|
||||||
|
|
||||||
|
# Sign in again (simulating user re-authentication)
|
||||||
|
post "/signin", params: {
|
||||||
|
email_address: @user.email_address,
|
||||||
|
password: "password"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :redirect
|
||||||
|
# Follow redirect to after_authentication_url (which is /oauth/authorize without prompt=login)
|
||||||
|
follow_redirect!
|
||||||
|
|
||||||
|
# Should receive authorization code redirect
|
||||||
|
assert_response :redirect
|
||||||
|
second_redirect_url = response.location
|
||||||
|
second_code = CGI.parse(URI(second_redirect_url).query)["code"].first
|
||||||
|
|
||||||
|
assert second_code.present?, "Should receive authorization code after re-authentication"
|
||||||
|
|
||||||
|
# Exchange second authorization code for tokens
|
||||||
|
post "/oauth/token", params: {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: second_code,
|
||||||
|
redirect_uri: @application.parsed_redirect_uris.first,
|
||||||
|
client_id: @application.client_id,
|
||||||
|
client_secret: @client_secret
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
second_tokens = JSON.parse(response.body)
|
||||||
|
second_id_token = OidcJwtService.decode_id_token(second_tokens["id_token"])
|
||||||
|
second_auth_time = second_id_token[0]["auth_time"]
|
||||||
|
|
||||||
|
# The second auth_time should be >= the first (re-authentication occurred)
|
||||||
|
# Note: May be equal if both occur in the same second (test timing edge case)
|
||||||
|
assert second_auth_time >= first_auth_time,
|
||||||
|
"prompt=login should result in a later auth_time. " \
|
||||||
|
"First: #{first_auth_time}, Second: #{second_auth_time}"
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -228,7 +228,11 @@ class OidcRefreshTokenControllerTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
assert_response :success
|
assert_response :success
|
||||||
json = JSON.parse(response.body)
|
json = JSON.parse(response.body)
|
||||||
assert_equal @user.id.to_s, json["sub"]
|
|
||||||
|
# Should return pairwise SID from consent (alice has consent for kavita_app in fixtures)
|
||||||
|
consent = OidcUserConsent.find_by(user: @user, application: @application)
|
||||||
|
expected_sub = consent&.sid || @user.id.to_s
|
||||||
|
assert_equal expected_sub, json["sub"]
|
||||||
assert_equal @user.email_address, json["email"]
|
assert_equal @user.email_address, json["email"]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
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
|
application: kavita_app
|
||||||
scopes_granted: openid profile email
|
scopes_granted: openid profile email
|
||||||
granted_at: 2025-10-24 16:57:39
|
granted_at: 2025-10-24 16:57:39
|
||||||
|
sid: alice-kavita-sid-12345
|
||||||
|
|
||||||
bob_consent:
|
bob_consent:
|
||||||
user: bob
|
user: bob
|
||||||
application: another_app
|
application: another_app
|
||||||
scopes_granted: openid email groups
|
scopes_granted: openid email groups
|
||||||
granted_at: 2025-10-24 16:57:39
|
granted_at: 2025-10-24 16:57:39
|
||||||
|
sid: bob-another-sid-67890
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
require "test_helper"
|
require "test_helper"
|
||||||
|
|
||||||
# Note: This file tests API endpoints directly (post/get/assert_response)
|
# Advanced integration tests for Forward Auth API
|
||||||
# so it should use IntegrationTest, not SystemTestCase
|
class ForwardAuthAdvancedTest < ActionDispatch::IntegrationTest
|
||||||
class ForwardAuthSystemTest < ActionDispatch::IntegrationTest
|
|
||||||
setup do
|
setup do
|
||||||
@user = users(:one)
|
@user = users(:one)
|
||||||
@admin_user = users(:two)
|
@admin_user = users(:two)
|
||||||
@@ -24,7 +23,7 @@ class ForwardAuthSystemTest < ActionDispatch::IntegrationTest
|
|||||||
assert_response 302
|
assert_response 302
|
||||||
location = response.location
|
location = response.location
|
||||||
assert_match %r{/signin}, 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
|
# Step 2: Extract return URL from session
|
||||||
assert_equal "https://app.example.com/dashboard", session[:return_to_after_authenticating]
|
assert_equal "https://app.example.com/dashboard", session[:return_to_after_authenticating]
|
||||||
@@ -32,8 +31,11 @@ class ForwardAuthSystemTest < ActionDispatch::IntegrationTest
|
|||||||
# Step 3: Sign in
|
# Step 3: Sign in
|
||||||
post "/signin", params: {email_address: @user.email_address, password: "password"}
|
post "/signin", params: {email_address: @user.email_address, password: "password"}
|
||||||
|
|
||||||
assert_response 302
|
assert_response 303
|
||||||
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
|
# Step 4: Authenticated request to protected resource
|
||||||
get "/api/verify", headers: {"X-Forwarded-Host" => "app.example.com"}
|
get "/api/verify", headers: {"X-Forwarded-Host" => "app.example.com"}
|
||||||
@@ -62,7 +64,7 @@ class ForwardAuthSystemTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
# Sign in once
|
# Sign in once
|
||||||
post "/signin", params: {email_address: @user.email_address, password: "password"}
|
post "/signin", params: {email_address: @user.email_address, password: "password"}
|
||||||
assert_response 302
|
assert_response 303
|
||||||
assert_redirected_to "/"
|
assert_redirected_to "/"
|
||||||
|
|
||||||
# Test access to different applications
|
# Test access to different applications
|
||||||
@@ -99,7 +101,7 @@ class ForwardAuthSystemTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
# Sign in
|
# Sign in
|
||||||
post "/signin", params: {email_address: @user.email_address, password: "password"}
|
post "/signin", params: {email_address: @user.email_address, password: "password"}
|
||||||
assert_response 302
|
assert_response 303
|
||||||
|
|
||||||
# Should have access (in allowed group)
|
# Should have access (in allowed group)
|
||||||
get "/api/verify", headers: {"X-Forwarded-Host" => "admin.example.com"}
|
get "/api/verify", headers: {"X-Forwarded-Host" => "admin.example.com"}
|
||||||
@@ -137,7 +139,7 @@ class ForwardAuthSystemTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
# Sign in
|
# Sign in
|
||||||
post "/signin", params: {email_address: @user.email_address, password: "password"}
|
post "/signin", params: {email_address: @user.email_address, password: "password"}
|
||||||
assert_response 302
|
assert_response 303
|
||||||
|
|
||||||
# Should have access (bypass mode)
|
# Should have access (bypass mode)
|
||||||
get "/api/verify", headers: {"X-Forwarded-Host" => "public.example.com"}
|
get "/api/verify", headers: {"X-Forwarded-Host" => "public.example.com"}
|
||||||
@@ -146,40 +148,17 @@ class ForwardAuthSystemTest < ActionDispatch::IntegrationTest
|
|||||||
end
|
end
|
||||||
|
|
||||||
# Security System Tests
|
# 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
|
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
|
# Sign in
|
||||||
post "/signin", params: {email_address: @user.email_address, password: "password"}
|
post "/signin", params: {email_address: @user.email_address, password: "password"}
|
||||||
session_id = cookies[:session_id]
|
session_id = Session.last.id
|
||||||
|
|
||||||
# Should work initially
|
# Should work initially
|
||||||
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
|
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
|
||||||
@@ -199,42 +178,42 @@ class ForwardAuthSystemTest < ActionDispatch::IntegrationTest
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "concurrent access with rate limiting considerations" do
|
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
|
# Sign in
|
||||||
post "/signin", params: {email_address: @user.email_address, password: "password"}
|
post "/signin", params: {email_address: @user.email_address, password: "password"}
|
||||||
session_cookie = cookies[:session_id]
|
|
||||||
|
|
||||||
# Simulate multiple concurrent requests from different IPs
|
# Make multiple sequential requests (threads don't work in integration tests)
|
||||||
threads = []
|
|
||||||
results = []
|
results = []
|
||||||
|
|
||||||
10.times do |i|
|
10.times do |i|
|
||||||
threads << Thread.new do
|
start_time = Time.current
|
||||||
start_time = Time.current
|
|
||||||
|
|
||||||
get "/api/verify", headers: {
|
get "/api/verify", headers: {
|
||||||
"X-Forwarded-Host" => "app#{i}.example.com",
|
"X-Forwarded-Host" => "app#{i}.example.com",
|
||||||
"X-Forwarded-For" => "192.168.1.#{100 + i}",
|
"X-Forwarded-For" => "192.168.1.#{100 + i}"
|
||||||
"Cookie" => "_clinch_session_id=#{session_cookie}"
|
}
|
||||||
}
|
|
||||||
|
|
||||||
end_time = Time.current
|
end_time = Time.current
|
||||||
|
|
||||||
results << {
|
results << {
|
||||||
thread_id: i,
|
request_id: i,
|
||||||
status: response.status,
|
status: response.status,
|
||||||
user: response.headers["x-remote-user"],
|
user: response.headers["x-remote-user"],
|
||||||
duration: end_time - start_time
|
duration: end_time - start_time
|
||||||
}
|
}
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
threads.each(&:join)
|
|
||||||
|
|
||||||
# All requests should succeed
|
# All requests should succeed
|
||||||
results.each do |result|
|
results.each do |result|
|
||||||
assert_equal 200, result[:status], "Thread #{result[:thread_id]} failed"
|
assert_equal 200, result[:status], "Request #{result[:request_id]} failed"
|
||||||
assert_equal @user.email_address, result[:user], "Thread #{result[:thread_id]} has wrong user"
|
assert_equal @user.email_address, result[:user], "Request #{result[:request_id]} has wrong user"
|
||||||
assert result[:duration] < 1.0, "Thread #{result[:thread_id]} was too slow"
|
assert result[:duration] < 1.0, "Request #{result[:request_id]} was too slow"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -276,7 +255,7 @@ class ForwardAuthSystemTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
# Sign in once
|
# Sign in once
|
||||||
post "/signin", params: {email_address: @user.email_address, password: "password"}
|
post "/signin", params: {email_address: @user.email_address, password: "password"}
|
||||||
assert_response 302
|
assert_response 303
|
||||||
|
|
||||||
# Test access to each application
|
# Test access to each application
|
||||||
apps.each do |app|
|
apps.each do |app|
|
||||||
@@ -285,22 +264,23 @@ class ForwardAuthSystemTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
# Verify headers are correct
|
# Verify headers are correct
|
||||||
if app[:headers_config][:user].present?
|
if app[:headers_config][:user].present?
|
||||||
assert_equal app[:headers_config][:user],
|
assert response.headers.key?(app[:headers_config][:user]),
|
||||||
response.headers.keys.find { |k| k.include?("USER") },
|
"Missing header #{app[:headers_config][:user]} for #{app[:domain]}"
|
||||||
"Wrong user header for #{app[:domain]}"
|
assert_equal @user.email_address, response.headers[app[:headers_config][:user]],
|
||||||
assert_equal @user.email_address, response.headers[app[:headers_config][:user]]
|
"Wrong user value in #{app[:headers_config][:user]} for #{app[:domain]}"
|
||||||
else
|
else
|
||||||
# Should have no auth headers
|
# Should have no auth headers
|
||||||
auth_headers = response.headers.select { |k, v| k.match?(/^(X-|Remote-)/i) }
|
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]}"
|
assert_empty auth_headers, "Should have no headers for #{app[:domain]}, got: #{auth_headers.keys.join(", ")}"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
test "domain pattern edge cases" do
|
test "domain pattern edge cases" do
|
||||||
# Test various domain patterns
|
# 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 = [
|
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: "api.*.com", domains: ["api.example.com", "api.test.com"]},
|
||||||
{pattern: "*.*.example.com", domains: ["app.dev.example.com", "api.staging.example.com"]}
|
{pattern: "*.*.example.com", domains: ["app.dev.example.com", "api.staging.example.com"]}
|
||||||
]
|
]
|
||||||
@@ -329,12 +309,11 @@ class ForwardAuthSystemTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
# Performance System Tests
|
# Performance System Tests
|
||||||
test "system performance under load" do
|
test "system performance under load" do
|
||||||
# Create test application
|
# Create test application with wildcard pattern
|
||||||
Application.create!(name: "Load Test", slug: "loadtest", app_type: "forward_auth", domain_pattern: "loadtest.example.com", active: true)
|
Application.create!(name: "Load Test", slug: "loadtest", app_type: "forward_auth", domain_pattern: "*.loadtest.example.com", active: true)
|
||||||
|
|
||||||
# Sign in
|
# Sign in
|
||||||
post "/signin", params: {email_address: @user.email_address, password: "password"}
|
post "/signin", params: {email_address: @user.email_address, password: "password"}
|
||||||
session_cookie = cookies[:session_id]
|
|
||||||
|
|
||||||
# Performance test
|
# Performance test
|
||||||
start_time = Time.current
|
start_time = Time.current
|
||||||
@@ -345,8 +324,7 @@ class ForwardAuthSystemTest < ActionDispatch::IntegrationTest
|
|||||||
request_start = Time.current
|
request_start = Time.current
|
||||||
|
|
||||||
get "/api/verify", headers: {
|
get "/api/verify", headers: {
|
||||||
"X-Forwarded-Host" => "app#{i}.loadtest.example.com",
|
"X-Forwarded-Host" => "app#{i}.loadtest.example.com"
|
||||||
"Cookie" => "_clinch_session_id=#{session_cookie}"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
request_end = Time.current
|
request_end = Time.current
|
||||||
@@ -370,35 +348,4 @@ class ForwardAuthSystemTest < ActionDispatch::IntegrationTest
|
|||||||
rps = request_count / total_time
|
rps = request_count / total_time
|
||||||
assert rps > 10, "Requests per second #{rps} is too low"
|
assert rps > 10, "Requests per second #{rps} is too low"
|
||||||
end
|
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
|
end
|
||||||
@@ -27,7 +27,7 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
# Step 2: Sign in
|
# Step 2: Sign in
|
||||||
post "/signin", params: {email_address: @user.email_address, password: "password"}
|
post "/signin", params: {email_address: @user.email_address, password: "password"}
|
||||||
assert_response 302
|
assert_response 303
|
||||||
# Signin now redirects back with fa_token parameter
|
# Signin now redirects back with fa_token parameter
|
||||||
assert_match(/\?fa_token=/, response.location)
|
assert_match(/\?fa_token=/, response.location)
|
||||||
assert cookies[:session_id]
|
assert cookies[:session_id]
|
||||||
|
|||||||
@@ -54,45 +54,39 @@ class WebauthnSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
end
|
end
|
||||||
|
|
||||||
# ====================
|
# ====================
|
||||||
# USER HANDLE BINDING TESTS
|
# USER HANDLE SECURITY TESTS
|
||||||
# ====================
|
# ====================
|
||||||
|
|
||||||
test "user handle is properly bound to WebAuthn credential" do
|
test "WebAuthn challenge includes authenticated user's handle (not another user's)" do
|
||||||
user = User.create!(email_address: "webauthn_handle_test@example.com", password: "password123")
|
# 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
|
# Generate handles for both users
|
||||||
user_handle = SecureRandom.uuid
|
handle_a = user_a.webauthn_user_handle
|
||||||
credential = user.webauthn_credentials.create!(
|
handle_b = user_b.webauthn_user_handle
|
||||||
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
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify user handle is associated with the credential
|
# Sign in as User A
|
||||||
assert_equal user_handle, credential.user_handle
|
post signin_path, params: {email_address: user_a.email_address, password: "password123"}
|
||||||
|
assert_response :redirect
|
||||||
|
|
||||||
user.destroy
|
# Request WebAuthn challenge (for registration)
|
||||||
end
|
post webauthn_challenge_path, params: {email: user_a.email_address}
|
||||||
|
assert_response :success
|
||||||
|
|
||||||
test "WebAuthn authentication validates user handle" do
|
# Parse the JSON response
|
||||||
user = User.create!(email_address: "webauthn_handle_auth_test@example.com", password: "password123")
|
challenge_data = JSON.parse(response.body)
|
||||||
|
|
||||||
user_handle = SecureRandom.uuid
|
# SECURITY: Verify challenge includes User A's handle
|
||||||
user.webauthn_credentials.create!(
|
assert challenge_data.key?("user")
|
||||||
external_id: Base64.urlsafe_encode64("fake_credential_id"),
|
assert_equal handle_a, challenge_data["user"]["id"], "Challenge should include authenticated user's handle"
|
||||||
public_key: Base64.urlsafe_encode64("fake_public_key"),
|
assert_equal user_a.email_address, challenge_data["user"]["name"]
|
||||||
sign_count: 0,
|
|
||||||
nickname: "Test Key",
|
|
||||||
user_handle: user_handle
|
|
||||||
)
|
|
||||||
|
|
||||||
# Sign in with WebAuthn
|
# SECURITY: Verify challenge does NOT include User B's handle
|
||||||
# The implementation should verify the user handle matches
|
assert_not_equal handle_b, challenge_data["user"]["id"], "Challenge should NOT include another user's handle"
|
||||||
# This test documents the expected behavior
|
|
||||||
|
|
||||||
user.destroy
|
user_a.destroy
|
||||||
|
user_b.destroy
|
||||||
end
|
end
|
||||||
|
|
||||||
# ====================
|
# ====================
|
||||||
@@ -134,7 +128,10 @@ class WebauthnSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
nickname: "Test Key"
|
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"}
|
post webauthn_challenge_path, params: {email: "webauthn_verify_origin_test@example.com"}
|
||||||
assert_response :success
|
assert_response :success
|
||||||
|
|
||||||
@@ -230,8 +227,8 @@ class WebauthnSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
)
|
)
|
||||||
|
|
||||||
credential.reload
|
credential.reload
|
||||||
assert_equal "192.168.1.100", credential.last_ip_address
|
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.last_user_agent
|
assert_equal "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", credential.user_agent
|
||||||
|
|
||||||
user.destroy
|
user.destroy
|
||||||
end
|
end
|
||||||
@@ -316,7 +313,7 @@ class WebauthnSecurityTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
test "WebAuthn can be required for authentication" do
|
test "WebAuthn can be required for authentication" do
|
||||||
user = User.create!(email_address: "webauthn_required_test@example.com", password: "password123")
|
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
|
# Sign in with password should still work
|
||||||
post signin_path, params: {email_address: "webauthn_required_test@example.com", password: "password123"}
|
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
|
test "WebAuthn can be used for passwordless authentication" do
|
||||||
user = User.create!(email_address: "webauthn_passwordless_test@example.com", password: "password123")
|
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!(
|
user.webauthn_credentials.create!(
|
||||||
external_id: Base64.urlsafe_encode64("passwordless_credential"),
|
external_id: Base64.urlsafe_encode64("passwordless_credential"),
|
||||||
|
|||||||
136
test/lib/duration_parser_test.rb
Normal file
136
test/lib/duration_parser_test.rb
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class DurationParserTest < ActiveSupport::TestCase
|
||||||
|
# Valid formats
|
||||||
|
test "parses seconds" do
|
||||||
|
assert_equal 1, DurationParser.parse("1s")
|
||||||
|
assert_equal 30, DurationParser.parse("30s")
|
||||||
|
assert_equal 3600, DurationParser.parse("3600s")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "parses minutes" do
|
||||||
|
assert_equal 60, DurationParser.parse("1m")
|
||||||
|
assert_equal 300, DurationParser.parse("5m")
|
||||||
|
assert_equal 1800, DurationParser.parse("30m")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "parses hours" do
|
||||||
|
assert_equal 3600, DurationParser.parse("1h")
|
||||||
|
assert_equal 7200, DurationParser.parse("2h")
|
||||||
|
assert_equal 86400, DurationParser.parse("24h")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "parses days" do
|
||||||
|
assert_equal 86400, DurationParser.parse("1d")
|
||||||
|
assert_equal 172800, DurationParser.parse("2d")
|
||||||
|
assert_equal 2592000, DurationParser.parse("30d")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "parses weeks" do
|
||||||
|
assert_equal 604800, DurationParser.parse("1w")
|
||||||
|
assert_equal 1209600, DurationParser.parse("2w")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "parses months (30 days)" do
|
||||||
|
assert_equal 2592000, DurationParser.parse("1M")
|
||||||
|
assert_equal 5184000, DurationParser.parse("2M")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "parses years (365 days)" do
|
||||||
|
assert_equal 31536000, DurationParser.parse("1y")
|
||||||
|
assert_equal 63072000, DurationParser.parse("2y")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Plain numbers
|
||||||
|
test "parses plain integer as seconds" do
|
||||||
|
assert_equal 3600, DurationParser.parse(3600)
|
||||||
|
assert_equal 300, DurationParser.parse(300)
|
||||||
|
assert_equal 0, DurationParser.parse(0)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "parses plain numeric string as seconds" do
|
||||||
|
assert_equal 3600, DurationParser.parse("3600")
|
||||||
|
assert_equal 300, DurationParser.parse("300")
|
||||||
|
assert_equal 0, DurationParser.parse("0")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Whitespace handling
|
||||||
|
test "handles leading and trailing whitespace" do
|
||||||
|
assert_equal 3600, DurationParser.parse(" 1h ")
|
||||||
|
assert_equal 300, DurationParser.parse(" 5m ")
|
||||||
|
assert_equal 86400, DurationParser.parse("\t1d\n")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "handles space between number and unit" do
|
||||||
|
assert_equal 3600, DurationParser.parse("1 h")
|
||||||
|
assert_equal 300, DurationParser.parse("5 m")
|
||||||
|
assert_equal 86400, DurationParser.parse("1 d")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Case sensitivity - only lowercase units work (except M for months)
|
||||||
|
test "lowercase units work" do
|
||||||
|
assert_equal 1, DurationParser.parse("1s")
|
||||||
|
assert_equal 60, DurationParser.parse("1m") # minute (lowercase)
|
||||||
|
assert_equal 3600, DurationParser.parse("1h")
|
||||||
|
assert_equal 86400, DurationParser.parse("1d")
|
||||||
|
assert_equal 604800, DurationParser.parse("1w")
|
||||||
|
assert_equal 31536000, DurationParser.parse("1y")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "uppercase M for months works" do
|
||||||
|
assert_equal 2592000, DurationParser.parse("1M") # month (uppercase)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns nil for wrong case" do
|
||||||
|
assert_nil DurationParser.parse("1S") # Should be 1s
|
||||||
|
assert_nil DurationParser.parse("1H") # Should be 1h
|
||||||
|
assert_nil DurationParser.parse("1D") # Should be 1d
|
||||||
|
assert_nil DurationParser.parse("1W") # Should be 1w
|
||||||
|
assert_nil DurationParser.parse("1Y") # Should be 1y
|
||||||
|
end
|
||||||
|
|
||||||
|
# Edge cases
|
||||||
|
test "handles zero duration" do
|
||||||
|
assert_equal 0, DurationParser.parse("0s")
|
||||||
|
assert_equal 0, DurationParser.parse("0m")
|
||||||
|
assert_equal 0, DurationParser.parse("0h")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "handles large numbers" do
|
||||||
|
assert_equal 86400000, DurationParser.parse("1000d")
|
||||||
|
assert_equal 360000, DurationParser.parse("100h")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Invalid formats - should return nil (not raise)
|
||||||
|
test "returns nil for invalid format" do
|
||||||
|
assert_nil DurationParser.parse("invalid")
|
||||||
|
assert_nil DurationParser.parse("1x")
|
||||||
|
assert_nil DurationParser.parse("abc")
|
||||||
|
assert_nil DurationParser.parse("1.5h") # No decimals
|
||||||
|
assert_nil DurationParser.parse("-1h") # No negatives
|
||||||
|
assert_nil DurationParser.parse("h1") # Wrong order
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns nil for blank input" do
|
||||||
|
assert_nil DurationParser.parse("")
|
||||||
|
assert_nil DurationParser.parse(nil)
|
||||||
|
assert_nil DurationParser.parse(" ")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns nil for multiple units" do
|
||||||
|
assert_nil DurationParser.parse("1h30m") # Keep it simple, don't support this
|
||||||
|
assert_nil DurationParser.parse("1d2h")
|
||||||
|
end
|
||||||
|
|
||||||
|
# String coercion
|
||||||
|
test "handles string input" do
|
||||||
|
assert_equal 3600, DurationParser.parse("1h")
|
||||||
|
assert_equal 3600, DurationParser.parse(:"1h") # Symbol
|
||||||
|
end
|
||||||
|
|
||||||
|
# Boundary validation (not parser's job, but good to know)
|
||||||
|
test "parses values outside typical TTL ranges without error" do
|
||||||
|
assert_equal 1, DurationParser.parse("1s") # Below min access_token_ttl
|
||||||
|
assert_equal 315360000, DurationParser.parse("10y") # Above max refresh_token_ttl
|
||||||
|
end
|
||||||
|
end
|
||||||
109
test/models/application_duration_parser_test.rb
Normal file
109
test/models/application_duration_parser_test.rb
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class ApplicationDurationParserTest < ActiveSupport::TestCase
|
||||||
|
test "access_token_ttl accepts human-friendly durations" do
|
||||||
|
app = Application.new(access_token_ttl: "1h")
|
||||||
|
assert_equal 3600, app.access_token_ttl
|
||||||
|
|
||||||
|
app.access_token_ttl = "30m"
|
||||||
|
assert_equal 1800, app.access_token_ttl
|
||||||
|
|
||||||
|
app.access_token_ttl = "5m"
|
||||||
|
assert_equal 300, app.access_token_ttl
|
||||||
|
end
|
||||||
|
|
||||||
|
test "refresh_token_ttl accepts human-friendly durations" do
|
||||||
|
app = Application.new(refresh_token_ttl: "30d")
|
||||||
|
assert_equal 2592000, app.refresh_token_ttl
|
||||||
|
|
||||||
|
app.refresh_token_ttl = "1M"
|
||||||
|
assert_equal 2592000, app.refresh_token_ttl
|
||||||
|
|
||||||
|
app.refresh_token_ttl = "7d"
|
||||||
|
assert_equal 604800, app.refresh_token_ttl
|
||||||
|
end
|
||||||
|
|
||||||
|
test "id_token_ttl accepts human-friendly durations" do
|
||||||
|
app = Application.new(id_token_ttl: "1h")
|
||||||
|
assert_equal 3600, app.id_token_ttl
|
||||||
|
|
||||||
|
app.id_token_ttl = "2h"
|
||||||
|
assert_equal 7200, app.id_token_ttl
|
||||||
|
end
|
||||||
|
|
||||||
|
test "TTL fields still accept plain numbers" do
|
||||||
|
app = Application.new(
|
||||||
|
access_token_ttl: 3600,
|
||||||
|
refresh_token_ttl: 2592000,
|
||||||
|
id_token_ttl: 3600
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_equal 3600, app.access_token_ttl
|
||||||
|
assert_equal 2592000, app.refresh_token_ttl
|
||||||
|
assert_equal 3600, app.id_token_ttl
|
||||||
|
end
|
||||||
|
|
||||||
|
test "TTL fields accept plain number strings" do
|
||||||
|
app = Application.new(
|
||||||
|
access_token_ttl: "3600",
|
||||||
|
refresh_token_ttl: "2592000",
|
||||||
|
id_token_ttl: "3600"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_equal 3600, app.access_token_ttl
|
||||||
|
assert_equal 2592000, app.refresh_token_ttl
|
||||||
|
assert_equal 3600, app.id_token_ttl
|
||||||
|
end
|
||||||
|
|
||||||
|
test "invalid TTL values are set to nil" do
|
||||||
|
app = Application.new(
|
||||||
|
access_token_ttl: "invalid",
|
||||||
|
refresh_token_ttl: "bad",
|
||||||
|
id_token_ttl: "nope"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_nil app.access_token_ttl
|
||||||
|
assert_nil app.refresh_token_ttl
|
||||||
|
assert_nil app.id_token_ttl
|
||||||
|
end
|
||||||
|
|
||||||
|
test "validation still works with parsed values" do
|
||||||
|
app = Application.new(
|
||||||
|
name: "Test",
|
||||||
|
slug: "test",
|
||||||
|
app_type: "oidc",
|
||||||
|
redirect_uris: "https://example.com/callback"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Too short (below 5 minutes)
|
||||||
|
app.access_token_ttl = "1m"
|
||||||
|
assert_not app.valid?
|
||||||
|
assert_includes app.errors[:access_token_ttl], "must be greater than or equal to 300"
|
||||||
|
|
||||||
|
# Too long (above 24 hours for access token)
|
||||||
|
app.access_token_ttl = "2d"
|
||||||
|
assert_not app.valid?
|
||||||
|
assert_includes app.errors[:access_token_ttl], "must be less than or equal to 86400"
|
||||||
|
|
||||||
|
# Just right
|
||||||
|
app.access_token_ttl = "1h"
|
||||||
|
app.valid? # Revalidate
|
||||||
|
assert app.errors[:access_token_ttl].blank?
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can create OIDC app with human-friendly TTL values" do
|
||||||
|
app = Application.create!(
|
||||||
|
name: "Test App",
|
||||||
|
slug: "test-app",
|
||||||
|
app_type: "oidc",
|
||||||
|
redirect_uris: "https://example.com/callback",
|
||||||
|
access_token_ttl: "1h",
|
||||||
|
refresh_token_ttl: "30d",
|
||||||
|
id_token_ttl: "2h"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_equal 3600, app.access_token_ttl
|
||||||
|
assert_equal 2592000, app.refresh_token_ttl
|
||||||
|
assert_equal 7200, app.id_token_ttl
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -319,4 +319,35 @@ class UserTest < ActiveSupport::TestCase
|
|||||||
|
|
||||||
# Note: parsed_backup_codes method and legacy tests removed
|
# Note: parsed_backup_codes method and legacy tests removed
|
||||||
# All users now use BCrypt hashes stored in JSON column
|
# 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
|
end
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "should generate id token with required claims" do
|
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_not_nil token, "Should generate token"
|
||||||
assert token.length > 100, "Token should be substantial"
|
assert token.length > 100, "Token should be substantial"
|
||||||
@@ -88,7 +88,7 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
|
|||||||
admin_group = groups(:admin_group)
|
admin_group = groups(:admin_group)
|
||||||
@user.groups << admin_group unless @user.groups.include?(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
|
decoded = JWT.decode(token, nil, false).first
|
||||||
assert_includes decoded["groups"], "Administrators", "Should include user's groups"
|
assert_includes decoded["groups"], "Administrators", "Should include user's groups"
|
||||||
@@ -248,10 +248,10 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "should handle access token generation" do
|
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
|
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_includes decoded.keys, "email_verified"
|
||||||
assert_equal @user.id.to_s, decoded["sub"], "Should decode subject correctly"
|
assert_equal @user.id.to_s, decoded["sub"], "Should decode subject correctly"
|
||||||
assert_equal @application.client_id, decoded["aud"], "Should decode audience 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"}
|
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
|
decoded = JWT.decode(token, nil, false).first
|
||||||
|
|
||||||
assert_equal ["admin"], decoded["app_groups"]
|
assert_equal ["admin"], decoded["app_groups"]
|
||||||
@@ -305,7 +305,7 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
|
|||||||
custom_claims: {role: "admin", app_specific: true}
|
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
|
decoded = JWT.decode(token, nil, false).first
|
||||||
|
|
||||||
# App-specific claim should win
|
# App-specific claim should win
|
||||||
@@ -330,7 +330,7 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
|
|||||||
# User adds roles: ["admin"]
|
# User adds roles: ["admin"]
|
||||||
user.update!(custom_claims: {"roles" => ["admin"], "permissions" => ["write"]})
|
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
|
decoded = JWT.decode(token, nil, false).first
|
||||||
|
|
||||||
# Roles should be combined (not overwritten)
|
# Roles should be combined (not overwritten)
|
||||||
@@ -360,7 +360,7 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
|
|||||||
# User adds roles: ["admin"]
|
# User adds roles: ["admin"]
|
||||||
user.update!(custom_claims: {"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
|
decoded = JWT.decode(token, nil, false).first
|
||||||
|
|
||||||
# All roles should be combined
|
# All roles should be combined
|
||||||
@@ -382,7 +382,7 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
|
|||||||
# User also has "user" role (duplicate)
|
# User also has "user" role (duplicate)
|
||||||
user.update!(custom_claims: {"roles" => ["user", "admin"]})
|
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
|
decoded = JWT.decode(token, nil, false).first
|
||||||
|
|
||||||
# "user" should only appear once
|
# "user" should only appear once
|
||||||
@@ -404,7 +404,7 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
|
|||||||
# User overrides max_items and theme, adds to roles
|
# User overrides max_items and theme, adds to roles
|
||||||
user.update!(custom_claims: {"roles" => ["admin"], "max_items" => 100, "theme" => "dark"})
|
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
|
decoded = JWT.decode(token, nil, false).first
|
||||||
|
|
||||||
# Arrays should be combined
|
# 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
|
decoded = JWT.decode(token, nil, false).first
|
||||||
|
|
||||||
# Nested hashes should be deep merged
|
# Nested hashes should be deep merged
|
||||||
@@ -467,7 +467,7 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
|
|||||||
custom_claims: {"roles" => ["app_admin"]}
|
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
|
decoded = JWT.decode(token, nil, false).first
|
||||||
|
|
||||||
# All three sources should be combined
|
# All three sources should be combined
|
||||||
@@ -562,4 +562,133 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
|
|||||||
assert_includes decoded.keys, "azp", "Should include azp claim"
|
assert_includes decoded.keys, "azp", "Should include azp claim"
|
||||||
assert_equal @application.client_id, decoded["azp"], "azp should be the application's client_id"
|
assert_equal @application.client_id, decoded["azp"], "azp should be the application's client_id"
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
Reference in New Issue
Block a user