Compare commits
34 Commits
c1c6e0112e
...
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 |
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
|
||||||
@@ -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
|
||||||
|
|||||||
185
README.md
185
README.md
@@ -1,8 +1,10 @@
|
|||||||
# 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**
|
||||||
|
|
||||||
Clinch gives you one place to manage users and lets any web app authenticate against it without managing its own users.
|
Clinch gives you one place to manage users and lets any web app authenticate against it without managing its own users.
|
||||||
@@ -73,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
|
||||||
@@ -306,21 +311,112 @@ bin/dev
|
|||||||
|
|
||||||
## Production Deployment
|
## Production Deployment
|
||||||
|
|
||||||
### Docker
|
### 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 \
|
**Then create `.env`:**
|
||||||
-e SMTP_USERNAME=your-username \
|
|
||||||
-e SMTP_PASSWORD=your-password \
|
```bash
|
||||||
clinch
|
# 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
|
### Backup & Restore
|
||||||
@@ -336,9 +432,6 @@ Use SQLite's `VACUUM INTO` command for safe, atomic backups of a running databas
|
|||||||
```bash
|
```bash
|
||||||
# Local development
|
# Local development
|
||||||
sqlite3 storage/production.sqlite3 "VACUUM INTO 'backup.sqlite3';"
|
sqlite3 storage/production.sqlite3 "VACUUM INTO 'backup.sqlite3';"
|
||||||
|
|
||||||
# Docker
|
|
||||||
docker exec clinch sqlite3 /rails/storage/production.sqlite3 "VACUUM INTO '/rails/storage/backup.sqlite3';"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
This creates an optimized copy of the database that's safe to make even while Clinch is running.
|
This creates an optimized copy of the database that's safe to make even while Clinch is running.
|
||||||
@@ -354,9 +447,9 @@ sqlite3 storage/production.sqlite3 "VACUUM INTO 'backup-$(date +%Y%m%d).sqlite3'
|
|||||||
# 2. Backup uploaded files (ActiveStorage files are immutable)
|
# 2. Backup uploaded files (ActiveStorage files are immutable)
|
||||||
tar -czf uploads-backup-$(date +%Y%m%d).tar.gz storage/uploads/
|
tar -czf uploads-backup-$(date +%Y%m%d).tar.gz storage/uploads/
|
||||||
|
|
||||||
# Docker equivalent
|
# Docker Compose equivalent
|
||||||
docker exec clinch sqlite3 /rails/storage/production.sqlite3 "VACUUM INTO '/rails/storage/backup-$(date +%Y%m%d).sqlite3';"
|
docker compose exec clinch sqlite3 /rails/storage/production.sqlite3 "VACUUM INTO '/rails/storage/backup-$(date +%Y%m%d).sqlite3';"
|
||||||
docker exec clinch tar -czf /rails/storage/uploads-backup-$(date +%Y%m%d).tar.gz /rails/storage/uploads/
|
docker compose exec clinch tar -czf /rails/storage/uploads-backup-$(date +%Y%m%d).tar.gz /rails/storage/uploads/
|
||||||
```
|
```
|
||||||
|
|
||||||
**Restore:**
|
**Restore:**
|
||||||
@@ -383,13 +476,13 @@ sqlite3 /host/path/production.sqlite3 "VACUUM INTO '/host/path/backup-$(date +%Y
|
|||||||
rsync -av /host/path/backup-*.sqlite3 /host/path/uploads/ remote:/backups/clinch/
|
rsync -av /host/path/backup-*.sqlite3 /host/path/uploads/ remote:/backups/clinch/
|
||||||
```
|
```
|
||||||
|
|
||||||
b) **Docker volumes** (e.g., `-v clinch_storage:/rails/storage`):
|
b) **Docker volumes** (e.g., using named volumes in compose):
|
||||||
```bash
|
```bash
|
||||||
# Database backup (safe while running)
|
# Database backup (safe while running)
|
||||||
docker exec clinch sqlite3 /rails/storage/production.sqlite3 "VACUUM INTO '/rails/storage/backup.sqlite3';"
|
docker compose exec clinch sqlite3 /rails/storage/production.sqlite3 "VACUUM INTO '/rails/storage/backup.sqlite3';"
|
||||||
|
|
||||||
# Copy out of container
|
# Copy out of container
|
||||||
docker cp clinch:/rails/storage/backup.sqlite3 ./backup-$(date +%Y%m%d).sqlite3
|
docker compose cp clinch:/rails/storage/backup.sqlite3 ./backup-$(date +%Y%m%d).sqlite3
|
||||||
```
|
```
|
||||||
|
|
||||||
**Option 2: While Stopped (Offline Backup)**
|
**Option 2: While Stopped (Offline Backup)**
|
||||||
@@ -414,35 +507,7 @@ docker compose up -d
|
|||||||
|
|
||||||
## 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)
|
||||||
@@ -667,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
|
||||||
|
# Production: Use SameSite=None to allow cross-site cookies (needed for OIDC conformance testing)
|
||||||
|
# Development: Use SameSite=Lax since HTTPS might not be available
|
||||||
|
cookie_options = if Rails.env.production?
|
||||||
|
{
|
||||||
|
value: session.id,
|
||||||
|
httponly: true,
|
||||||
|
same_site: :none, # Allow cross-site cookies for OIDC testing
|
||||||
|
secure: true # Required for SameSite=None
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
value: session.id,
|
value: session.id,
|
||||||
httponly: true,
|
httponly: true,
|
||||||
same_site: :lax,
|
same_site: :lax,
|
||||||
secure: Rails.env.production?
|
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,19 +819,49 @@ 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")
|
||||||
|
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)
|
claims[:groups] = user.groups.pluck(:name)
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# Merge custom claims from groups
|
# Merge custom claims from groups
|
||||||
user.groups.each do |group|
|
user.groups.each do |group|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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?
|
||||||
|
if should_include_claim?("groups", id_token_claims)
|
||||||
payload[:groups] = user.groups.pluck(:name)
|
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">
|
||||||
|
<div>
|
||||||
|
<p class="font-medium text-gray-900 mb-1">Token Types:</p>
|
||||||
<p><strong>Access Token:</strong> Used to access protected resources (APIs). Shorter lifetime = more secure. Users won't notice automatic refreshes.</p>
|
<p><strong>Access Token:</strong> Used to access protected resources (APIs). Shorter lifetime = more secure. Users won't notice automatic refreshes.</p>
|
||||||
<p><strong>Refresh Token:</strong> Used to get new access tokens without re-authentication. Longer lifetime = better UX (less re-logins).</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>
|
<p><strong>ID Token:</strong> Contains user identity information (JWT). Should match access token lifetime in most cases.</p>
|
||||||
<p class="text-xs italic mt-2">💡 Tip: Banking apps use 5-15 min access tokens. Internal tools use 1-4 hours.</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.2"
|
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
|
||||||
@@ -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)
|
||||||
- [x] 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,8 +193,7 @@ 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
|
||||||
@@ -192,21 +204,32 @@ This checklist ensures Clinch meets security, quality, and documentation standar
|
|||||||
- [ ] 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
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ class ForwardAuthAdvancedTest < 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
|
||||||
redirect_uri = URI.parse(response.location)
|
redirect_uri = URI.parse(response.location)
|
||||||
assert_equal "https", redirect_uri.scheme
|
assert_equal "https", redirect_uri.scheme
|
||||||
assert_equal "app.example.com", redirect_uri.host
|
assert_equal "app.example.com", redirect_uri.host
|
||||||
@@ -64,7 +64,7 @@ class ForwardAuthAdvancedTest < 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
|
||||||
@@ -101,7 +101,7 @@ class ForwardAuthAdvancedTest < 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"}
|
||||||
@@ -139,7 +139,7 @@ class ForwardAuthAdvancedTest < 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"}
|
||||||
@@ -255,7 +255,7 @@ class ForwardAuthAdvancedTest < 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|
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
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
|
||||||
@@ -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