Compare commits
17
Commits
v0.16.2
...
51ddb42bc7
+105
-28
@@ -1,22 +1,63 @@
|
||||
name: Build and publish image
|
||||
|
||||
# Publishes the multi-arch image (amd64 + arm64) to GitHub Packages
|
||||
# (ghcr.io/dkam/clinch) whenever config/initializers/version.rb changes on
|
||||
# main — a version bump IS the release. Each arch builds natively (no QEMU); a
|
||||
# merge job stitches them into one manifest tagged :vX.Y.Z (+ :latest for
|
||||
# non-pre-releases).
|
||||
#
|
||||
# To cut a release: edit Clinch::VERSION in config/initializers/version.rb,
|
||||
# commit, push. For a dev build: set a pre-release version (e.g. "1.1.0-dev") —
|
||||
# it publishes :v1.1.0-dev but does not move :latest. Or run this workflow
|
||||
# manually from the Actions tab.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
tags: [ 'v*' ]
|
||||
paths:
|
||||
- config/initializers/version.rb
|
||||
workflow_dispatch:
|
||||
|
||||
# Only one build per ref at a time; cancel superseded main builds.
|
||||
concurrency:
|
||||
group: build-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref == 'refs/heads/main' }}
|
||||
env:
|
||||
IMAGE: ghcr.io/${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
# Read the SemVer constant; decide whether this release moves :latest.
|
||||
prepare:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
latest: ${{ steps.version.outputs.latest }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Read version from config/initializers/version.rb
|
||||
id: version
|
||||
run: |
|
||||
V=$(ruby -e "require './config/initializers/version'; puts Clinch::VERSION")
|
||||
echo "version=$V" >> "$GITHUB_OUTPUT"
|
||||
# A pre-release (e.g. 1.1.0-dev) publishes its own tag but not :latest.
|
||||
if [[ "$V" == *-* ]]; then latest=false; else latest=true; fi
|
||||
echo "latest=$latest" >> "$GITHUB_OUTPUT"
|
||||
echo "Building v$V (move :latest = $latest)"
|
||||
|
||||
build:
|
||||
needs: prepare
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
arch: amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
arch: arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write # Required to push to GHCR
|
||||
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
@@ -31,26 +72,62 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract image metadata (tags, labels)
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=edge,branch=main
|
||||
type=sha,prefix=sha-,format=short,enable={{is_default_branch}}
|
||||
type=semver,pattern=v{{version}}
|
||||
type=semver,pattern=v{{major}}.{{minor}}
|
||||
flavor: |
|
||||
latest=auto
|
||||
|
||||
- name: Build and push
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
platforms: ${{ matrix.platform }}
|
||||
cache-from: type=gha,scope=${{ matrix.arch }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.arch }}
|
||||
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-${{ matrix.arch }}
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
needs: [prepare, build]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Create and push the multi-arch manifest
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
tags="-t ${{ env.IMAGE }}:v${{ needs.prepare.outputs.version }}"
|
||||
if [ "${{ needs.prepare.outputs.latest }}" = "true" ]; then
|
||||
tags="$tags -t ${{ env.IMAGE }}:latest"
|
||||
fi
|
||||
docker buildx imagetools create $tags $(printf '${{ env.IMAGE }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect result
|
||||
run: docker buildx imagetools inspect ${{ env.IMAGE }}:latest
|
||||
|
||||
@@ -70,3 +70,6 @@ yarn-debug.log*
|
||||
|
||||
# Ignore bootsnap cache
|
||||
/tmp/cache/bootsnap*
|
||||
|
||||
# Local-only: do not publish the security findings tracker
|
||||
SECURITY_REVIEW_TODO.md
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
4.0.5
|
||||
4.0.6
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@
|
||||
# For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html
|
||||
|
||||
# Make sure RUBY_VERSION matches the Ruby version in .ruby-version
|
||||
ARG RUBY_VERSION=4.0.5
|
||||
ARG RUBY_VERSION=4.0.6
|
||||
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
|
||||
|
||||
LABEL org.opencontainers.image.source=https://github.com/dkam/clinch
|
||||
|
||||
+40
-45
@@ -111,16 +111,15 @@ GEM
|
||||
cose (1.3.1)
|
||||
cbor (~> 0.5.9)
|
||||
openssl-signature_algorithm (~> 1.0)
|
||||
crass (1.0.6)
|
||||
crass (1.0.7)
|
||||
date (3.5.1)
|
||||
debug (1.11.1)
|
||||
irb (~> 1.10)
|
||||
reline (>= 0.3.8)
|
||||
docile (1.4.1)
|
||||
dotenv (3.2.0)
|
||||
drb (2.2.3)
|
||||
ed25519 (1.4.0)
|
||||
erb (6.0.4)
|
||||
erb (6.0.5)
|
||||
erubi (1.13.1)
|
||||
et-orbi (1.4.0)
|
||||
tzinfo
|
||||
@@ -131,10 +130,10 @@ GEM
|
||||
ffi (1.17.4-arm64-darwin)
|
||||
ffi (1.17.4-x86_64-linux-gnu)
|
||||
ffi (1.17.4-x86_64-linux-musl)
|
||||
fugit (1.12.2)
|
||||
fugit (1.13.0)
|
||||
et-orbi (~> 1.4)
|
||||
raabro (~> 1.4)
|
||||
globalid (1.3.0)
|
||||
globalid (1.4.0)
|
||||
activesupport (>= 6.1)
|
||||
i18n (1.15.2)
|
||||
concurrent-ruby (~> 1.0)
|
||||
@@ -154,7 +153,7 @@ GEM
|
||||
jbuilder (2.15.1)
|
||||
actionview (>= 7.0.0)
|
||||
activesupport (>= 7.0.0)
|
||||
json (2.19.9)
|
||||
json (2.21.1)
|
||||
jwt (3.2.0)
|
||||
base64
|
||||
kamal (2.12.0)
|
||||
@@ -168,7 +167,7 @@ GEM
|
||||
sshkit (>= 1.23.0, < 2.0)
|
||||
thor (~> 1.3)
|
||||
zeitwerk (>= 2.6.18, < 3.0)
|
||||
language_server-protocol (3.17.0.5)
|
||||
language_server-protocol (3.17.0.6)
|
||||
launchy (3.1.1)
|
||||
addressable (~> 2.8)
|
||||
childprocess (~> 5.0)
|
||||
@@ -177,10 +176,10 @@ GEM
|
||||
launchy (>= 2.2, < 4)
|
||||
lint_roller (1.1.0)
|
||||
logger (1.7.0)
|
||||
loofah (2.25.1)
|
||||
loofah (2.25.2)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.12.0)
|
||||
mail (2.9.0)
|
||||
mail (2.9.1)
|
||||
logger
|
||||
mini_mime (>= 0.1.1)
|
||||
net-imap
|
||||
@@ -188,7 +187,7 @@ GEM
|
||||
net-smtp
|
||||
marcel (1.2.1)
|
||||
matrix (0.4.3)
|
||||
mini_magick (5.3.1)
|
||||
mini_magick (5.3.2)
|
||||
logger
|
||||
mini_mime (1.1.5)
|
||||
minitest (5.27.0)
|
||||
@@ -206,7 +205,7 @@ GEM
|
||||
net-ssh (>= 5.0.0, < 8.0.0)
|
||||
net-smtp (0.5.1)
|
||||
net-protocol
|
||||
net-ssh (7.3.2)
|
||||
net-ssh (7.3.3)
|
||||
nio4r (2.7.5)
|
||||
nokogiri (1.19.4-aarch64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
@@ -227,10 +226,10 @@ GEM
|
||||
openssl (> 2.0)
|
||||
ostruct (0.6.3)
|
||||
parallel (2.1.0)
|
||||
parser (3.3.11.1)
|
||||
parser (3.3.12.0)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
pp (0.6.3)
|
||||
pp (0.6.4)
|
||||
prettyprint
|
||||
prettyprint (0.2.0)
|
||||
prism (1.9.0)
|
||||
@@ -238,9 +237,6 @@ GEM
|
||||
actionpack (>= 7.0.0)
|
||||
activesupport (>= 7.0.0)
|
||||
rack
|
||||
psych (5.4.0)
|
||||
date
|
||||
stringio
|
||||
public_suffix (7.0.5)
|
||||
puma (8.0.2)
|
||||
nio4r (~> 2.0)
|
||||
@@ -272,8 +268,8 @@ GEM
|
||||
activesupport (>= 5.0.0)
|
||||
minitest
|
||||
nokogiri (>= 1.6)
|
||||
rails-html-sanitizer (1.7.0)
|
||||
loofah (~> 2.25)
|
||||
rails-html-sanitizer (1.7.1)
|
||||
loofah (~> 2.25, >= 2.25.2)
|
||||
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
|
||||
railties (8.1.3)
|
||||
actionpack (= 8.1.3)
|
||||
@@ -286,9 +282,14 @@ GEM
|
||||
zeitwerk (~> 2.6)
|
||||
rainbow (3.1.1)
|
||||
rake (13.4.2)
|
||||
rdoc (7.2.0)
|
||||
rbs (4.0.3)
|
||||
logger
|
||||
prism (>= 1.6.0)
|
||||
tsort
|
||||
rdoc (8.0.0)
|
||||
erb
|
||||
psych (>= 4.0.0)
|
||||
prism (>= 1.6.0)
|
||||
rbs (>= 4.0.0)
|
||||
tsort
|
||||
regexp_parser (2.12.0)
|
||||
reline (0.6.3)
|
||||
@@ -299,7 +300,7 @@ GEM
|
||||
chunky_png (~> 1.0)
|
||||
rqrcode_core (~> 2.0)
|
||||
rqrcode_core (2.1.0)
|
||||
rubocop (1.87.0)
|
||||
rubocop (1.88.2)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (~> 3.17.0.2)
|
||||
lint_roller (~> 1.1.0)
|
||||
@@ -310,7 +311,7 @@ GEM
|
||||
rubocop-ast (>= 1.49.0, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 2.4.0, < 4.0)
|
||||
rubocop-ast (1.49.1)
|
||||
rubocop-ast (1.50.0)
|
||||
parser (>= 3.3.7.2)
|
||||
prism (~> 1.7)
|
||||
rubocop-performance (1.26.1)
|
||||
@@ -321,11 +322,11 @@ GEM
|
||||
ruby-vips (2.3.0)
|
||||
ffi (~> 1.12)
|
||||
logger
|
||||
rubyzip (3.4.0)
|
||||
rubyzip (3.4.1)
|
||||
safety_net_attestation (0.5.0)
|
||||
jwt (>= 2.0, < 4.0)
|
||||
securerandom (0.4.1)
|
||||
selenium-webdriver (4.45.0)
|
||||
selenium-webdriver (4.46.0)
|
||||
base64 (~> 0.2)
|
||||
logger (~> 1.4)
|
||||
rexml (~> 3.2, >= 3.2.5)
|
||||
@@ -338,13 +339,8 @@ GEM
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
logger
|
||||
simplecov (0.22.0)
|
||||
docile (~> 1.1)
|
||||
simplecov-html (~> 0.11)
|
||||
simplecov_json_formatter (~> 0.1)
|
||||
simplecov-html (0.13.2)
|
||||
simplecov_json_formatter (0.1.4)
|
||||
solid_cable (4.0.0)
|
||||
simplecov (1.0.2)
|
||||
solid_cable (4.0.2)
|
||||
actioncable (>= 7.2)
|
||||
activejob (>= 7.2)
|
||||
activerecord (>= 7.2)
|
||||
@@ -374,10 +370,10 @@ GEM
|
||||
net-sftp (>= 2.1.2)
|
||||
net-ssh (>= 2.8.0)
|
||||
ostruct
|
||||
standard (1.55.0)
|
||||
standard (1.56.0)
|
||||
language_server-protocol (~> 3.17.0.2)
|
||||
lint_roller (~> 1.0)
|
||||
rubocop (~> 1.87.0)
|
||||
rubocop (~> 1.88.0)
|
||||
standard-custom (~> 1.0.0)
|
||||
standard-performance (~> 1.8)
|
||||
standard-custom (1.0.2)
|
||||
@@ -388,21 +384,20 @@ GEM
|
||||
rubocop-performance (~> 1.26.0)
|
||||
stimulus-rails (1.3.4)
|
||||
railties (>= 6.0.0)
|
||||
stringio (3.2.0)
|
||||
tailwindcss-rails (4.6.0)
|
||||
railties (>= 7.0.0)
|
||||
tailwindcss-ruby (~> 4.0)
|
||||
tailwindcss-ruby (4.3.1)
|
||||
tailwindcss-ruby (4.3.1-aarch64-linux-gnu)
|
||||
tailwindcss-ruby (4.3.1-aarch64-linux-musl)
|
||||
tailwindcss-ruby (4.3.1-arm64-darwin)
|
||||
tailwindcss-ruby (4.3.1-x86_64-linux-gnu)
|
||||
tailwindcss-ruby (4.3.1-x86_64-linux-musl)
|
||||
tailwindcss-ruby (4.3.2)
|
||||
tailwindcss-ruby (4.3.2-aarch64-linux-gnu)
|
||||
tailwindcss-ruby (4.3.2-aarch64-linux-musl)
|
||||
tailwindcss-ruby (4.3.2-arm64-darwin)
|
||||
tailwindcss-ruby (4.3.2-x86_64-linux-gnu)
|
||||
tailwindcss-ruby (4.3.2-x86_64-linux-musl)
|
||||
thor (1.5.0)
|
||||
thruster (0.1.21)
|
||||
thruster (0.1.21-aarch64-linux)
|
||||
thruster (0.1.21-arm64-darwin)
|
||||
thruster (0.1.21-x86_64-linux)
|
||||
thruster (0.1.23)
|
||||
thruster (0.1.23-aarch64-linux)
|
||||
thruster (0.1.23-arm64-darwin)
|
||||
thruster (0.1.23-x86_64-linux)
|
||||
timeout (0.6.1)
|
||||
tpm-key_attestation (0.14.1)
|
||||
bindata (~> 2.4)
|
||||
@@ -432,7 +427,7 @@ GEM
|
||||
safety_net_attestation (~> 0.5.0)
|
||||
tpm-key_attestation (~> 0.14.0)
|
||||
websocket (1.2.11)
|
||||
websocket-driver (0.8.1)
|
||||
websocket-driver (0.8.2)
|
||||
base64
|
||||
websocket-extensions (>= 0.1.0)
|
||||
websocket-extensions (0.1.5)
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
# Security Review — Tracking
|
||||
|
||||
Status of findings from the multi-surface security review (OIDC/OAuth2, ForwardAuth,
|
||||
WebAuthn/TOTP, sessions, admin/config). Work landed on branch
|
||||
`security/forward-auth-and-consent-csrf`.
|
||||
|
||||
## ✅ Done (branch `security/forward-auth-and-consent-csrf`)
|
||||
|
||||
All HIGH findings are closed. Each fix has tests; suite is green.
|
||||
|
||||
| Commit | Fix | Sev |
|
||||
|--------|-----|-----|
|
||||
| `703d24e` | ForwardAuth fail-open when no host header; consent endpoint CSRF | HIGH ×2 |
|
||||
| `8a095e4` | Bearer API-key skipped group check at use-time | HIGH |
|
||||
| `96a657e` | Open redirect via unvalidated `X-Forwarded-Host` in login redirect | HIGH |
|
||||
| `84ed462` | `CLINCH_HOST` made mandatory in deployed envs; dropped request-host fallback | MEDIUM |
|
||||
| `f38ac2e` | TOTP code replay within drift window (+ latent plaintext backup-code bug) | HIGH |
|
||||
| `406a79d` | SSRF via `backchannel_logout_uri` (metadata/loopback/RFC1918) | HIGH |
|
||||
| `57d7d1f` | Host-auth regex unanchored (`evil-example.com` matched) | HIGH |
|
||||
| `89bd5f1` | Disabled user could complete 2FA mid-flow / keep session; enforce active status | HIGH |
|
||||
| `cd862c7` | TOTP/backup/OAuth/PKCE `code` params not filtered from logs | MEDIUM |
|
||||
| `2426687` | `revoke_family!` didn't revoke access tokens on refresh-token reuse | HIGH |
|
||||
| `44892e3` | WebAuthn clone detection logged but didn't block; false-positive on synced passkeys | HIGH |
|
||||
| `d49e7ce` | CSP `unsafe-inline` removed (script-src + style-src → nonces) | HIGH |
|
||||
|
||||
**Verified false positive (no change):** PKCE *is* required by default —
|
||||
`require_pkce` column defaults to `true` (`db/schema.rb`), token endpoint enforces
|
||||
it, admin UI exposes the opt-out. Operational check: confirm no legacy confidential
|
||||
apps sit on `require_pkce = false`.
|
||||
|
||||
**Follow-up before relying on CSP change:** do one manual browser pass (DevTools
|
||||
console) on `/signin`, OAuth consent, a Turbo navigation, dark-mode toggle, and a
|
||||
WebAuthn sign-in — expect zero CSP violations. Dev is report-only so violations
|
||||
surface as warnings without breaking. Fallback if style-src surprises: keep
|
||||
`style-src 'unsafe-inline'`, ship script-src only.
|
||||
|
||||
## ☐ Remaining — MEDIUM
|
||||
|
||||
- [ ] **`id_token_hint` ignored at OIDC logout** — any client can redirect logout to
|
||||
any other registered client's post-logout URI. Validate the hint's `aud` and
|
||||
scope the redirect to that app. `app/controllers/oidc_controller.rb` (logout).
|
||||
- [ ] **`offline_access` doesn't gate refresh-token issuance** — refresh tokens are
|
||||
minted unconditionally; gate on the granted scope.
|
||||
`app/controllers/oidc_controller.rb` (authorization_code grant, ~line 564).
|
||||
- [ ] **CSP-report endpoint hardening** — unauthenticated, no rate limit / body-size
|
||||
cap, logs raw CRLF (log injection). Sanitize values, cap size, rate-limit.
|
||||
`app/controllers/api/csp_controller.rb`.
|
||||
- [ ] **Port not stripped from `X-Forwarded-Host`** in main verify + bearer paths →
|
||||
403 outages on non-standard ports (also a correctness bug). Reuse the
|
||||
port-stripping done in `check_forward_auth_token`.
|
||||
`app/controllers/api/forward_auth_controller.rb`.
|
||||
- [ ] **WebAuthn `acr:"2"` without enforced user verification** — `user_verification:
|
||||
"preferred"` lets a PIN-less key authenticate yet reports verified 2FA. Use
|
||||
`"required"`, or downgrade `acr` to `"1"` when the UV flag is absent.
|
||||
`app/controllers/sessions_controller.rb` (webauthn_challenge/verify),
|
||||
`app/controllers/webauthn_controller.rb`.
|
||||
- [ ] **`RESERVED_CLAIMS` incomplete** — missing `at_hash`/`auth_time`/`acr`; and
|
||||
`ApplicationUserClaims` has no reserved-name validation (User/Group do). Could
|
||||
let a custom claim overwrite a security claim. `app/services/oidc_jwt_service.rb`,
|
||||
`app/models/application_user_claim.rb`.
|
||||
- [ ] **`reset_session` not called on login** — defensive best practice for an IdP;
|
||||
clears pre-auth session state. `app/controllers/concerns/authentication.rb`
|
||||
(`start_new_session_for`).
|
||||
- [x] **Hardcoded private IP `192.168.2.246`** in `config/environments/production.rb`
|
||||
— removed; it was redundant with the `192.168.0.0/16` regex already in the
|
||||
`CLINCH_ALLOW_INTERNAL_IPS` block.
|
||||
- [ ] **CSP `form-action` widened by unvalidated `redirect_uri`** before auth — only
|
||||
add to `form-action` if the client_id+redirect_uri is a registered pair.
|
||||
`app/controllers/concerns/authentication.rb` (`allow_oauth_redirect_in_csp`).
|
||||
- [ ] **SVG `style` attribute permits `url()`/`expression()`** — mitigated today by
|
||||
`Content-Disposition: attachment`, but fragile. Sanitize CSS values or drop
|
||||
`style` from the allowlist. `app/models/svg_scrubber.rb`.
|
||||
- [ ] **WebAuthn error messages leak internals** — return generic errors to client,
|
||||
log detail server-side. `app/controllers/sessions_controller.rb`,
|
||||
`app/controllers/webauthn_controller.rb`.
|
||||
- [ ] **Account enumeration via webauthn challenge** — distinguishes "user not found"
|
||||
vs "no passkey". Return a uniform message. `app/controllers/sessions_controller.rb`
|
||||
(`webauthn_challenge`).
|
||||
- [ ] **`token_family_id` only 31 bits** (`SecureRandom.random_number(2**31)`) —
|
||||
birthday collision ~46k; use a UUID/string. `app/models/oidc_refresh_token.rb`.
|
||||
- [ ] **Session cookie uses sequential integer DB id** — HMAC-signed so not forgeable,
|
||||
but consider a random `token` column (Rails 8 generator default).
|
||||
`app/models/session.rb`, `app/controllers/concerns/authentication.rb`.
|
||||
- [ ] **Login rate-limit is IP-only** — no account lockout (distributed brute force /
|
||||
credential stuffing). Add failed-count + `locked_until` on users.
|
||||
- [ ] **Backup-code rate limit not reset on success** and is cache-based (resets on
|
||||
cache flush). Reset on success; consider DB-backed counter. `app/models/user.rb`.
|
||||
|
||||
## ☐ Remaining — LOW / INFO
|
||||
|
||||
- [ ] Public clients can't revoke their own tokens (revoke endpoint requires secret).
|
||||
- [ ] Basic-auth client creds not URL-decoded per RFC 6749 §2.3.1.
|
||||
- [ ] `token_hmac` columns nullable at DB level despite model `presence: true`.
|
||||
- [ ] Group names allow commas → injection into `X-Remote-Groups` (false memberships
|
||||
downstream). Add a format validator. `app/models/group.rb`.
|
||||
- [ ] `fa_token` leaks in redirect URL / Referer / history (60s TTL, host-bound).
|
||||
- [ ] Admin `domain_pattern` allows ReDoS — add a format validator.
|
||||
`app/models/application.rb`.
|
||||
- [ ] Forced-TOTP-setup login path can redirect-loop (`totp_required` + no TOTP).
|
||||
- [ ] `complete_setup` creates an unprompted session for any authenticated user.
|
||||
- [ ] Password min length only 8 — consider 12 + a max (bcrypt 72-byte truncation).
|
||||
- [ ] `support_unencrypted_data: true` left enabled (TOTP secret encryption migration).
|
||||
`config/initializers/active_record_encryption.rb`.
|
||||
- [ ] All crypto keys derived from a single `SECRET_KEY_BASE` root — document setting
|
||||
independent `ACTIVE_RECORD_ENCRYPTION_*` keys in production.
|
||||
- [ ] Log injection via user `email_address` in ForwardAuth logs (strip CRLF / use
|
||||
structured logging). `app/controllers/api/forward_auth_controller.rb`.
|
||||
- [ ] WebAuthn RP ID is the registrable domain (cross-subdomain credential roaming) —
|
||||
set `CLINCH_RP_ID` to the exact host unless roaming is intended.
|
||||
`config/initializers/webauthn.rb`.
|
||||
@@ -0,0 +1,16 @@
|
||||
module Admin
|
||||
# Toggles the RFC 7591 dynamic client registration window on/off at runtime.
|
||||
class DynamicClientRegistrationController < BaseController
|
||||
def update
|
||||
enabled = ActiveModel::Type::Boolean.new.cast(params[:enabled])
|
||||
Setting.set(Application::DCR_SETTING_KEY, enabled)
|
||||
|
||||
notice = if enabled
|
||||
"Dynamic client registration enabled. New clients can self-register — attach them to a group, then disable this again."
|
||||
else
|
||||
"Dynamic client registration disabled."
|
||||
end
|
||||
redirect_to admin_applications_path, notice: notice
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,97 @@
|
||||
# User-facing side of the OAuth 2.0 Device Authorization Grant (RFC 8628 §3.3).
|
||||
#
|
||||
# The CLI/agent sends the human here (GET /device) with the short user_code it
|
||||
# was issued. This controller is authenticated, so an unauthenticated visitor is
|
||||
# bounced through /signin (with their passkey) and returned here afterwards via
|
||||
# session[:return_to_after_authenticating]. On POST /device the signed-in user
|
||||
# approves or denies; approval attaches them to the device code and records
|
||||
# consent so the token endpoint can mint tokens.
|
||||
class DeviceAuthorizationsController < ApplicationController
|
||||
# Browser form endpoint — keep CSRF protection on (do NOT skip it).
|
||||
|
||||
# RFC 8628 §5.1: rate-limit user_code entry so a signed-in user cannot brute
|
||||
# force the short code space to deny or hijack another user's pending
|
||||
# authorization during its ~10 minute window. Covers both the lookup (show) and
|
||||
# the state-changing submit (verify).
|
||||
rate_limit to: 10, within: 1.minute, only: [:show, :verify], with: -> {
|
||||
render plain: "Too many attempts. Try again later.", status: :too_many_requests
|
||||
}
|
||||
|
||||
# GET /device?user_code=WDJB-MJHT
|
||||
def show
|
||||
@user_code = params[:user_code].to_s
|
||||
if @user_code.blank?
|
||||
@state = :prompt
|
||||
return render :show
|
||||
end
|
||||
|
||||
@device_code = OidcDeviceCode.find_by_user_code(@user_code)
|
||||
@state = device_code_state(@device_code)
|
||||
if @state == :ok
|
||||
@state = :confirm
|
||||
@application = @device_code.application
|
||||
@scopes = granted_scopes(@device_code)
|
||||
end
|
||||
|
||||
render :show
|
||||
end
|
||||
|
||||
# POST /device
|
||||
def verify
|
||||
@device_code = OidcDeviceCode.find_by_user_code(params[:user_code].to_s)
|
||||
@state = device_code_state(@device_code)
|
||||
return render :result unless @state == :ok
|
||||
|
||||
@application = @device_code.application
|
||||
|
||||
if params[:deny].present?
|
||||
@device_code.deny!
|
||||
@state = :denied
|
||||
return render :result
|
||||
end
|
||||
|
||||
# Enforce the same group-based access control as the OIDC authorize flow.
|
||||
unless @application.user_allowed?(Current.user)
|
||||
@state = :not_allowed
|
||||
return render :result
|
||||
end
|
||||
|
||||
record_consent(@device_code, Current.user)
|
||||
@device_code.approve!(
|
||||
user: Current.user,
|
||||
acr: Current.session.acr,
|
||||
auth_time: Current.session.created_at.to_i
|
||||
)
|
||||
@state = :approved
|
||||
render :result
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Single resolver for the shared terminal-state cascade. Returns :not_found,
|
||||
# :expired, :already_handled, or :ok (the code is live and actionable). Both
|
||||
# show and verify branch on this so the cascade lives in one place, and the
|
||||
# terminal states render through the shared _terminal_state partial.
|
||||
def device_code_state(device_code)
|
||||
return :not_found if device_code.nil?
|
||||
return :expired if device_code.expired?
|
||||
return :already_handled unless device_code.pending?
|
||||
:ok
|
||||
end
|
||||
|
||||
def granted_scopes(device_code)
|
||||
device_code.scope.to_s.split & OidcScopes::SUPPORTED
|
||||
end
|
||||
|
||||
def record_consent(device_code, user)
|
||||
# merge: true — the consent record is shared with the browser flow, so a
|
||||
# narrower device request must not shrink previously granted scopes or wipe
|
||||
# stored claims.
|
||||
OidcUserConsent.record!(
|
||||
user: user,
|
||||
application: device_code.application,
|
||||
scopes: granted_scopes(device_code),
|
||||
merge: true
|
||||
)
|
||||
end
|
||||
end
|
||||
+475
-102
@@ -1,25 +1,51 @@
|
||||
class OidcController < ApplicationController
|
||||
SUPPORTED_SCOPES = %w[openid profile email groups offline_access].freeze
|
||||
|
||||
# Grant types this authorization server supports. Single source of truth:
|
||||
# advertised in discovery (grant_types_supported), accepted at dynamic client
|
||||
# registration, and dispatched by the token endpoint. clinch offers all of these
|
||||
# to every OIDC client — they are all user-context grants gated by consent and
|
||||
# Application#user_allowed?, so there is no per-client grant restriction to
|
||||
# enforce. Keep this in sync with the `case grant_type` dispatch in #token.
|
||||
SUPPORTED_GRANT_TYPES = [
|
||||
"authorization_code",
|
||||
"refresh_token",
|
||||
"urn:ietf:params:oauth:grant-type:device_code"
|
||||
].freeze
|
||||
|
||||
# Discovery and JWKS endpoints are public
|
||||
# authorize is also unauthenticated to handle prompt=none and prompt=login specially
|
||||
allow_unauthenticated_access only: [:discovery, :jwks, :token, :revoke, :userinfo, :logout, :authorize]
|
||||
# Machine-to-machine endpoints (token/revoke/userinfo) and pure redirect handlers
|
||||
# (logout/authorize) legitimately skip CSRF. The consent endpoint is browser-facing
|
||||
# and state-changing (it grants OAuth scopes), so it MUST keep CSRF protection — the
|
||||
# consent form already embeds the token via form_with.
|
||||
skip_before_action :verify_authenticity_token, only: [:token, :revoke, :userinfo, :logout, :authorize]
|
||||
allow_unauthenticated_access only: [:discovery, :jwks, :token, :revoke, :introspect, :userinfo, :logout, :authorize, :device_authorization]
|
||||
# Machine-to-machine endpoints (token/revoke/introspect/userinfo/device_authorization)
|
||||
# and pure redirect handlers (logout/authorize) legitimately skip CSRF. The consent
|
||||
# endpoint is browser-facing and state-changing (it grants OAuth scopes), so it MUST
|
||||
# keep CSRF protection — the consent form already embeds the token via form_with.
|
||||
skip_before_action :verify_authenticity_token, only: [:token, :revoke, :introspect, :userinfo, :logout, :authorize, :device_authorization]
|
||||
|
||||
# RFC 6749 §4.1.2.1: client_id and redirect_uri must be validated *before* any
|
||||
# other error can be reported via redirect. Failures here render a plain page.
|
||||
before_action :set_application, only: :authorize
|
||||
before_action :validate_redirect_uri, only: :authorize
|
||||
|
||||
# Rate limiting to prevent brute force and abuse
|
||||
rate_limit to: 60, within: 1.minute, only: [:token, :revoke], with: -> {
|
||||
# Rate limiting to prevent brute force and abuse.
|
||||
#
|
||||
# Each rate_limit MUST pass a distinct name: — without it actionpack derives the
|
||||
# same cache key for every call on the controller, collapsing these into one
|
||||
# shared counter. That let high-frequency RFC 8628 device polling (on the token
|
||||
# endpoint) exhaust the budget and 429 unrelated token/refresh/revoke/introspect
|
||||
# requests, including the poll that completes a just-approved login.
|
||||
#
|
||||
# The token endpoint also carries device-code polling, which is legitimately
|
||||
# frequent and can come from several devices behind one NAT, so its bucket has
|
||||
# generous headroom. Per-device poll abuse is separately bounded by the slow_down
|
||||
# interval, and none of these endpoints exposes a brute-forceable secret (tokens,
|
||||
# codes, and client secrets are opaque high-entropy values), so the limit is a
|
||||
# DoS guard rather than a credential guard.
|
||||
rate_limit to: 120, within: 1.minute, name: "oidc_token", only: [:token, :revoke, :introspect, :device_authorization], with: -> {
|
||||
render json: {error: "too_many_requests", error_description: "Rate limit exceeded. Try again later."}, status: :too_many_requests
|
||||
}
|
||||
rate_limit to: 30, within: 1.minute, only: [:authorize, :consent], with: -> {
|
||||
# Browser-facing authorization flow — kept on its own counter so token-endpoint
|
||||
# traffic can never consume the interactive login budget (or vice versa).
|
||||
rate_limit to: 30, within: 1.minute, name: "oidc_authorize", only: [:authorize, :consent], with: -> {
|
||||
render plain: "Too many authorization attempts. Try again later.", status: :too_many_requests
|
||||
}
|
||||
|
||||
@@ -32,15 +58,17 @@ class OidcController < ApplicationController
|
||||
authorization_endpoint: "#{base_url}/oauth/authorize",
|
||||
token_endpoint: "#{base_url}/oauth/token",
|
||||
revocation_endpoint: "#{base_url}/oauth/revoke",
|
||||
introspection_endpoint: "#{base_url}/oauth/introspect",
|
||||
userinfo_endpoint: "#{base_url}/oauth/userinfo",
|
||||
device_authorization_endpoint: "#{base_url}/oauth/device_authorization",
|
||||
jwks_uri: "#{base_url}/.well-known/jwks.json",
|
||||
end_session_endpoint: "#{base_url}/logout",
|
||||
response_types_supported: ["code"],
|
||||
response_modes_supported: ["query"],
|
||||
grant_types_supported: ["authorization_code", "refresh_token"],
|
||||
grant_types_supported: SUPPORTED_GRANT_TYPES,
|
||||
subject_types_supported: ["pairwise"],
|
||||
id_token_signing_alg_values_supported: ["RS256"],
|
||||
scopes_supported: SUPPORTED_SCOPES,
|
||||
scopes_supported: OidcScopes::SUPPORTED,
|
||||
token_endpoint_auth_methods_supported: ["client_secret_post", "client_secret_basic"],
|
||||
claims_supported: [
|
||||
"sub", # Always included
|
||||
@@ -60,6 +88,11 @@ class OidcController < ApplicationController
|
||||
claims_parameter_supported: true
|
||||
}
|
||||
|
||||
# Only advertise dynamic client registration when it is enabled (RFC 7591).
|
||||
if Application.dynamic_registration_enabled?
|
||||
config[:registration_endpoint] = "#{base_url}/oauth/register"
|
||||
end
|
||||
|
||||
render json: config
|
||||
end
|
||||
|
||||
@@ -68,6 +101,82 @@ class OidcController < ApplicationController
|
||||
render json: OidcJwtService.jwks
|
||||
end
|
||||
|
||||
# POST /oauth/device_authorization
|
||||
# RFC 8628 §3.1-3.2 — Device Authorization Request/Response.
|
||||
# Public (PKCE) client presents its client_id and gets back a device_code the
|
||||
# client polls with, plus a short user_code the human types on the /device page.
|
||||
def device_authorization
|
||||
client_id, client_secret = extract_client_credentials
|
||||
application = Application.find_by(client_id: client_id, app_type: "oidc")
|
||||
|
||||
unless application&.active?
|
||||
render json: {error: "invalid_client", error_description: "Unknown or inactive client"}, status: :unauthorized
|
||||
return
|
||||
end
|
||||
|
||||
# RFC 8628 §3.1: the device authorization request must authenticate the client
|
||||
# per its type. Public (PKCE) clients present only their client_id; a
|
||||
# confidential client must also prove possession of its secret, otherwise an
|
||||
# attacker knowing the public client_id could initiate a request in its name.
|
||||
if application.confidential_client?
|
||||
unless client_secret.present? && application.authenticate_client_secret(client_secret)
|
||||
render json: {error: "invalid_client", error_description: "Invalid client credentials"}, status: :unauthorized
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
# Only accept scopes we support (mirrors the authorize endpoint).
|
||||
requested_scope = (params[:scope].to_s.split & OidcScopes::SUPPORTED).join(" ")
|
||||
requested_scope = "openid" if requested_scope.blank?
|
||||
|
||||
# PKCE is optional but recommended for device flow (RFC 8628 §5.5). If the
|
||||
# client sends a challenge here it must send the verifier at the token endpoint.
|
||||
code_challenge = params[:code_challenge].presence
|
||||
code_challenge_method = params[:code_challenge_method].presence
|
||||
|
||||
# Public clients have no secret, so PKCE is their only proof-of-possession.
|
||||
# Require the code_challenge up front — otherwise an intercepted device_code
|
||||
# plus the well-known public client_id would be enough to redeem tokens.
|
||||
if application.requires_pkce? && code_challenge.blank?
|
||||
render json: {error: "invalid_request", error_description: "code_challenge is required for this client"}, status: :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
if code_challenge_method.present? && code_challenge_method != "S256"
|
||||
render json: {error: "invalid_request", error_description: "Only S256 code_challenge_method is supported"}, status: :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
# RFC 8707 Resource Indicator (optional): bind the eventual token to a target.
|
||||
resource = params[:resource].presence
|
||||
if resource && !valid_resource_indicator?(resource)
|
||||
render json: {error: "invalid_target", error_description: "resource must be an absolute URI without a fragment"}, status: :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
device_code = OidcDeviceCode.create!(
|
||||
application: application,
|
||||
scope: requested_scope,
|
||||
nonce: params[:nonce].presence,
|
||||
resource: resource,
|
||||
code_challenge: code_challenge,
|
||||
code_challenge_method: code_challenge.present? ? (code_challenge_method || "S256") : nil
|
||||
)
|
||||
|
||||
base_url = OidcJwtService.issuer_url
|
||||
verification_uri = "#{base_url}/device"
|
||||
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
render json: {
|
||||
device_code: device_code.plaintext_device_code,
|
||||
user_code: device_code.user_code,
|
||||
verification_uri: verification_uri,
|
||||
verification_uri_complete: "#{verification_uri}?user_code=#{device_code.user_code}",
|
||||
expires_in: (device_code.expires_at - Time.current).to_i,
|
||||
interval: device_code.interval
|
||||
}
|
||||
end
|
||||
|
||||
# GET /oauth/authorize
|
||||
def authorize
|
||||
# @application and a validated redirect_uri are guaranteed by the before_actions.
|
||||
@@ -80,6 +189,7 @@ class OidcController < ApplicationController
|
||||
response_type = params[:response_type]
|
||||
code_challenge = params[:code_challenge]
|
||||
code_challenge_method = params[:code_challenge_method] || "S256"
|
||||
resource = params[:resource] # RFC 8707 Resource Indicator (target audience)
|
||||
|
||||
# ============================================================================
|
||||
# client_id and redirect_uri are already validated (see before_actions).
|
||||
@@ -92,20 +202,21 @@ class OidcController < ApplicationController
|
||||
# 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
|
||||
redirect_authorize_error(redirect_uri, "request_not_supported", description: "Request objects are not supported", state: state)
|
||||
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
|
||||
redirect_authorize_error(redirect_uri, "unsupported_response_type", description: "Only 'code' response_type is supported", state: state)
|
||||
return
|
||||
end
|
||||
|
||||
# RFC 8707 §2: if a resource indicator is supplied it must be a valid target,
|
||||
# otherwise the request is rejected with error=invalid_target.
|
||||
if resource.present? && !valid_resource_indicator?(resource)
|
||||
redirect_authorize_error(redirect_uri, "invalid_target", description: "resource must be an absolute URI without a fragment", state: state)
|
||||
return
|
||||
end
|
||||
|
||||
@@ -113,20 +224,14 @@ class OidcController < ApplicationController
|
||||
if code_challenge.present?
|
||||
unless code_challenge_method == "S256"
|
||||
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: only 'S256' is supported")}"
|
||||
error_uri += "&state=#{CGI.escape(state)}" if state.present?
|
||||
redirect_to error_uri, allow_other_host: true
|
||||
redirect_authorize_error(redirect_uri, "invalid_request", description: "Invalid code_challenge_method: only 'S256' is supported", state: state)
|
||||
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
|
||||
redirect_authorize_error(redirect_uri, "invalid_request", description: "Invalid code_challenge format: must be 43-128 characters of base64url encoding", state: state)
|
||||
return
|
||||
end
|
||||
end
|
||||
@@ -134,7 +239,7 @@ class OidcController < ApplicationController
|
||||
# Normalize requested scopes to the set we support. Needed here so claims
|
||||
# validation below can check claim→scope coverage against what will actually
|
||||
# be granted.
|
||||
requested_scopes = scope.split(" ") & SUPPORTED_SCOPES
|
||||
requested_scopes = scope.split(" ") & OidcScopes::SUPPORTED
|
||||
scope = requested_scopes.join(" ")
|
||||
|
||||
# Parse claims parameter (JSON string) for OIDC claims request
|
||||
@@ -146,10 +251,7 @@ class OidcController < ApplicationController
|
||||
# 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
|
||||
redirect_authorize_error(redirect_uri, "invalid_request", description: "Invalid claims parameter: must be valid JSON", state: state)
|
||||
return
|
||||
end
|
||||
|
||||
@@ -158,10 +260,7 @@ class OidcController < ApplicationController
|
||||
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
|
||||
redirect_authorize_error(redirect_uri, "invalid_scope", description: "Claims parameter requests claims not covered by granted scopes", state: state)
|
||||
return
|
||||
end
|
||||
end
|
||||
@@ -169,9 +268,7 @@ class OidcController < ApplicationController
|
||||
# Check if application is active (now we can safely redirect with error)
|
||||
unless @application.active?
|
||||
Rails.logger.error "OAuth: Application is not active: #{@application.name}"
|
||||
error_uri = "#{redirect_uri}?error=unauthorized_client&error_description=Application+is+not+active"
|
||||
error_uri += "&state=#{CGI.escape(state)}" if state.present?
|
||||
redirect_to error_uri, allow_other_host: true
|
||||
redirect_authorize_error(redirect_uri, "unauthorized_client", description: "Application is not active", state: state)
|
||||
return
|
||||
end
|
||||
|
||||
@@ -181,9 +278,7 @@ class OidcController < ApplicationController
|
||||
# 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
|
||||
redirect_authorize_error(redirect_uri, "login_required", state: state)
|
||||
return
|
||||
end
|
||||
|
||||
@@ -196,6 +291,7 @@ class OidcController < ApplicationController
|
||||
scope: scope,
|
||||
code_challenge: code_challenge,
|
||||
code_challenge_method: code_challenge_method,
|
||||
resource: resource,
|
||||
claims_requests: parsed_claims&.to_json
|
||||
}
|
||||
# Store the current URL (with all OAuth params) for redirect after authentication
|
||||
@@ -261,9 +357,7 @@ class OidcController < ApplicationController
|
||||
end
|
||||
|
||||
unless requested_scopes.include?("openid")
|
||||
error_uri = "#{redirect_uri}?error=invalid_scope&error_description=#{CGI.escape("The 'openid' scope is required")}"
|
||||
error_uri += "&state=#{CGI.escape(state)}" if state.present?
|
||||
redirect_to error_uri, allow_other_host: true
|
||||
redirect_authorize_error(redirect_uri, "invalid_scope", description: "The 'openid' scope is required", state: state)
|
||||
return
|
||||
end
|
||||
|
||||
@@ -286,6 +380,7 @@ class OidcController < ApplicationController
|
||||
nonce: nonce,
|
||||
code_challenge: code_challenge,
|
||||
code_challenge_method: code_challenge_method,
|
||||
resource: resource,
|
||||
claims_requests: parsed_claims || {},
|
||||
auth_time: Current.session.created_at.to_i,
|
||||
acr: Current.session.acr,
|
||||
@@ -311,6 +406,7 @@ class OidcController < ApplicationController
|
||||
nonce: nonce,
|
||||
code_challenge: code_challenge,
|
||||
code_challenge_method: code_challenge_method,
|
||||
resource: resource,
|
||||
claims_requests: parsed_claims || {},
|
||||
auth_time: Current.session.created_at.to_i,
|
||||
acr: Current.session.acr,
|
||||
@@ -333,6 +429,7 @@ class OidcController < ApplicationController
|
||||
scope: scope,
|
||||
code_challenge: code_challenge,
|
||||
code_challenge_method: code_challenge_method,
|
||||
resource: resource,
|
||||
claims_requests: parsed_claims&.to_json
|
||||
}
|
||||
|
||||
@@ -374,9 +471,7 @@ class OidcController < ApplicationController
|
||||
# User denied consent
|
||||
if params[:deny].present?
|
||||
session.delete(:oauth_params)
|
||||
error_uri = "#{oauth_params["redirect_uri"]}?error=access_denied"
|
||||
error_uri += "&state=#{CGI.escape(oauth_params["state"])}" if oauth_params["state"]
|
||||
redirect_to error_uri, allow_other_host: true
|
||||
redirect_authorize_error(oauth_params["redirect_uri"], "access_denied", state: oauth_params["state"])
|
||||
return
|
||||
end
|
||||
|
||||
@@ -388,15 +483,13 @@ class OidcController < ApplicationController
|
||||
unless application&.active?
|
||||
Rails.logger.error "OAuth: Application is not active: #{application&.name || client_id}"
|
||||
session.delete(:oauth_params)
|
||||
error_uri = "#{oauth_params["redirect_uri"]}?error=unauthorized_client&error_description=Application+is+not+active"
|
||||
error_uri += "&state=#{CGI.escape(oauth_params["state"])}" if oauth_params["state"].present?
|
||||
redirect_to error_uri, allow_other_host: true
|
||||
redirect_authorize_error(oauth_params["redirect_uri"], "unauthorized_client", description: "Application is not active", state: oauth_params["state"])
|
||||
return
|
||||
end
|
||||
|
||||
user = Current.session.user
|
||||
|
||||
requested_scopes = oauth_params["scope"].split(" ") & SUPPORTED_SCOPES
|
||||
requested_scopes = oauth_params["scope"].split(" ") & OidcScopes::SUPPORTED
|
||||
parsed_claims = begin
|
||||
JSON.parse(oauth_params["claims_requests"])
|
||||
rescue
|
||||
@@ -418,6 +511,7 @@ class OidcController < ApplicationController
|
||||
nonce: oauth_params["nonce"],
|
||||
code_challenge: oauth_params["code_challenge"],
|
||||
code_challenge_method: oauth_params["code_challenge_method"],
|
||||
resource: oauth_params["resource"],
|
||||
claims_requests: parsed_claims,
|
||||
auth_time: Current.session.created_at.to_i,
|
||||
acr: Current.session.acr,
|
||||
@@ -453,11 +547,146 @@ class OidcController < ApplicationController
|
||||
handle_authorization_code_grant
|
||||
when "refresh_token"
|
||||
handle_refresh_token_grant
|
||||
when "urn:ietf:params:oauth:grant-type:device_code"
|
||||
handle_device_code_grant
|
||||
else
|
||||
render json: {error: "unsupported_grant_type"}, status: :bad_request
|
||||
end
|
||||
end
|
||||
|
||||
# RFC 8628 §3.4-3.5 — the CLI/agent polls here with its device_code until the
|
||||
# user approves on the /device page, then receives the standard token triple.
|
||||
def handle_device_code_grant
|
||||
client_id, client_secret = extract_client_credentials
|
||||
|
||||
unless client_id
|
||||
render json: {error: "invalid_client", error_description: "client_id is required"}, status: :unauthorized
|
||||
return
|
||||
end
|
||||
|
||||
application = Application.find_by(client_id: client_id)
|
||||
unless application
|
||||
render json: {error: "invalid_client", error_description: "Unknown client"}, status: :unauthorized
|
||||
return
|
||||
end
|
||||
|
||||
# Public clients authenticate with the device_code (+ optional PKCE); a
|
||||
# confidential client using device flow must still present its secret.
|
||||
if application.confidential_client?
|
||||
unless client_secret.present? && application.authenticate_client_secret(client_secret)
|
||||
render json: {error: "invalid_client", error_description: "Invalid client credentials"}, status: :unauthorized
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
unless application.active?
|
||||
render json: {error: "invalid_client", error_description: "Application is not active"}, status: :forbidden
|
||||
return
|
||||
end
|
||||
|
||||
device_code = OidcDeviceCode.find_by_plaintext_device_code(params[:device_code])
|
||||
unless device_code && device_code.application_id == application.id
|
||||
render json: {error: "invalid_grant", error_description: "Invalid device_code"}, status: :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
OidcDeviceCode.transaction do
|
||||
# Lock so concurrent polls / a poll racing with approval can't double-issue.
|
||||
device_code.lock!
|
||||
|
||||
# Replay: an already-redeemed code must never mint tokens again. Mirror the
|
||||
# authorization-code reuse semantics (RFC 6749 §4.1.2) — revoke every token
|
||||
# descended from it and report the reuse distinguishably, rather than the
|
||||
# generic "Invalid device_code" returned for an unknown code.
|
||||
if device_code.redeemed?
|
||||
Rails.logger.warn "OIDC Security: Device code reuse detected for code #{device_code.id}"
|
||||
now = Time.current
|
||||
device_code.oidc_access_tokens.where(revoked_at: nil).update_all(revoked_at: now)
|
||||
device_code.oidc_refresh_tokens.where(revoked_at: nil).update_all(revoked_at: now)
|
||||
render json: {error: "invalid_grant", error_description: "Device code has already been used"}, status: :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
if device_code.expired?
|
||||
render json: {error: "expired_token", error_description: "The device_code has expired"}, status: :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
if device_code.denied?
|
||||
render json: {error: "access_denied", error_description: "The authorization request was denied"}, status: :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
if device_code.pending?
|
||||
# Enforce the polling interval; too-frequent polls get slow_down, and the
|
||||
# client is expected to add 5s to its interval (RFC 8628 §3.5). The bump is
|
||||
# capped at MAX_INTERVAL so a persistently fast poller can't grow it without
|
||||
# bound and starve a legitimate client before the code expires.
|
||||
if device_code.last_polled_at && (Time.current - device_code.last_polled_at) < device_code.interval
|
||||
bumped_interval = [device_code.interval + OidcDeviceCode::INTERVAL_INCREMENT, OidcDeviceCode::MAX_INTERVAL].min
|
||||
device_code.update!(interval: bumped_interval, last_polled_at: Time.current)
|
||||
render json: {error: "slow_down"}, status: :bad_request
|
||||
else
|
||||
device_code.update!(last_polled_at: Time.current)
|
||||
render json: {error: "authorization_pending"}, status: :bad_request
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
# Approved: mint tokens via the same path as the authorization code grant.
|
||||
user = device_code.user
|
||||
|
||||
# Re-check authorization at mint time. Approval may have happened minutes ago;
|
||||
# an admin could have deactivated the user or removed them from the allowed
|
||||
# group in the meantime. user_allowed? covers app active, user active, and
|
||||
# group membership, so a now-unauthorized user is refused their tokens.
|
||||
unless user && application.user_allowed?(user)
|
||||
render json: {error: "access_denied", error_description: "User is no longer permitted to access this application"}, status: :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
consent = OidcUserConsent.find_by(user: user, application: application)
|
||||
unless consent
|
||||
Rails.logger.error "OIDC Security: Device token requested without consent record (user: #{user&.id}, app: #{application.id})"
|
||||
render json: {error: "invalid_grant", error_description: "Authorization consent not found"}, status: :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
# PKCE is enforced whenever the device authorization request supplied a
|
||||
# code_challenge. Clients that require PKCE (all public clients) are also
|
||||
# guaranteed to have one by the device_authorization endpoint; re-check here
|
||||
# so a device_code minted without a challenge can never redeem tokens.
|
||||
if application.requires_pkce? && !device_code.uses_pkce?
|
||||
render json: {error: "invalid_grant", error_description: "PKCE is required for this client"}, status: :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
if device_code.uses_pkce?
|
||||
pkce_result = validate_pkce(application, device_code, params[:code_verifier])
|
||||
unless pkce_result[:valid]
|
||||
render json: {error: pkce_result[:error], error_description: pkce_result[:error_description]}, status: pkce_result[:status]
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
# Single-use: mark the code redeemed (don't destroy it) so a replay is
|
||||
# detected as reuse — see the redeemed? check at the top of this block. The
|
||||
# cleanup job reaps redeemed codes after they expire.
|
||||
device_code.update!(redeemed_at: Time.current)
|
||||
|
||||
# Device flow never carries an OIDC claims request, so there are no claims
|
||||
# to filter into the id_token.
|
||||
mint_and_render_tokens(
|
||||
application: application,
|
||||
user: user,
|
||||
grant: device_code,
|
||||
grant_association: {oidc_device_code: device_code},
|
||||
consent: consent,
|
||||
claims_requests: {}
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def handle_authorization_code_grant
|
||||
# Get client credentials from Authorization header or params
|
||||
client_id, client_secret = extract_client_credentials
|
||||
@@ -556,26 +785,15 @@ class OidcController < ApplicationController
|
||||
# Get the user
|
||||
user = auth_code.user
|
||||
|
||||
# Generate access token record (opaque token with BCrypt hashing)
|
||||
access_token_record = OidcAccessToken.create!(
|
||||
application: application,
|
||||
user: user,
|
||||
scope: auth_code.scope,
|
||||
oidc_authorization_code: auth_code
|
||||
)
|
||||
# Re-check authorization at mint time: the user may have been deactivated or
|
||||
# removed from the allowed group between /authorize and this token request.
|
||||
unless user && application.user_allowed?(user)
|
||||
render json: {error: "access_denied", error_description: "User is no longer permitted to access this application"}, status: :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
# Generate refresh token (opaque, with hashing)
|
||||
refresh_token_record = OidcRefreshToken.create!(
|
||||
application: application,
|
||||
user: user,
|
||||
oidc_access_token: access_token_record,
|
||||
oidc_authorization_code: auth_code,
|
||||
scope: auth_code.scope,
|
||||
auth_time: auth_code.auth_time,
|
||||
acr: auth_code.acr
|
||||
)
|
||||
|
||||
# Find user consent for this application
|
||||
# Find user consent for this application before minting, so a missing
|
||||
# consent record can't leave orphaned tokens committed in this transaction.
|
||||
consent = OidcUserConsent.find_by(user: user, application: application)
|
||||
|
||||
unless consent
|
||||
@@ -584,35 +802,16 @@ class OidcController < ApplicationController
|
||||
return
|
||||
end
|
||||
|
||||
# Generate ID token (JWT) with pairwise SID, at_hash, auth_time, and acr
|
||||
# auth_time and acr come from the authorization code (captured at /authorize time)
|
||||
# scopes determine which claims are included (per OIDC Core spec)
|
||||
# claims_requests parameter filters which claims are included
|
||||
id_token = OidcJwtService.generate_id_token(
|
||||
user,
|
||||
application,
|
||||
# auth_time, acr, and nonce come from the authorization code (captured at
|
||||
# /authorize time); the claims request filters which id_token claims appear.
|
||||
mint_and_render_tokens(
|
||||
application: application,
|
||||
user: user,
|
||||
grant: auth_code,
|
||||
grant_association: {oidc_authorization_code: auth_code},
|
||||
consent: consent,
|
||||
nonce: auth_code.nonce,
|
||||
access_token: access_token_record.plaintext_token,
|
||||
auth_time: auth_code.auth_time,
|
||||
acr: auth_code.acr,
|
||||
scopes: auth_code.scope,
|
||||
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
|
||||
render json: {
|
||||
access_token: access_token_record.plaintext_token, # Opaque token
|
||||
token_type: "Bearer",
|
||||
expires_in: application.access_token_ttl || 3600,
|
||||
id_token: id_token, # JWT
|
||||
refresh_token: refresh_token_record.token, # Opaque token
|
||||
scope: auth_code.scope
|
||||
}
|
||||
end
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
render json: {error: "invalid_grant"}, status: :bad_request
|
||||
@@ -690,19 +889,32 @@ class OidcController < ApplicationController
|
||||
# Get the user
|
||||
user = refresh_token_record.user
|
||||
|
||||
# Re-check authorization at mint time. Refresh tokens are long-lived (up to
|
||||
# 30 days), so re-evaluate every refresh: a user deactivated or removed from
|
||||
# the allowed group must not be able to keep minting access tokens. Checked
|
||||
# before rotation so a denied refresh has no side effects.
|
||||
unless user && application.user_allowed?(user)
|
||||
render json: {error: "access_denied", error_description: "User is no longer permitted to access this application"}, status: :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
# Revoke the old refresh token (token rotation)
|
||||
refresh_token_record.revoke!
|
||||
|
||||
# Generate new access token record (opaque token with BCrypt hashing)
|
||||
# Carry the authorization-code FK forward across rotations so replay
|
||||
# revocation reaches every descendant token in the chain.
|
||||
# Carry the issuing-code FK forward across rotations so replay revocation
|
||||
# reaches every descendant token in the chain — for both the authorization-code
|
||||
# and device-code grants (a token descends from exactly one of them).
|
||||
issuing_auth_code = refresh_token_record.oidc_authorization_code
|
||||
issuing_device_code = refresh_token_record.oidc_device_code
|
||||
|
||||
new_access_token = OidcAccessToken.create!(
|
||||
application: application,
|
||||
user: user,
|
||||
scope: refresh_token_record.scope,
|
||||
oidc_authorization_code: issuing_auth_code
|
||||
oidc_authorization_code: issuing_auth_code,
|
||||
oidc_device_code: issuing_device_code,
|
||||
resource: refresh_token_record.resource
|
||||
)
|
||||
|
||||
# Generate new refresh token (token rotation)
|
||||
@@ -711,10 +923,12 @@ class OidcController < ApplicationController
|
||||
user: user,
|
||||
oidc_access_token: new_access_token,
|
||||
oidc_authorization_code: issuing_auth_code,
|
||||
oidc_device_code: issuing_device_code,
|
||||
scope: refresh_token_record.scope,
|
||||
token_family_id: refresh_token_record.token_family_id, # Keep same family for rotation tracking
|
||||
auth_time: refresh_token_record.auth_time, # Carry over original auth_time
|
||||
acr: refresh_token_record.acr # Carry over original acr
|
||||
acr: refresh_token_record.acr, # Carry over original acr
|
||||
resource: refresh_token_record.resource # Carry the bound audience across rotation
|
||||
)
|
||||
|
||||
# Find user consent for this application
|
||||
@@ -868,6 +1082,89 @@ class OidcController < ApplicationController
|
||||
render json: claims
|
||||
end
|
||||
|
||||
# POST /oauth/introspect
|
||||
# RFC 7662 - OAuth 2.0 Token Introspection.
|
||||
# A resource server (e.g. c2a2) presents an opaque access token and its own
|
||||
# client credentials; we reply whether the token is active and, as an extension,
|
||||
# the user's groups so the resource server can authorize on group membership.
|
||||
def introspect
|
||||
# RFC 7662 §2.1: the caller (resource server) MUST authenticate. Only a
|
||||
# registered confidential client may introspect.
|
||||
caller_id, caller_secret = extract_client_credentials
|
||||
caller = Application.find_by(client_id: caller_id) if caller_id.present?
|
||||
|
||||
unless caller&.confidential_client? && caller.active? &&
|
||||
caller_secret.present? && caller.authenticate_client_secret(caller_secret)
|
||||
render json: {error: "invalid_client", error_description: "Caller authentication failed"}, status: :unauthorized
|
||||
return
|
||||
end
|
||||
|
||||
token_value = params[:token]
|
||||
if token_value.blank?
|
||||
render json: {error: "invalid_request", error_description: "token parameter is required"}, status: :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
response.headers["Pragma"] = "no-cache"
|
||||
|
||||
access_token = OidcAccessToken.find_by_token(token_value)
|
||||
|
||||
# Inactive/unknown/expired/revoked tokens (or those for a disabled app) are
|
||||
# reported as simply inactive per RFC 7662 §2.2 — never an error.
|
||||
unless access_token&.active? && access_token.application&.active? && access_token.user
|
||||
render json: {active: false}
|
||||
return
|
||||
end
|
||||
|
||||
# RFC 7662 §4: a token must only be disclosed to a resource server authorized
|
||||
# to introspect it. Otherwise any confidential client could harvest every
|
||||
# user's identity by introspecting tokens issued to other clients. A caller is
|
||||
# authorized only for tokens issued to itself, or tokens whose bound audience
|
||||
# (RFC 8707 resource) it is registered to serve. Unauthorized callers get the
|
||||
# same inactive response as an unknown token, disclosing nothing.
|
||||
unless caller_may_introspect?(caller, access_token)
|
||||
Rails.logger.warn "OAuth: Client #{caller.client_id} not authorized to introspect token for resource #{access_token.resource.inspect}"
|
||||
render json: {active: false}
|
||||
return
|
||||
end
|
||||
|
||||
user = access_token.user
|
||||
application = access_token.application
|
||||
consent = OidcUserConsent.find_by(user: user, application: application)
|
||||
scopes = access_token.scope.to_s.split
|
||||
|
||||
body = {
|
||||
active: true,
|
||||
scope: access_token.scope,
|
||||
client_id: application.client_id,
|
||||
token_type: "Bearer",
|
||||
exp: access_token.expires_at.to_i,
|
||||
iat: access_token.created_at.to_i,
|
||||
sub: consent&.sid || user.id.to_s,
|
||||
# RFC 8707: the resource the token was bound to (falls back to the client
|
||||
# when no resource indicator was used at authorization time).
|
||||
aud: access_token.resource.presence || application.client_id
|
||||
}
|
||||
|
||||
# Disclose identity claims only when the token actually carries the scope that
|
||||
# grants them (mirrors the userinfo endpoint) — a token without `email`/`groups`
|
||||
# scope must not leak the user's email or group memberships.
|
||||
body[:username] = user.email_address if scopes.include?("email")
|
||||
body[:groups] = user.groups.pluck(:name) if scopes.include?("groups")
|
||||
|
||||
render json: body
|
||||
end
|
||||
|
||||
# A caller may introspect a token issued to itself, or a token bound (RFC 8707)
|
||||
# to a resource the caller is registered to serve.
|
||||
def caller_may_introspect?(caller, access_token)
|
||||
return true if access_token.application_id == caller.id
|
||||
|
||||
resource = access_token.resource.presence
|
||||
resource.present? && caller.serves_resource?(resource)
|
||||
end
|
||||
|
||||
# POST /oauth/revoke
|
||||
# RFC 7009 - Token Revocation
|
||||
def revoke
|
||||
@@ -980,6 +1277,71 @@ class OidcController < ApplicationController
|
||||
|
||||
private
|
||||
|
||||
# Redirect back to the client's redirect_uri with an OAuth 2.0 authorization
|
||||
# error (RFC 6749 §4.1.2.1). Extracted because the authorize / consent flows
|
||||
# report errors this same way ~a dozen times. Composes the query safely so a
|
||||
# redirect_uri that already carries a query string gets "&error=..." rather than
|
||||
# a second "?", and CGI-escapes the description and state.
|
||||
# Mints the access + refresh + id-token triple and renders the RFC 6749 §5.1
|
||||
# token response. Shared by the authorization-code and device-code grants: both
|
||||
# redeem a `grant` (the auth code / device code) exposing scope/resource/nonce/
|
||||
# auth_time/acr, and both tie the tokens back to it via `grant_association` (the
|
||||
# belongs_to used for replay revocation). The caller marks the grant consumed
|
||||
# before calling this, and both callers run inside the grant's locked transaction.
|
||||
def mint_and_render_tokens(application:, user:, grant:, grant_association:, consent:, claims_requests:)
|
||||
access_token_record = OidcAccessToken.create!(
|
||||
application: application,
|
||||
user: user,
|
||||
scope: grant.scope,
|
||||
resource: grant.resource,
|
||||
**grant_association
|
||||
)
|
||||
|
||||
refresh_token_record = OidcRefreshToken.create!(
|
||||
application: application,
|
||||
user: user,
|
||||
oidc_access_token: access_token_record,
|
||||
scope: grant.scope,
|
||||
auth_time: grant.auth_time,
|
||||
acr: grant.acr,
|
||||
resource: grant.resource,
|
||||
**grant_association
|
||||
)
|
||||
|
||||
id_token = OidcJwtService.generate_id_token(
|
||||
user,
|
||||
application,
|
||||
consent: consent,
|
||||
nonce: grant.nonce,
|
||||
access_token: access_token_record.plaintext_token,
|
||||
auth_time: grant.auth_time,
|
||||
acr: grant.acr,
|
||||
scopes: grant.scope,
|
||||
claims_requests: claims_requests
|
||||
)
|
||||
|
||||
# RFC 6749 §5.1: the token response MUST NOT be cached.
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
response.headers["Pragma"] = "no-cache"
|
||||
|
||||
render json: {
|
||||
access_token: access_token_record.plaintext_token,
|
||||
token_type: "Bearer",
|
||||
expires_in: application.access_token_ttl || 3600,
|
||||
id_token: id_token,
|
||||
refresh_token: refresh_token_record.token,
|
||||
scope: grant.scope
|
||||
}
|
||||
end
|
||||
|
||||
def redirect_authorize_error(redirect_uri, error, description: nil, state: nil)
|
||||
query = {error: error}
|
||||
query[:error_description] = description if description
|
||||
query[:state] = state if state.present?
|
||||
separator = redirect_uri.include?("?") ? "&" : "?"
|
||||
redirect_to "#{redirect_uri}#{separator}#{query.to_query}", allow_other_host: true
|
||||
end
|
||||
|
||||
# Look up @application from client_id. RFC 6749 §4.1.2.1 requires that an
|
||||
# invalid client_id be reported on-page, not via redirect.
|
||||
def set_application
|
||||
@@ -1094,6 +1456,17 @@ class OidcController < ApplicationController
|
||||
{valid: true}
|
||||
end
|
||||
|
||||
# RFC 8707 §2: a resource indicator must be an absolute URI and MUST NOT
|
||||
# include a fragment component. We validate syntax only (pass-through) — the
|
||||
# resource server enforces the audience when it introspects the token.
|
||||
def valid_resource_indicator?(value)
|
||||
return false if value.blank?
|
||||
uri = URI.parse(value)
|
||||
uri.absolute? && uri.fragment.nil?
|
||||
rescue URI::InvalidURIError
|
||||
false
|
||||
end
|
||||
|
||||
def extract_client_credentials
|
||||
# Try Authorization header first (Basic auth)
|
||||
if request.headers["Authorization"]&.start_with?("Basic ")
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
require "uri"
|
||||
|
||||
# OAuth 2.0 Dynamic Client Registration (RFC 7591).
|
||||
#
|
||||
# Lets a client (e.g. an MCP connector such as Claude) register itself instead of
|
||||
# being hand-created in the admin UI. Gated by a runtime toggle
|
||||
# (Application.dynamic_registration_enabled?) that defaults off. Registered
|
||||
# clients are default-deny — they have no allowed_groups until an admin attaches
|
||||
# one — so an anonymous registration cannot reach any user's data on its own.
|
||||
class OidcRegistrationController < ApplicationController
|
||||
allow_unauthenticated_access only: [:create]
|
||||
skip_before_action :verify_authenticity_token, only: [:create]
|
||||
|
||||
rate_limit to: 10, within: 1.minute, only: [:create], with: -> {
|
||||
render json: {error: "too_many_requests", error_description: "Rate limit exceeded. Try again later."}, status: :too_many_requests
|
||||
}
|
||||
|
||||
AUTH_METHODS = %w[none client_secret_basic client_secret_post].freeze
|
||||
SUPPORTED_RESPONSE_TYPES = %w[code].freeze
|
||||
|
||||
# Accept exactly the grant types the authorization server advertises in
|
||||
# discovery (single source of truth), so a client cannot be rejected for
|
||||
# requesting a grant the server actually supports (e.g. the device_code grant).
|
||||
def supported_grant_types
|
||||
OidcController::SUPPORTED_GRANT_TYPES
|
||||
end
|
||||
|
||||
# POST /oauth/register
|
||||
def create
|
||||
unless Application.dynamic_registration_enabled?
|
||||
render json: {error: "access_denied", error_description: "Dynamic client registration is disabled"}, status: :forbidden
|
||||
return
|
||||
end
|
||||
|
||||
metadata = parse_body
|
||||
if metadata == :invalid
|
||||
return register_error("invalid_client_metadata", "Request body must be a valid JSON object")
|
||||
end
|
||||
|
||||
auth_method = metadata["token_endpoint_auth_method"].presence || "client_secret_basic"
|
||||
unless AUTH_METHODS.include?(auth_method)
|
||||
return register_error("invalid_client_metadata", "Unsupported token_endpoint_auth_method")
|
||||
end
|
||||
|
||||
grant_types = Array(metadata["grant_types"].presence || ["authorization_code"])
|
||||
if (grant_types - supported_grant_types).any?
|
||||
return register_error("invalid_client_metadata", "Unsupported grant_types; only #{supported_grant_types.join(", ")} are allowed")
|
||||
end
|
||||
|
||||
response_types = Array(metadata["response_types"].presence || ["code"])
|
||||
if (response_types - SUPPORTED_RESPONSE_TYPES).any?
|
||||
return register_error("invalid_client_metadata", "Unsupported response_types; only 'code' is allowed")
|
||||
end
|
||||
|
||||
redirect_uris = Array(metadata["redirect_uris"]).map(&:to_s).reject(&:blank?)
|
||||
if redirect_uris.empty?
|
||||
return register_error("invalid_redirect_uri", "At least one redirect_uri is required")
|
||||
end
|
||||
invalid = redirect_uris.reject { |uri| valid_redirect_uri?(uri) }
|
||||
if invalid.any?
|
||||
return register_error("invalid_redirect_uri", "Invalid redirect_uri: #{invalid.first}")
|
||||
end
|
||||
|
||||
public_client = (auth_method == "none")
|
||||
client_name = metadata["client_name"].to_s.strip.presence || "Dynamically Registered Client"
|
||||
|
||||
application = Application.new(
|
||||
name: client_name,
|
||||
slug: unique_slug(client_name),
|
||||
app_type: "oidc",
|
||||
active: true,
|
||||
# MCP / OAuth 2.1 expect PKCE; public clients require it automatically.
|
||||
require_pkce: true,
|
||||
is_public_client: public_client,
|
||||
redirect_uris: redirect_uris.to_json,
|
||||
metadata: registration_metadata(metadata, auth_method).to_json
|
||||
)
|
||||
|
||||
unless application.save
|
||||
return register_error("invalid_client_metadata", application.errors.full_messages.join("; "))
|
||||
end
|
||||
|
||||
body = {
|
||||
client_id: application.client_id,
|
||||
client_id_issued_at: application.created_at.to_i,
|
||||
redirect_uris: redirect_uris,
|
||||
token_endpoint_auth_method: auth_method,
|
||||
grant_types: grant_types,
|
||||
response_types: response_types,
|
||||
client_name: client_name
|
||||
}
|
||||
body[:scope] = metadata["scope"] if metadata["scope"].present?
|
||||
|
||||
# Return the plaintext secret exactly once, for confidential clients.
|
||||
if application.confidential_client?
|
||||
body[:client_secret] = application.client_secret
|
||||
body[:client_secret_expires_at] = 0 # never expires
|
||||
end
|
||||
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
response.headers["Pragma"] = "no-cache"
|
||||
render json: body, status: :created
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def parse_body
|
||||
parsed = JSON.parse(request.raw_post)
|
||||
parsed.is_a?(Hash) ? parsed : :invalid
|
||||
rescue JSON::ParserError
|
||||
:invalid
|
||||
end
|
||||
|
||||
def register_error(error, description)
|
||||
render json: {error: error, error_description: description}, status: :bad_request
|
||||
end
|
||||
|
||||
# RFC 7591 allows https everywhere and http only for loopback (native apps).
|
||||
def valid_redirect_uri?(uri)
|
||||
parsed = URI.parse(uri)
|
||||
return false unless parsed.is_a?(URI::HTTP) # covers HTTP and HTTPS
|
||||
return true if parsed.scheme == "https"
|
||||
# #hostname (not #host) returns the unbracketed form for IPv6 literals, so the
|
||||
# RFC 8252 IPv6 loopback http://[::1]:PORT/... compares as "::1", not "[::1]".
|
||||
%w[localhost 127.0.0.1 ::1].include?(parsed.hostname)
|
||||
rescue URI::InvalidURIError
|
||||
false
|
||||
end
|
||||
|
||||
def unique_slug(name)
|
||||
base = name.parameterize.presence || "client"
|
||||
"#{base.first(40)}-#{SecureRandom.hex(6)}"
|
||||
end
|
||||
|
||||
# Preserve the descriptive metadata the client sent for later reference in the
|
||||
# admin UI, without letting it drive access.
|
||||
def registration_metadata(metadata, auth_method)
|
||||
{
|
||||
"dynamically_registered" => true,
|
||||
"token_endpoint_auth_method" => auth_method,
|
||||
"client_uri" => metadata["client_uri"],
|
||||
"logo_uri" => metadata["logo_uri"],
|
||||
"contacts" => metadata["contacts"],
|
||||
"policy_uri" => metadata["policy_uri"],
|
||||
"tos_uri" => metadata["tos_uri"],
|
||||
"scope" => metadata["scope"]
|
||||
}.compact
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,16 @@
|
||||
module OidcHelper
|
||||
# Single source of truth for the human-readable description of what each OAuth
|
||||
# scope grants. Shown on both the browser consent screen (oidc/consent) and the
|
||||
# device authorization screen (device_authorizations/show) via the shared
|
||||
# shared/_scope_list partial. Unknown scopes fall back to their raw name.
|
||||
def scope_description(scope, user: Current.user)
|
||||
case scope
|
||||
when "openid" then "Verify your identity"
|
||||
when "email" then "Access your email address (#{user&.email_address})"
|
||||
when "profile" then "Access your profile information"
|
||||
when "groups" then "Access your group memberships"
|
||||
when "offline_access" then "Stay signed in (refresh access)"
|
||||
else scope
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -25,5 +25,15 @@ class OidcTokenCleanupJob < ApplicationJob
|
||||
old_auth_codes = OidcAuthorizationCode.where("created_at < ?", 7.days.ago)
|
||||
deleted_count = old_auth_codes.delete_all
|
||||
Rails.logger.info "OIDC Token Cleanup: Deleted #{deleted_count} old authorization codes"
|
||||
|
||||
# Delete expired device codes (RFC 8628). They have a ~10 minute TTL and no
|
||||
# audit value; a redeemed code is already destroyed at token issuance. Once
|
||||
# expired a code can never be redeemed, so this single expiry sweep clears the
|
||||
# expired, denied, and abandoned rows that would otherwise accumulate forever
|
||||
# (anonymous callers can create them via /oauth/device_authorization). The
|
||||
# short grace keeps this clear of any in-flight redemption near expiry.
|
||||
expired_device_codes = OidcDeviceCode.where("expires_at < ?", 1.hour.ago)
|
||||
deleted_count = expired_device_codes.delete_all
|
||||
Rails.logger.info "OIDC Token Cleanup: Deleted #{deleted_count} expired device codes"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -103,6 +103,22 @@ class Application < ApplicationRecord
|
||||
app_type == "forward_auth"
|
||||
end
|
||||
|
||||
DCR_SETTING_KEY = "dynamic_client_registration".freeze
|
||||
|
||||
# OAuth 2.0 Dynamic Client Registration (RFC 7591) is opt-in: it lets anyone
|
||||
# anonymously create an OIDC client, so it is disabled by default. An admin
|
||||
# toggles it at runtime (open the window, let a client self-register, attach it
|
||||
# to a group, close the window again). Newly registered clients are still
|
||||
# default-deny (no allowed_groups) until an admin grants access.
|
||||
#
|
||||
# The persisted Setting is authoritative once set; until then we fall back to
|
||||
# the CLINCH_DCR_ENABLED env var (bootstrap/headless default, off if unset).
|
||||
def self.dynamic_registration_enabled?
|
||||
stored = Setting.boolean(DCR_SETTING_KEY)
|
||||
return stored unless stored.nil?
|
||||
ActiveModel::Type::Boolean.new.cast(ENV["CLINCH_DCR_ENABLED"])
|
||||
end
|
||||
|
||||
# Client type checks (for OIDC)
|
||||
def public_client?
|
||||
client_secret_digest.blank?
|
||||
@@ -139,6 +155,19 @@ class Application < ApplicationRecord
|
||||
redirect_uris.split("\n").map(&:strip).reject(&:blank?)
|
||||
end
|
||||
|
||||
# RFC 8707 resource identifier(s) this application serves as a resource server.
|
||||
# Used to authorize token introspection (see OidcController#caller_may_introspect?).
|
||||
def parsed_resource_identifiers
|
||||
return [] unless resource_identifiers.present?
|
||||
JSON.parse(resource_identifiers)
|
||||
rescue JSON::ParserError
|
||||
resource_identifiers.split("\n").map(&:strip).reject(&:blank?)
|
||||
end
|
||||
|
||||
def serves_resource?(uri)
|
||||
parsed_resource_identifiers.include?(uri)
|
||||
end
|
||||
|
||||
def parsed_metadata
|
||||
return {} unless metadata.present?
|
||||
JSON.parse(metadata)
|
||||
|
||||
@@ -2,6 +2,7 @@ class OidcAccessToken < ApplicationRecord
|
||||
belongs_to :application
|
||||
belongs_to :user
|
||||
belongs_to :oidc_authorization_code, optional: true
|
||||
belongs_to :oidc_device_code, optional: true
|
||||
has_many :oidc_refresh_tokens, dependent: :destroy
|
||||
|
||||
before_validation :generate_token, on: :create
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
# OAuth 2.0 Device Authorization Grant code (RFC 8628).
|
||||
#
|
||||
# Mirrors OidcAuthorizationCode: the long device_code is opaque and stored as an
|
||||
# HMAC, while the short user_code is stored in plaintext because the user types it
|
||||
# back on the verification page. A record is created "pending" by the device
|
||||
# authorization endpoint, moved to "approved" (with a user) or "denied" on the
|
||||
# verification page, and consumed by the token endpoint once approved.
|
||||
class OidcDeviceCode < ApplicationRecord
|
||||
belongs_to :application
|
||||
belongs_to :user, optional: true # nil until the request is approved
|
||||
|
||||
# Tokens minted from this code, so a replayed (already-redeemed) code can revoke
|
||||
# every token descended from it — mirrors OidcAuthorizationCode.
|
||||
has_many :oidc_access_tokens, dependent: :nullify
|
||||
has_many :oidc_refresh_tokens, dependent: :nullify
|
||||
|
||||
# Alphabet for the user_code: uppercase letters + digits, minus visually
|
||||
# ambiguous characters (0/O, 1/I, etc.) so it is easy to read and type.
|
||||
USER_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789".chars.freeze
|
||||
USER_CODE_GROUP_SIZE = 4
|
||||
USER_CODE_GROUPS = 2 # e.g. "WDJB-MJHT"
|
||||
|
||||
STATUSES = %w[pending approved denied].freeze
|
||||
|
||||
# Polling interval, in seconds. The token endpoint bumps the persisted interval
|
||||
# by INTERVAL_INCREMENT on each too-fast poll (RFC 8628 §3.5 slow_down), but
|
||||
# clamps it to MAX_INTERVAL so a client polling slightly fast — or an attacker
|
||||
# spamming a known device_code — can't balloon it past the expiry window and
|
||||
# starve a well-behaved client of its token.
|
||||
INTERVAL_INCREMENT = 5
|
||||
MAX_INTERVAL = 30
|
||||
|
||||
attr_accessor :plaintext_device_code
|
||||
|
||||
before_validation :generate_device_code, on: :create
|
||||
before_validation :generate_user_code, on: :create
|
||||
before_validation :set_expiry, on: :create
|
||||
|
||||
validates :device_code_hmac, presence: true, uniqueness: true
|
||||
validates :user_code, presence: true, uniqueness: true
|
||||
validates :status, inclusion: {in: STATUSES}
|
||||
validates :code_challenge_method, inclusion: {in: %w[S256], allow_nil: true}
|
||||
validate :validate_code_challenge_format, if: -> { code_challenge.present? }
|
||||
|
||||
scope :valid, -> { where(status: "pending").where("expires_at > ?", Time.current) }
|
||||
scope :expired, -> { where("expires_at <= ?", Time.current) }
|
||||
|
||||
# Find a device code by its plaintext device_code using HMAC verification.
|
||||
def self.find_by_plaintext_device_code(plaintext_device_code)
|
||||
return nil if plaintext_device_code.blank?
|
||||
|
||||
find_by(device_code_hmac: compute_device_code_hmac(plaintext_device_code))
|
||||
end
|
||||
|
||||
# Look up a device code by the human-typed user_code. Normalizes case and
|
||||
# strips separators/whitespace so "wdjb-mjht" and "WDJB MJHT" both match.
|
||||
def self.find_by_user_code(user_code)
|
||||
return nil if user_code.blank?
|
||||
|
||||
find_by(user_code: normalize_user_code(user_code))
|
||||
end
|
||||
|
||||
def self.normalize_user_code(user_code)
|
||||
user_code.to_s.upcase.gsub(/[^A-Z0-9]/, "")
|
||||
end
|
||||
|
||||
def self.compute_device_code_hmac(plaintext_device_code)
|
||||
OpenSSL::HMAC.hexdigest("SHA256", TokenHmac::KEY, plaintext_device_code)
|
||||
end
|
||||
|
||||
def expired?
|
||||
expires_at <= Time.current
|
||||
end
|
||||
|
||||
def pending?
|
||||
status == "pending"
|
||||
end
|
||||
|
||||
def approved?
|
||||
status == "approved"
|
||||
end
|
||||
|
||||
def denied?
|
||||
status == "denied"
|
||||
end
|
||||
|
||||
def uses_pkce?
|
||||
code_challenge.present?
|
||||
end
|
||||
|
||||
# True once the approved code has been exchanged for tokens. The row is kept
|
||||
# (not destroyed) so a replay is detectable and its tokens can be revoked.
|
||||
def redeemed?
|
||||
redeemed_at.present?
|
||||
end
|
||||
|
||||
# Grant the request: attach the approving user and capture their auth context.
|
||||
def approve!(user:, acr:, auth_time:)
|
||||
update!(status: "approved", user: user, acr: acr, auth_time: auth_time)
|
||||
end
|
||||
|
||||
def deny!
|
||||
update!(status: "denied")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_device_code
|
||||
self.plaintext_device_code ||= SecureRandom.urlsafe_base64(48)
|
||||
self.device_code_hmac ||= self.class.compute_device_code_hmac(plaintext_device_code)
|
||||
end
|
||||
|
||||
# Number of fresh candidates to try before falling back to the DB unique index.
|
||||
USER_CODE_MAX_ATTEMPTS = 10
|
||||
|
||||
def generate_user_code
|
||||
return if user_code.present?
|
||||
|
||||
# Regenerate on the (astronomically rare) collision with an existing code so a
|
||||
# client never gets an error just because two codes happened to match. The DB
|
||||
# unique index remains the final guard against a concurrent-insert race.
|
||||
USER_CODE_MAX_ATTEMPTS.times do
|
||||
candidate = random_user_code
|
||||
unless self.class.exists?(user_code: candidate)
|
||||
self.user_code = candidate
|
||||
return
|
||||
end
|
||||
end
|
||||
self.user_code = random_user_code
|
||||
end
|
||||
|
||||
def random_user_code
|
||||
# The user_code is a security credential (typing it + Approve grants tokens),
|
||||
# so draw from a CSPRNG rather than Ruby's global Mersenne Twister PRNG.
|
||||
USER_CODE_GROUPS.times.map do
|
||||
USER_CODE_GROUP_SIZE.times.map { USER_CODE_ALPHABET.sample(random: SecureRandom) }.join
|
||||
end.join
|
||||
end
|
||||
|
||||
def set_expiry
|
||||
self.expires_at ||= 10.minutes.from_now
|
||||
end
|
||||
|
||||
def validate_code_challenge_format
|
||||
# PKCE code challenge should be base64url-encoded, 43-128 characters.
|
||||
unless code_challenge.match?(/\A[A-Za-z0-9\-_]{43,128}\z/)
|
||||
errors.add(:code_challenge, "must be 43-128 characters of base64url encoding")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -3,6 +3,7 @@ class OidcRefreshToken < ApplicationRecord
|
||||
belongs_to :user
|
||||
belongs_to :oidc_access_token
|
||||
belongs_to :oidc_authorization_code, optional: true
|
||||
belongs_to :oidc_device_code, optional: true
|
||||
|
||||
before_validation :generate_token, on: :create
|
||||
before_validation :set_expiry, on: :create
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# Single source of truth for the OAuth/OIDC scopes this IdP supports. Shared by
|
||||
# the OIDC controller (discovery + authorize/consent), the device authorization
|
||||
# flow, and consent handling, so no controller has to reach into another for the
|
||||
# list.
|
||||
module OidcScopes
|
||||
SUPPORTED = %w[openid profile email groups offline_access].freeze
|
||||
end
|
||||
@@ -8,9 +8,29 @@ class OidcUserConsent < ApplicationRecord
|
||||
before_validation :set_granted_at, on: :create
|
||||
before_validation :set_sid, on: :create
|
||||
|
||||
# Parse scopes_granted into an array
|
||||
# Upsert a user's consent for an application. The record is unique on
|
||||
# user+application and shared across the browser and device flows.
|
||||
#
|
||||
# merge: false (the browser consent screen) records exactly the scopes the user
|
||||
# just approved. merge: true (device approval) unions the scopes into any
|
||||
# existing grant and leaves stored claims untouched, so a narrower device
|
||||
# request can never shrink a prior grant or wipe its claims. claims_requests is
|
||||
# written only when supplied (nil = keep whatever is stored, defaulting to {}
|
||||
# for a brand-new record).
|
||||
def self.record!(user:, application:, scopes:, claims_requests: nil, merge: false)
|
||||
consent = find_or_initialize_by(user: user, application: application)
|
||||
incoming = Array(scopes)
|
||||
consent.scopes = merge ? (consent.scopes | incoming) : incoming
|
||||
consent.claims_requests = claims_requests unless claims_requests.nil?
|
||||
consent.claims_requests ||= {}
|
||||
consent.granted_at = Time.current
|
||||
consent.save!
|
||||
consent
|
||||
end
|
||||
|
||||
# Parse scopes_granted into an array (nil-safe for not-yet-saved records).
|
||||
def scopes
|
||||
scopes_granted.split(" ")
|
||||
scopes_granted.to_s.split(" ")
|
||||
end
|
||||
|
||||
# Set scopes from an array
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# Small persisted key/value store for runtime-togglable configuration that an
|
||||
# admin can flip from the UI without a redeploy (e.g. the dynamic client
|
||||
# registration window). Values are stored as strings; use the typed helpers.
|
||||
class Setting < ApplicationRecord
|
||||
validates :key, presence: true, uniqueness: true
|
||||
|
||||
def self.get(key)
|
||||
find_by(key: key.to_s)&.value
|
||||
end
|
||||
|
||||
def self.set(key, value)
|
||||
record = find_or_initialize_by(key: key.to_s)
|
||||
record.value = value.to_s
|
||||
record.save!
|
||||
value
|
||||
end
|
||||
|
||||
# Returns nil if the key has never been set, so callers can distinguish
|
||||
# "unset" (fall back to a default) from an explicit false.
|
||||
def self.boolean(key)
|
||||
raw = get(key)
|
||||
return nil if raw.nil?
|
||||
ActiveModel::Type::Boolean.new.cast(raw)
|
||||
end
|
||||
end
|
||||
@@ -23,6 +23,30 @@
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<% dcr_on = Application.dynamic_registration_enabled? %>
|
||||
<div class="mt-4 rounded-lg border px-4 py-3 flex items-center justify-between <%= dcr_on ? "border-amber-300 bg-amber-50 dark:border-amber-700 dark:bg-amber-900/20" : "border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800" %>">
|
||||
<div class="pr-4">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
Dynamic client registration
|
||||
<span class="ml-2 inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold <%= dcr_on ? "bg-amber-200 text-amber-900 dark:bg-amber-800 dark:text-amber-100" : "bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-200" %>">
|
||||
<%= dcr_on ? "On" : "Off" %>
|
||||
</span>
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-gray-600 dark:text-gray-400">
|
||||
<% if dcr_on %>
|
||||
Clients can self-register via <span class="font-mono">POST /oauth/register</span> (RFC 7591). New clients get no access until you attach a group. Disable this once your client is connected.
|
||||
<% else %>
|
||||
New clients must be created here. Enable briefly to let a client (e.g. an MCP connector) register itself, then disable again.
|
||||
<% end %>
|
||||
</p>
|
||||
</div>
|
||||
<%= button_to dcr_on ? "Disable" : "Enable",
|
||||
admin_dynamic_client_registration_path,
|
||||
method: :patch,
|
||||
params: {enabled: !dcr_on},
|
||||
class: "shrink-0 rounded-md px-3 py-2 text-sm font-semibold text-white shadow-sm #{dcr_on ? "bg-amber-600 hover:bg-amber-500" : "bg-blue-600 hover:bg-blue-500"}" %>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flow-root">
|
||||
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
|
||||
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<%#
|
||||
Terminal device-authorization states, shared by the /device prompt (show) and
|
||||
the result page. Pass `state:` as one of :expired, :already_handled, :not_found.
|
||||
%>
|
||||
<% headings = {
|
||||
expired: "Code expired",
|
||||
already_handled: "Code already used",
|
||||
not_found: "Code not found"
|
||||
} %>
|
||||
<% messages = {
|
||||
expired: "This device code has expired. Start again from your tool to get a fresh code.",
|
||||
already_handled: "This device code has already been approved or denied. Start again from your tool if you need a new one.",
|
||||
not_found: "We couldn't find that code. Check the code your tool is showing and try again."
|
||||
} %>
|
||||
<div class="text-center">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100"><%= headings[state] %></h2>
|
||||
<p class="mt-3 text-sm text-gray-600 dark:text-gray-400"><%= messages[state] %></p>
|
||||
<%= link_to "Enter a different code", device_verification_path, class: "mt-6 inline-block text-sm font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400" %>
|
||||
</div>
|
||||
@@ -0,0 +1,37 @@
|
||||
<div class="mx-auto max-w-md">
|
||||
<div class="bg-white dark:bg-gray-800 py-8 px-6 shadow rounded-lg sm:px-10 text-center">
|
||||
<% case @state %>
|
||||
<% when :approved %>
|
||||
<svg class="mx-auto h-14 w-14 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
<h2 class="mt-4 text-2xl font-bold text-gray-900 dark:text-gray-100">Device approved</h2>
|
||||
<p class="mt-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
<strong><%= @application.name %></strong> now has access to your account. You can return to your
|
||||
terminal — it will continue automatically. You may close this tab.
|
||||
</p>
|
||||
|
||||
<% when :denied %>
|
||||
<svg class="mx-auto h-14 w-14 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
<h2 class="mt-4 text-2xl font-bold text-gray-900 dark:text-gray-100">Request denied</h2>
|
||||
<p class="mt-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
No access was granted. You can close this tab.
|
||||
</p>
|
||||
|
||||
<% when :not_allowed %>
|
||||
<svg class="mx-auto h-14 w-14 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"/>
|
||||
</svg>
|
||||
<h2 class="mt-4 text-2xl font-bold text-gray-900 dark:text-gray-100">Access not allowed</h2>
|
||||
<p class="mt-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
Your account isn't a member of a group permitted to use <strong><%= @application.name %></strong>.
|
||||
Contact an administrator if you think this is a mistake.
|
||||
</p>
|
||||
|
||||
<% else %>
|
||||
<%= render "terminal_state", state: @state %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,64 @@
|
||||
<div class="mx-auto max-w-md">
|
||||
<div class="bg-white dark:bg-gray-800 py-8 px-6 shadow rounded-lg sm:px-10">
|
||||
<% case @state %>
|
||||
<% when :confirm %>
|
||||
<div class="mb-8 text-center">
|
||||
<% if @application.icon.attached? %>
|
||||
<div class="mx-auto h-20 w-20 mb-4">
|
||||
<%= app_icon_picture @application, class: "mx-auto h-20 w-20 rounded-xl object-cover border-2 border-gray-200 dark:border-gray-700 shadow-sm" %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="mx-auto mb-4">
|
||||
<%= render "shared/app_monogram", name: @application.name, class: "h-20 w-20 rounded-xl shadow-sm" %>
|
||||
</div>
|
||||
<% end %>
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100">Authorize device</h2>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<strong><%= @application.name %></strong> is requesting access to your account from a device or command line.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-md bg-gray-50 dark:bg-gray-900/40 p-4 mb-6 text-center">
|
||||
<p class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Code shown on your device</p>
|
||||
<p class="mt-1 font-mono text-2xl font-bold tracking-widest text-gray-900 dark:text-gray-100"><%= @device_code.user_code %></p>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">Only approve if this matches the code your tool is showing.</p>
|
||||
</div>
|
||||
|
||||
<% if @scopes.any? %>
|
||||
<div class="mb-6">
|
||||
<h3 class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">This will be able to:</h3>
|
||||
<%= render "shared/scope_list", scopes: @scopes %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= form_with url: device_verification_path, method: :post, class: "space-y-3", data: { turbo: false }, local: true do |form| %>
|
||||
<%= form.hidden_field :user_code, value: @device_code.user_code %>
|
||||
<%= form.submit "Approve",
|
||||
class: "w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-gray-900 focus:ring-blue-500" %>
|
||||
<%= button_tag "Deny",
|
||||
type: :submit,
|
||||
name: :deny,
|
||||
value: "1",
|
||||
class: "w-full flex justify-center py-2 px-4 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-gray-900 focus:ring-blue-500" %>
|
||||
<% end %>
|
||||
|
||||
<% when :prompt %>
|
||||
<div class="mb-6 text-center">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100">Enter device code</h2>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">Type the code shown by your tool or command line.</p>
|
||||
</div>
|
||||
<%= form_with url: device_verification_path, method: :get, class: "space-y-4", data: { turbo: false }, local: true do |form| %>
|
||||
<%= form.text_field :user_code,
|
||||
autofocus: true,
|
||||
autocomplete: "off",
|
||||
placeholder: "WDJB-MJHT",
|
||||
class: "block w-full text-center font-mono text-xl tracking-widest uppercase rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500" %>
|
||||
<%= form.submit "Continue",
|
||||
class: "w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-gray-900 focus:ring-blue-500" %>
|
||||
<% end %>
|
||||
|
||||
<% else %>
|
||||
<%= render "terminal_state", state: @state %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
@@ -18,40 +18,7 @@
|
||||
|
||||
<div class="mb-6">
|
||||
<h3 class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">This application will be able to:</h3>
|
||||
<ul class="space-y-2">
|
||||
<% if @scopes.include?("openid") %>
|
||||
<li class="flex items-start">
|
||||
<svg class="h-5 w-5 text-green-500 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Verify your identity</span>
|
||||
</li>
|
||||
<% end %>
|
||||
<% if @scopes.include?("email") %>
|
||||
<li class="flex items-start">
|
||||
<svg class="h-5 w-5 text-green-500 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Access your email address (<%= Current.session.user.email_address %>)</span>
|
||||
</li>
|
||||
<% end %>
|
||||
<% if @scopes.include?("profile") %>
|
||||
<li class="flex items-start">
|
||||
<svg class="h-5 w-5 text-green-500 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Access your profile information</span>
|
||||
</li>
|
||||
<% end %>
|
||||
<% if @scopes.include?("groups") %>
|
||||
<li class="flex items-start">
|
||||
<svg class="h-5 w-5 text-green-500 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Access your group memberships</span>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
<%= render "shared/scope_list", scopes: @scopes %>
|
||||
</div>
|
||||
|
||||
<div class="rounded-md bg-blue-50 dark:bg-blue-900/30 p-4 mb-6">
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<%# Renders the "this will be able to..." list of granted OAuth scopes. Shared by
|
||||
the browser consent screen and the device authorization screen so the scope
|
||||
descriptions (see OidcHelper#scope_description) stay in one place.
|
||||
Locals: scopes (Array<String>). %>
|
||||
<ul class="space-y-2">
|
||||
<% scopes.each do |scope| %>
|
||||
<li class="flex items-start">
|
||||
<svg class="h-5 w-5 text-green-500 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300"><%= scope_description(scope) %></span>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
@@ -1,5 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Clinch
|
||||
VERSION = "0.16.2"
|
||||
VERSION = "0.17.0"
|
||||
end
|
||||
|
||||
@@ -25,14 +25,25 @@ Rails.application.routes.draw do
|
||||
|
||||
# OIDC (OpenID Connect) routes
|
||||
get "/.well-known/openid-configuration", to: "oidc#discovery"
|
||||
# RFC 8414 OAuth 2.0 Authorization Server Metadata (alias of OIDC discovery;
|
||||
# MCP clients look here). OIDC discovery is a superset of the RFC 8414 fields.
|
||||
get "/.well-known/oauth-authorization-server", to: "oidc#discovery"
|
||||
get "/.well-known/jwks.json", to: "oidc#jwks"
|
||||
# RFC 7591 Dynamic Client Registration
|
||||
post "/oauth/register", to: "oidc_registration#create"
|
||||
match "/oauth/authorize", to: "oidc#authorize", via: [:get, :post]
|
||||
post "/oauth/authorize/consent", to: "oidc#consent", as: :oauth_consent
|
||||
post "/oauth/token", to: "oidc#token"
|
||||
post "/oauth/revoke", to: "oidc#revoke"
|
||||
post "/oauth/introspect", to: "oidc#introspect"
|
||||
match "/oauth/userinfo", to: "oidc#userinfo", via: [:get, :post]
|
||||
get "/logout", to: "oidc#logout"
|
||||
|
||||
# OAuth 2.0 Device Authorization Grant (RFC 8628)
|
||||
post "/oauth/device_authorization", to: "oidc#device_authorization"
|
||||
get "/device", to: "device_authorizations#show", as: :device_verification
|
||||
post "/device", to: "device_authorizations#verify"
|
||||
|
||||
# ForwardAuth / Trusted Header SSO
|
||||
namespace :api do
|
||||
get "/verify", to: "forward_auth#verify"
|
||||
@@ -96,6 +107,8 @@ Rails.application.routes.draw do
|
||||
end
|
||||
resources :groups
|
||||
get "access", to: "access_checks#new"
|
||||
# Runtime toggle for the RFC 7591 dynamic client registration window.
|
||||
resource :dynamic_client_registration, only: [:update], controller: "dynamic_client_registration"
|
||||
end
|
||||
|
||||
# Render dynamic PWA files from app/views/pwa/* (remember to link manifest in application.html.erb)
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
class CreateOidcDeviceCodes < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
create_table :oidc_device_codes do |t|
|
||||
t.references :application, null: false, foreign_key: true
|
||||
# user_id is nullable: it stays blank while the code is pending and is
|
||||
# filled in when the user approves the request on the verification page.
|
||||
t.references :user, null: true, foreign_key: true
|
||||
|
||||
# Opaque device_code, stored as an HMAC (never plaintext) — matches the
|
||||
# OidcAuthorizationCode pattern.
|
||||
t.string :device_code_hmac, null: false
|
||||
# Short, human-typable code the user enters on the verification page.
|
||||
# Stored in plaintext because the user reads it off one screen and types
|
||||
# it into another; kept safe by short expiry + single use + rate limiting.
|
||||
t.string :user_code, null: false
|
||||
|
||||
t.string :status, null: false, default: "pending" # pending / approved / denied
|
||||
|
||||
t.string :scope
|
||||
t.string :nonce
|
||||
|
||||
# PKCE (RFC 8628 permits and recommends PKCE for public clients).
|
||||
t.string :code_challenge
|
||||
t.string :code_challenge_method
|
||||
|
||||
# Captured at approval time from the approving user's session.
|
||||
t.string :acr
|
||||
t.integer :auth_time
|
||||
|
||||
t.datetime :expires_at, null: false
|
||||
# Timestamp of the last token-endpoint poll, used to enforce the polling
|
||||
# interval and emit slow_down (RFC 8628 §3.5).
|
||||
t.datetime :last_polled_at
|
||||
t.integer :interval, null: false, default: 5
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :oidc_device_codes, :device_code_hmac, unique: true
|
||||
add_index :oidc_device_codes, :user_code, unique: true
|
||||
add_index :oidc_device_codes, :expires_at
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,12 @@
|
||||
class CreateSettings < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
create_table :settings do |t|
|
||||
t.string :key, null: false
|
||||
t.string :value
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :settings, :key, unique: true
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,12 @@
|
||||
class AddResourceToOidcTokens < ActiveRecord::Migration[8.1]
|
||||
# RFC 8707 Resource Indicators: the audience (target resource server) a token
|
||||
# is bound to. Threaded from the authorize / device_authorization request
|
||||
# through the code and carried across refresh rotation onto the access token,
|
||||
# where introspection reports it as `aud`.
|
||||
def change
|
||||
add_column :oidc_authorization_codes, :resource, :string
|
||||
add_column :oidc_device_codes, :resource, :string
|
||||
add_column :oidc_access_tokens, :resource, :string
|
||||
add_column :oidc_refresh_tokens, :resource, :string
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,8 @@
|
||||
class AddResourceIdentifiersToApplications < ActiveRecord::Migration[8.1]
|
||||
# The RFC 8707 resource identifier(s) this application serves as a resource
|
||||
# server. Used to authorize RFC 7662 introspection: a caller may only introspect
|
||||
# tokens bound to a resource it serves (or tokens issued to itself).
|
||||
def change
|
||||
add_column :applications, :resource_identifiers, :text
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,20 @@
|
||||
class AddDeviceCodeReplayTracking < ActiveRecord::Migration[8.1]
|
||||
# Replay-revocation for the device grant, mirroring the authorization-code path.
|
||||
#
|
||||
# The device flow previously destroy!ed the code on redemption, so a replayed
|
||||
# redeemed code was indistinguishable from an unknown one and the tokens it
|
||||
# minted could not be revoked. Instead we now mark the code redeemed_at and link
|
||||
# the issued tokens back to it, so a second redemption is detected as reuse and
|
||||
# every descended token is revoked (RFC 6749 §4.1.2 reuse semantics).
|
||||
#
|
||||
# on_delete: :nullify matches the oidc_authorization_code FK: the cleanup job can
|
||||
# still delete expired device codes without orphaning or destroying live tokens.
|
||||
def change
|
||||
add_column :oidc_device_codes, :redeemed_at, :datetime
|
||||
|
||||
add_reference :oidc_access_tokens, :oidc_device_code, null: true,
|
||||
foreign_key: {on_delete: :nullify}
|
||||
add_reference :oidc_refresh_tokens, :oidc_device_code, null: true,
|
||||
foreign_key: {on_delete: :nullify}
|
||||
end
|
||||
end
|
||||
Generated
+47
-1
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.1].define(version: 2026_06_11_000001) do
|
||||
ActiveRecord::Schema[8.1].define(version: 2026_07_19_000005) do
|
||||
create_table "active_storage_attachments", force: :cascade do |t|
|
||||
t.bigint "blob_id", null: false
|
||||
t.datetime "created_at", null: false
|
||||
@@ -96,6 +96,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_11_000001) do
|
||||
t.text "redirect_uris"
|
||||
t.integer "refresh_token_ttl", default: 2592000
|
||||
t.boolean "require_pkce", default: true, null: false
|
||||
t.text "resource_identifiers"
|
||||
t.boolean "skip_consent", default: false, null: false
|
||||
t.string "slug", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
@@ -123,6 +124,8 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_11_000001) do
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "expires_at", null: false
|
||||
t.integer "oidc_authorization_code_id"
|
||||
t.integer "oidc_device_code_id"
|
||||
t.string "resource"
|
||||
t.datetime "revoked_at"
|
||||
t.string "scope"
|
||||
t.string "token_hmac"
|
||||
@@ -132,6 +135,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_11_000001) do
|
||||
t.index ["application_id"], name: "index_oidc_access_tokens_on_application_id"
|
||||
t.index ["expires_at"], name: "index_oidc_access_tokens_on_expires_at"
|
||||
t.index ["oidc_authorization_code_id"], name: "index_oidc_access_tokens_on_oidc_authorization_code_id"
|
||||
t.index ["oidc_device_code_id"], name: "index_oidc_access_tokens_on_oidc_device_code_id"
|
||||
t.index ["revoked_at"], name: "index_oidc_access_tokens_on_revoked_at"
|
||||
t.index ["token_hmac"], name: "index_oidc_access_tokens_on_token_hmac", unique: true
|
||||
t.index ["user_id"], name: "index_oidc_access_tokens_on_user_id"
|
||||
@@ -149,6 +153,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_11_000001) do
|
||||
t.datetime "expires_at", null: false
|
||||
t.string "nonce"
|
||||
t.string "redirect_uri", null: false
|
||||
t.string "resource"
|
||||
t.string "scope"
|
||||
t.datetime "updated_at", null: false
|
||||
t.boolean "used", default: false, null: false
|
||||
@@ -161,6 +166,32 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_11_000001) do
|
||||
t.index ["user_id"], name: "index_oidc_authorization_codes_on_user_id"
|
||||
end
|
||||
|
||||
create_table "oidc_device_codes", force: :cascade do |t|
|
||||
t.string "acr"
|
||||
t.integer "application_id", null: false
|
||||
t.integer "auth_time"
|
||||
t.string "code_challenge"
|
||||
t.string "code_challenge_method"
|
||||
t.datetime "created_at", null: false
|
||||
t.string "device_code_hmac", null: false
|
||||
t.datetime "expires_at", null: false
|
||||
t.integer "interval", default: 5, null: false
|
||||
t.datetime "last_polled_at"
|
||||
t.string "nonce"
|
||||
t.datetime "redeemed_at"
|
||||
t.string "resource"
|
||||
t.string "scope"
|
||||
t.string "status", default: "pending", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.string "user_code", null: false
|
||||
t.integer "user_id"
|
||||
t.index ["application_id"], name: "index_oidc_device_codes_on_application_id"
|
||||
t.index ["device_code_hmac"], name: "index_oidc_device_codes_on_device_code_hmac", unique: true
|
||||
t.index ["expires_at"], name: "index_oidc_device_codes_on_expires_at"
|
||||
t.index ["user_code"], name: "index_oidc_device_codes_on_user_code", unique: true
|
||||
t.index ["user_id"], name: "index_oidc_device_codes_on_user_id"
|
||||
end
|
||||
|
||||
create_table "oidc_refresh_tokens", force: :cascade do |t|
|
||||
t.string "acr"
|
||||
t.integer "application_id", null: false
|
||||
@@ -169,6 +200,8 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_11_000001) do
|
||||
t.datetime "expires_at", null: false
|
||||
t.integer "oidc_access_token_id", null: false
|
||||
t.integer "oidc_authorization_code_id"
|
||||
t.integer "oidc_device_code_id"
|
||||
t.string "resource"
|
||||
t.datetime "revoked_at"
|
||||
t.string "scope"
|
||||
t.integer "token_family_id"
|
||||
@@ -180,6 +213,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_11_000001) do
|
||||
t.index ["expires_at"], name: "index_oidc_refresh_tokens_on_expires_at"
|
||||
t.index ["oidc_access_token_id"], name: "index_oidc_refresh_tokens_on_oidc_access_token_id"
|
||||
t.index ["oidc_authorization_code_id"], name: "index_oidc_refresh_tokens_on_oidc_authorization_code_id"
|
||||
t.index ["oidc_device_code_id"], name: "index_oidc_refresh_tokens_on_oidc_device_code_id"
|
||||
t.index ["revoked_at"], name: "index_oidc_refresh_tokens_on_revoked_at"
|
||||
t.index ["token_family_id"], name: "index_oidc_refresh_tokens_on_token_family_id"
|
||||
t.index ["token_hmac"], name: "index_oidc_refresh_tokens_on_token_hmac", unique: true
|
||||
@@ -218,6 +252,14 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_11_000001) do
|
||||
t.index ["user_id"], name: "index_sessions_on_user_id"
|
||||
end
|
||||
|
||||
create_table "settings", force: :cascade do |t|
|
||||
t.datetime "created_at", null: false
|
||||
t.string "key", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.string "value"
|
||||
t.index ["key"], name: "index_settings_on_key", unique: true
|
||||
end
|
||||
|
||||
create_table "user_groups", force: :cascade do |t|
|
||||
t.datetime "created_at", null: false
|
||||
t.integer "group_id", null: false
|
||||
@@ -283,12 +325,16 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_11_000001) do
|
||||
add_foreign_key "application_user_claims", "users", on_delete: :cascade
|
||||
add_foreign_key "oidc_access_tokens", "applications"
|
||||
add_foreign_key "oidc_access_tokens", "oidc_authorization_codes", on_delete: :nullify
|
||||
add_foreign_key "oidc_access_tokens", "oidc_device_codes", on_delete: :nullify
|
||||
add_foreign_key "oidc_access_tokens", "users"
|
||||
add_foreign_key "oidc_authorization_codes", "applications"
|
||||
add_foreign_key "oidc_authorization_codes", "users"
|
||||
add_foreign_key "oidc_device_codes", "applications"
|
||||
add_foreign_key "oidc_device_codes", "users"
|
||||
add_foreign_key "oidc_refresh_tokens", "applications"
|
||||
add_foreign_key "oidc_refresh_tokens", "oidc_access_tokens"
|
||||
add_foreign_key "oidc_refresh_tokens", "oidc_authorization_codes", on_delete: :nullify
|
||||
add_foreign_key "oidc_refresh_tokens", "oidc_device_codes", on_delete: :nullify
|
||||
add_foreign_key "oidc_refresh_tokens", "users"
|
||||
add_foreign_key "oidc_user_consents", "applications"
|
||||
add_foreign_key "oidc_user_consents", "users"
|
||||
|
||||
+51
@@ -7,3 +7,54 @@
|
||||
# ["Action", "Comedy", "Drama", "Horror"].each do |genre_name|
|
||||
# MovieGenre.find_or_create_by!(name: genre_name)
|
||||
# end
|
||||
|
||||
# --- OAuth clients for CLI / agent access and token introspection ---------------
|
||||
#
|
||||
# These support the Device Authorization Grant (RFC 8628) and RFC 7662 token
|
||||
# introspection. See docs/decisions/0002-device-authorization-grant.md.
|
||||
|
||||
admins = Group.find_by(admin: true)
|
||||
|
||||
# Public client (no secret, PKCE) used by CLIs and agents via the device flow.
|
||||
# Ships with a well-known client_id so tools can hard-code it.
|
||||
cli = Application.find_or_create_by!(client_id: "clinch-cli") do |app|
|
||||
app.name = "Clinch CLI"
|
||||
app.slug = "clinch-cli"
|
||||
app.app_type = "oidc"
|
||||
app.is_public_client = true
|
||||
app.active = true
|
||||
end
|
||||
|
||||
# Grant the CLI to the admins group by default (device flow enforces
|
||||
# Application#user_allowed?). Adjust to taste.
|
||||
if admins && cli.allowed_groups.exclude?(admins)
|
||||
cli.allowed_groups << admins
|
||||
puts "Seeded 'clinch-cli' public client (allowed group: #{admins.name})."
|
||||
end
|
||||
|
||||
# Confidential client that resource servers (e.g. c2a2) use to authenticate to
|
||||
# the introspection endpoint. The secret is only shown once, on creation.
|
||||
#
|
||||
# resource_identifiers declares the RFC 8707 resource URI(s) this server answers
|
||||
# for. Introspection is authorized against it: c2a2 may only introspect tokens
|
||||
# whose bound audience is one of these. The CLI/agent must therefore request its
|
||||
# token with resource=<C2A2_RESOURCE>. Set C2A2_RESOURCE to c2a2's real URL.
|
||||
unless Application.exists?(client_id: "c2a2-introspection")
|
||||
secret = SecureRandom.urlsafe_base64(48)
|
||||
c2a2_resource = ENV["C2A2_RESOURCE"].presence || "https://c2a2.example.com"
|
||||
Application.create!(
|
||||
name: "c2a2 (introspection caller)",
|
||||
slug: "c2a2-introspection",
|
||||
client_id: "c2a2-introspection",
|
||||
client_secret: secret,
|
||||
app_type: "oidc",
|
||||
active: true,
|
||||
resource_identifiers: [c2a2_resource].to_json
|
||||
)
|
||||
puts "Seeded 'c2a2-introspection' confidential client:"
|
||||
puts " client_id: c2a2-introspection"
|
||||
puts " client_secret: #{secret}"
|
||||
puts " resource_identifier: #{c2a2_resource}"
|
||||
puts " Store the secret in c2a2 now — it is hashed and cannot be recovered."
|
||||
puts " The CLI must request tokens with resource=#{c2a2_resource} for c2a2 to introspect them."
|
||||
end
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# 0001 — Opaque access tokens (not JWT); introspection for resource servers
|
||||
|
||||
**Status:** Accepted · **Date:** 2026-07-19
|
||||
|
||||
## Decision
|
||||
|
||||
Clinch issues **opaque** access and refresh tokens — random strings stored server-side
|
||||
as SHA-256 HMACs (`OidcAccessToken` / `OidcRefreshToken`), not self-contained JWTs. The
|
||||
**ID token stays a JWT** (RS256), because it is meant to be read by the client. Resource
|
||||
servers that need to validate an access token call the **RFC 7662 introspection endpoint**
|
||||
(`POST /oauth/introspect`), which also returns the user's `groups` for authorization.
|
||||
|
||||
## Context
|
||||
|
||||
There is no universal winner between opaque and JWT access tokens; it is an architecture
|
||||
call:
|
||||
|
||||
| | Opaque (reference) | JWT (self-contained) |
|
||||
|---|---|---|
|
||||
| Validation | Resource server calls back (introspect/userinfo) | Offline signature check against JWKS |
|
||||
| **Revocation** | **Instant** — the AS holds the state | Valid until expiry unless a blocklist is added (which re-adds state) |
|
||||
| Best when | One central IdP, few resource servers, revocation matters | Many resource servers, high throughput, offline verification needed |
|
||||
| Token contents | Nothing leaks (just a handle) | Claims readable by any holder |
|
||||
|
||||
Clinch is a single self-hosted IdP with a handful of relying parties. It already has
|
||||
instant revocation, including refresh-token **family revocation** on reuse. That
|
||||
revocation guarantee is a real security property for an IdP, and the introspection
|
||||
callback cost is negligible at this scale (and cacheable by the resource server).
|
||||
|
||||
## Consequences
|
||||
|
||||
- Resource servers (e.g. c2a2) cannot verify tokens offline; they must call
|
||||
`/oauth/introspect` (authenticated as a confidential client) and should briefly cache
|
||||
positive results.
|
||||
- Tokens can be revoked immediately (logout, admin action, reuse detection) and stop
|
||||
working at the next introspection — a property JWT access tokens can't offer without
|
||||
reintroducing server state.
|
||||
- If we ever need many resource servers with zero-latency offline verification, revisit
|
||||
with RFC 9068 (JWT access token profile) — accepting the loss of instant revocation.
|
||||
@@ -0,0 +1,50 @@
|
||||
# 0002 — CLI/agent auth via the Device Authorization Grant (RFC 8628)
|
||||
|
||||
**Status:** Accepted · **Date:** 2026-07-19
|
||||
|
||||
## Decision
|
||||
|
||||
CLIs and terminal agents (e.g. Claude) authenticate to Clinch-protected services as a
|
||||
real user via the **OAuth 2.0 Device Authorization Grant (RFC 8628)** instead of static
|
||||
API keys. The tool prints a short code and a URL; the user approves at `/device` with
|
||||
their passkey; the tool polls the token endpoint and receives the standard
|
||||
access + refresh + ID token triple.
|
||||
|
||||
## Context
|
||||
|
||||
We wanted CLIs — and especially headless agents — to authenticate as a real user rather
|
||||
than carry a long-lived API key. The two mainstream options:
|
||||
|
||||
- **Device flow (RFC 8628):** tool prints a code, human approves on any device, tool
|
||||
polls. Needs only "print text" + "poll HTTP".
|
||||
- **Auth code + PKCE with a loopback (`127.0.0.1`) redirect (RFC 8252):** tool opens a
|
||||
browser and catches a local redirect.
|
||||
|
||||
Agents are often sandboxed or run on a remote box where opening a browser and receiving a
|
||||
loopback redirect is unreliable. Device flow needs neither, which is exactly why it fits
|
||||
CLIs and agents. The human approves wherever their passkey lives.
|
||||
|
||||
## Implementation notes
|
||||
|
||||
- `OidcDeviceCode` mirrors `OidcAuthorizationCode`: opaque `device_code` stored as an
|
||||
HMAC, short plaintext `user_code`, nullable `user` until approval, `status`
|
||||
(pending/approved/denied), PKCE columns, and `interval`/`last_polled_at` for
|
||||
`slow_down` enforcement.
|
||||
- Endpoints: `POST /oauth/device_authorization`, the
|
||||
`urn:ietf:params:oauth:grant-type:device_code` branch of `POST /oauth/token`, and the
|
||||
authenticated verification page `GET/POST /device`.
|
||||
- Token issuance reuses the authorization-code path (`OidcAccessToken` +
|
||||
`OidcRefreshToken` + `OidcJwtService`). Access control reuses
|
||||
`Application#user_allowed?`, so approval is gated by group membership.
|
||||
- PKCE is **optional** for device flow (RFC 8628 §5.5): enforced only when the device
|
||||
authorization request supplied a `code_challenge`. The `device_code` itself is a
|
||||
high-entropy secret delivered directly to the client over TLS.
|
||||
- A well-known public client `clinch-cli` (no secret, PKCE) is seeded for tools to use.
|
||||
|
||||
## Consequences
|
||||
|
||||
- No static API keys for user-context CLI/agent access; tokens are revocable and expire.
|
||||
- Resource servers validate the resulting opaque access tokens via introspection — see
|
||||
[0001](0001-opaque-vs-jwt-access-tokens.md).
|
||||
- Same foundation (public clients + PKCE + introspection) supports future MCP connector
|
||||
support, whose main additional piece would be Dynamic Client Registration (RFC 7591).
|
||||
@@ -0,0 +1,55 @@
|
||||
# 0003 — Dynamic Client Registration (RFC 7591), runtime-gated
|
||||
|
||||
**Status:** Accepted · **Date:** 2026-07-19
|
||||
|
||||
## Decision
|
||||
|
||||
Clinch supports **OAuth 2.0 Dynamic Client Registration (RFC 7591)** at
|
||||
`POST /oauth/register`, so clients (notably MCP connectors like Claude) can register
|
||||
themselves instead of being hand-created. It is **off by default** and toggled at runtime
|
||||
by an admin from the Applications page. A newly registered client is **default-deny**: it
|
||||
has no `allowed_groups` until an admin attaches one.
|
||||
|
||||
We also serve the **RFC 8414** metadata alias at
|
||||
`/.well-known/oauth-authorization-server` (the OIDC discovery document is a superset), and
|
||||
advertise `registration_endpoint` only while registration is enabled.
|
||||
|
||||
## Context
|
||||
|
||||
MCP connectors expect to self-register via anonymous DCR rather than being pre-provisioned.
|
||||
But open registration is a real risk: anyone could register a legitimate-looking client and
|
||||
attempt **consent phishing** — luring a user to approve it, then holding a token that acts
|
||||
as that user against any resource server that trusts clinch tokens (via introspection, see
|
||||
[0001](0001-opaque-vs-jwt-access-tokens.md)).
|
||||
|
||||
Two controls make this safe:
|
||||
|
||||
1. **Runtime window, not always-on.** DCR is a toggle (persisted `Setting`, admin UI), so
|
||||
the operator opens it briefly, lets the client register, attaches a group, and closes it
|
||||
again. The `CLINCH_DCR_ENABLED` env var is only a bootstrap default when the setting is
|
||||
unset. Default is off.
|
||||
2. **Default-deny for new clients.** Clinch's authorize flow already gates on
|
||||
`Application#user_allowed?` (group membership), evaluated *before* the consent screen
|
||||
renders. A group-less registered client therefore can't show any user an approve button —
|
||||
the consent-phishing path dead-ends until an admin explicitly grants a group. ForwardAuth
|
||||
services are gated by the user's session cookie, not client tokens, so DCR doesn't widen
|
||||
that surface at all.
|
||||
|
||||
## Implementation notes
|
||||
|
||||
- `OidcRegistrationController#create`: validates `token_endpoint_auth_method`
|
||||
(none/client_secret_basic/client_secret_post), `grant_types`
|
||||
(authorization_code/refresh_token), `response_types` (code), and `redirect_uris`
|
||||
(https anywhere; http only for loopback). Creates a public or confidential `Application`
|
||||
with `require_pkce: true`, returns the RFC 7591 response (client_secret once, for
|
||||
confidential clients).
|
||||
- `Setting` is a small key/value store; `Application.dynamic_registration_enabled?` reads it
|
||||
with the env var as fallback. Admin toggle: `Admin::DynamicClientRegistrationController`.
|
||||
|
||||
## Consequences
|
||||
|
||||
- MCP connectors can self-register when the window is open, then operate normally once an
|
||||
admin grants a group.
|
||||
- No always-on anonymous registration surface; the risky window is short and operator-driven.
|
||||
- Remaining MCP pieces (resource indicators RFC 8707, protected-resource metadata RFC 9728 on
|
||||
the resource server) are separate, smaller follow-ups.
|
||||
@@ -0,0 +1,51 @@
|
||||
# 0004 — Resource Indicators (RFC 8707), pass-through binding
|
||||
|
||||
**Status:** Accepted · **Date:** 2026-07-19
|
||||
|
||||
## Decision
|
||||
|
||||
Clinch accepts the RFC 8707 `resource` parameter at `/oauth/authorize` and
|
||||
`/oauth/device_authorization`, binds it to the issued token as its audience, and
|
||||
reports it at introspection as `aud`. Validation is **syntax-only (pass-through)**:
|
||||
the value must be an absolute URI without a fragment; clinch does not maintain a
|
||||
registry of resource servers. The **resource server enforces** the audience when it
|
||||
introspects the token.
|
||||
|
||||
## Context
|
||||
|
||||
Without an audience, an access token minted for one API could be replayed against
|
||||
another API that also trusts clinch (the confused-deputy problem). RFC 8707 lets the
|
||||
client name the target (`resource=https://c2a2.example.com`); the token is then bound
|
||||
to that audience and is useless elsewhere.
|
||||
|
||||
Two ways to handle the value:
|
||||
|
||||
- **Pass-through (chosen):** validate the URI syntax, store it, report it as `aud`.
|
||||
Enforcement is at the resource server, which already validates tokens via
|
||||
introspection ([0001](0001-opaque-vs-jwt-access-tokens.md)) and simply checks
|
||||
`aud == <its own identifier>`. A token bound to an arbitrary audience is worthless
|
||||
anywhere that isn't that audience, so no clinch-side registry is needed.
|
||||
- **Registry-gated (not chosen):** reject unknown resources with `invalid_target`.
|
||||
Catches typos early but requires clinch to model and maintain resource-server
|
||||
identities, which it does not have today.
|
||||
|
||||
## Implementation notes
|
||||
|
||||
- `resource` is threaded from the authorize / device_authorization request onto the
|
||||
authorization/device code, then onto the access and refresh tokens, and is carried
|
||||
across refresh rotation so re-issued tokens keep the audience.
|
||||
- Invalid resources are rejected with `error=invalid_target` (redirect for authorize,
|
||||
JSON 400 for device_authorization). Validation lives in
|
||||
`OidcController#valid_resource_indicator?` (absolute URI, no fragment).
|
||||
- Introspection returns `aud = access_token.resource` when bound, falling back to the
|
||||
client_id when no resource indicator was used.
|
||||
- Binding happens at authorization time and is carried through; token-time `resource`
|
||||
narrowing is not implemented (not needed for the MCP / device flows).
|
||||
|
||||
## Consequences
|
||||
|
||||
- Tokens can be scoped to a single resource server, closing the cross-service replay
|
||||
path — enforced where it belongs, at the resource server.
|
||||
- Completes the clinch-side OAuth surface MCP connectors rely on (with DCR
|
||||
[0003](0003-dynamic-client-registration.md) and introspection). The remaining MCP
|
||||
piece, Protected Resource Metadata (RFC 9728), lives on the resource server.
|
||||
@@ -0,0 +1,46 @@
|
||||
# 0005 — Introspection authorization & claim scope-gating
|
||||
|
||||
**Status:** Accepted · **Date:** 2026-07-19
|
||||
|
||||
## Decision
|
||||
|
||||
The RFC 7662 introspection endpoint restricts *which* tokens a caller may see and
|
||||
*which* claims it returns:
|
||||
|
||||
1. **Authorization to introspect.** A caller may introspect a token only if it was
|
||||
issued to that caller, or the token is bound (RFC 8707 `resource`) to a resource
|
||||
the caller is registered to serve (`Application#serves_resource?`, backed by a new
|
||||
`resource_identifiers` column). Unauthorized callers get the same `{active:false}`
|
||||
as an unknown token — disclosing nothing.
|
||||
2. **Claim scope-gating.** `username` (email) is returned only when the token carries
|
||||
the `email` scope; `groups` only with the `groups` scope — mirroring the userinfo
|
||||
endpoint. `sub` is a pairwise pseudonym and is always safe to return.
|
||||
|
||||
## Context
|
||||
|
||||
The first cut authenticated the caller (any confidential client) but then returned
|
||||
`active:true` plus the user's email and **all** group names for **any** token — even
|
||||
tokens issued to a different client and regardless of the token's scopes. A
|
||||
low-privilege second client could therefore harvest every user's email and group
|
||||
memberships by replaying tokens it observed. RFC 7662 §4 explicitly calls for the AS
|
||||
to verify the resource server is authorized to introspect the particular token,
|
||||
typically via audience restriction.
|
||||
|
||||
## How it fits together
|
||||
|
||||
This is the enforcement half of the RFC 8707 resource indicators
|
||||
([0004](0004-resource-indicators.md)): the CLI/agent requests a token with
|
||||
`resource=<resource server>`, the resource server is registered with that same
|
||||
identifier, and only it can introspect (and thereby read the user's groups to
|
||||
authorize). A token minted for one resource server cannot be introspected by another.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **c2a2 setup:** the `c2a2-introspection` client must declare its
|
||||
`resource_identifiers` (seeded from `C2A2_RESOURCE`), and the CLI must request its
|
||||
token with `resource=<that URL>`. A token with no bound resource can only be
|
||||
introspected by the client it was issued to.
|
||||
- Resource servers see only the identity claims the token was actually granted, so an
|
||||
over-broad token (or a misconfigured scope) can't leak email/groups.
|
||||
- Unauthorized introspection is indistinguishable from an unknown token, preventing
|
||||
token-scanning and cross-client identity harvesting.
|
||||
@@ -0,0 +1,14 @@
|
||||
# Architecture Decision Records
|
||||
|
||||
Short, dated records of non-obvious technical decisions in Clinch. They live in the
|
||||
repo (rather than a wiki) so they version with the code and travel with a checkout.
|
||||
|
||||
Each file is one decision. Newest decisions get the next number.
|
||||
|
||||
| # | Decision |
|
||||
|---|----------|
|
||||
| [0001](0001-opaque-vs-jwt-access-tokens.md) | Access tokens are opaque (not JWT); resource servers use introspection |
|
||||
| [0002](0002-device-authorization-grant.md) | CLI/agent auth uses the OAuth 2.0 Device Authorization Grant (RFC 8628) |
|
||||
| [0003](0003-dynamic-client-registration.md) | Dynamic Client Registration (RFC 7591), runtime-gated + default-deny |
|
||||
| [0004](0004-resource-indicators.md) | Resource Indicators (RFC 8707) bind token audience; pass-through validation |
|
||||
| [0005](0005-introspection-authorization.md) | Introspection restricted to authorized callers + claim scope-gating |
|
||||
@@ -16,6 +16,10 @@ class OidcClaimsSecurityTest < ActionDispatch::IntegrationTest
|
||||
@application.generate_new_client_secret!
|
||||
@plain_client_secret = @application.client_secret
|
||||
@application.save!
|
||||
|
||||
# The user must be allowed on the app for tokens to be minted (mint-time
|
||||
# authorization re-check); these tests create codes/tokens directly.
|
||||
grant_everyone_access(@application)
|
||||
end
|
||||
|
||||
def teardown
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
require "test_helper"
|
||||
|
||||
class OidcDeviceFlowControllerTest < ActionDispatch::IntegrationTest
|
||||
DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code".freeze
|
||||
|
||||
def setup
|
||||
@group = Group.create!(name: "device-flow-testers", description: "test")
|
||||
@user = User.create!(email_address: "device_flow@example.com", password: "password123")
|
||||
@user.groups << @group
|
||||
|
||||
@cli = Application.create!(
|
||||
name: "Device Flow CLI",
|
||||
slug: "device-flow-cli",
|
||||
app_type: "oidc",
|
||||
is_public_client: true,
|
||||
active: true
|
||||
)
|
||||
@cli.allowed_groups << @group
|
||||
|
||||
@resource_secret = "resource-server-secret-value-1234567890"
|
||||
@resource = Application.create!(
|
||||
name: "Device Flow Resource Server",
|
||||
slug: "device-flow-rs",
|
||||
app_type: "oidc",
|
||||
client_secret: @resource_secret,
|
||||
active: true
|
||||
)
|
||||
end
|
||||
|
||||
def teardown
|
||||
Current.session = nil
|
||||
[@cli, @resource].each do |app|
|
||||
OidcRefreshToken.where(application: app).delete_all
|
||||
OidcAccessToken.where(application: app).delete_all
|
||||
OidcDeviceCode.where(application: app).delete_all
|
||||
OidcUserConsent.where(application: app).delete_all
|
||||
end
|
||||
end
|
||||
|
||||
# --- Discovery -------------------------------------------------------------
|
||||
|
||||
test "discovery advertises the device grant and new endpoints" do
|
||||
get "/.well-known/openid-configuration"
|
||||
assert_response :success
|
||||
config = JSON.parse(@response.body)
|
||||
|
||||
assert_includes config["grant_types_supported"], DEVICE_GRANT
|
||||
assert config["device_authorization_endpoint"].end_with?("/oauth/device_authorization")
|
||||
assert config["introspection_endpoint"].end_with?("/oauth/introspect")
|
||||
end
|
||||
|
||||
# --- Device authorization endpoint -----------------------------------------
|
||||
|
||||
test "device_authorization issues a device_code and user_code" do
|
||||
post "/oauth/device_authorization", params: {
|
||||
client_id: @cli.client_id, scope: "openid groups",
|
||||
code_challenge: code_challenge_for(CODE_VERIFIER)
|
||||
}
|
||||
assert_response :success
|
||||
body = JSON.parse(@response.body)
|
||||
|
||||
assert body["device_code"].present?
|
||||
assert_match(/\A[A-HJ-NP-Z2-9]{8}\z/, body["user_code"])
|
||||
assert body["verification_uri"].end_with?("/device")
|
||||
assert body["verification_uri_complete"].include?("user_code=#{body["user_code"]}")
|
||||
assert_equal 5, body["interval"]
|
||||
assert body["expires_in"].positive?
|
||||
end
|
||||
|
||||
test "device_authorization requires PKCE for a public client" do
|
||||
post "/oauth/device_authorization", params: {client_id: @cli.client_id, scope: "openid"}
|
||||
assert_response :bad_request
|
||||
assert_equal "invalid_request", JSON.parse(@response.body)["error"]
|
||||
assert_equal 0, OidcDeviceCode.where(application: @cli).count
|
||||
end
|
||||
|
||||
test "device_authorization rejects an unknown client" do
|
||||
post "/oauth/device_authorization", params: {client_id: "does-not-exist"}
|
||||
assert_response :unauthorized
|
||||
assert_equal "invalid_client", JSON.parse(@response.body)["error"]
|
||||
end
|
||||
|
||||
test "device_authorization rejects a confidential client with no secret" do
|
||||
post "/oauth/device_authorization", params: {client_id: @resource.client_id, scope: "openid"}
|
||||
assert_response :unauthorized
|
||||
assert_equal "invalid_client", JSON.parse(@response.body)["error"]
|
||||
end
|
||||
|
||||
test "device_authorization rejects a confidential client with a wrong secret" do
|
||||
post "/oauth/device_authorization",
|
||||
params: {client_id: @resource.client_id, client_secret: "wrong-secret", scope: "openid"}
|
||||
assert_response :unauthorized
|
||||
assert_equal "invalid_client", JSON.parse(@response.body)["error"]
|
||||
end
|
||||
|
||||
test "device_authorization accepts a confidential client with a valid secret" do
|
||||
post "/oauth/device_authorization", params: {
|
||||
client_id: @resource.client_id, client_secret: @resource_secret, scope: "openid",
|
||||
code_challenge: code_challenge_for(CODE_VERIFIER), code_challenge_method: "S256"
|
||||
}
|
||||
assert_response :success
|
||||
assert JSON.parse(@response.body)["device_code"].present?
|
||||
end
|
||||
|
||||
# --- Token endpoint device_code grant --------------------------------------
|
||||
|
||||
test "token endpoint returns authorization_pending while pending" do
|
||||
dc = OidcDeviceCode.create!(application: @cli, scope: "openid")
|
||||
poll(dc)
|
||||
assert_response :bad_request
|
||||
assert_equal "authorization_pending", JSON.parse(@response.body)["error"]
|
||||
end
|
||||
|
||||
test "token endpoint returns slow_down when polled faster than the interval" do
|
||||
dc = OidcDeviceCode.create!(application: @cli, scope: "openid")
|
||||
poll(dc) # first poll records last_polled_at
|
||||
poll(dc) # immediate second poll is too fast
|
||||
assert_response :bad_request
|
||||
assert_equal "slow_down", JSON.parse(@response.body)["error"]
|
||||
end
|
||||
|
||||
test "slow_down interval is capped and does not grow without bound" do
|
||||
dc = OidcDeviceCode.create!(application: @cli, scope: "openid")
|
||||
# Hammer the code far more times than it would take to exceed the cap if the
|
||||
# interval grew by 5 unbounded (20 * 5 = 100s >> MAX_INTERVAL).
|
||||
20.times { poll(dc) }
|
||||
assert_operator dc.reload.interval, :<=, OidcDeviceCode::MAX_INTERVAL
|
||||
end
|
||||
|
||||
test "token endpoint returns expired_token for an expired code" do
|
||||
dc = OidcDeviceCode.create!(application: @cli, scope: "openid", expires_at: 1.minute.ago)
|
||||
poll(dc)
|
||||
assert_response :bad_request
|
||||
assert_equal "expired_token", JSON.parse(@response.body)["error"]
|
||||
end
|
||||
|
||||
test "token endpoint returns access_denied when the user denied" do
|
||||
dc = OidcDeviceCode.create!(application: @cli, scope: "openid")
|
||||
dc.deny!
|
||||
poll(dc)
|
||||
assert_response :bad_request
|
||||
assert_equal "access_denied", JSON.parse(@response.body)["error"]
|
||||
end
|
||||
|
||||
test "token endpoint issues tokens once approved, then the code is single-use" do
|
||||
OidcUserConsent.create!(user: @user, application: @cli, scopes_granted: "openid groups", granted_at: Time.current)
|
||||
dc = OidcDeviceCode.create!(
|
||||
application: @cli, scope: "openid groups",
|
||||
code_challenge: code_challenge_for(CODE_VERIFIER), code_challenge_method: "S256"
|
||||
)
|
||||
dc.approve!(user: @user, acr: "1", auth_time: Time.current.to_i)
|
||||
|
||||
poll(dc, code_verifier: CODE_VERIFIER)
|
||||
assert_response :success
|
||||
body = JSON.parse(@response.body)
|
||||
assert body["access_token"].present?
|
||||
assert body["refresh_token"].present?
|
||||
assert body["id_token"].present?
|
||||
assert_equal "Bearer", body["token_type"]
|
||||
assert_equal "openid groups", body["scope"]
|
||||
|
||||
# Replaying the (now consumed) device_code fails, and is reported as reuse —
|
||||
# distinguishable from the generic "Invalid device_code" for an unknown code.
|
||||
poll(dc, code_verifier: CODE_VERIFIER)
|
||||
assert_response :bad_request
|
||||
replay = JSON.parse(@response.body)
|
||||
assert_equal "invalid_grant", replay["error"]
|
||||
assert_match(/already been used/i, replay["error_description"])
|
||||
end
|
||||
|
||||
test "replaying a redeemed device_code revokes the tokens it issued" do
|
||||
OidcUserConsent.create!(user: @user, application: @cli, scopes_granted: "openid", granted_at: Time.current)
|
||||
dc = OidcDeviceCode.create!(
|
||||
application: @cli, scope: "openid",
|
||||
code_challenge: code_challenge_for(CODE_VERIFIER), code_challenge_method: "S256"
|
||||
)
|
||||
dc.approve!(user: @user, acr: "1", auth_time: Time.current.to_i)
|
||||
|
||||
poll(dc, code_verifier: CODE_VERIFIER)
|
||||
assert_response :success
|
||||
access = OidcAccessToken.find_by_token(JSON.parse(@response.body)["access_token"])
|
||||
refresh = OidcRefreshToken.where(oidc_device_code: dc).first
|
||||
assert access.active?, "token should be live before the replay"
|
||||
|
||||
# The code is kept (not destroyed) so the replay is detectable...
|
||||
assert dc.reload.redeemed?
|
||||
poll(dc, code_verifier: CODE_VERIFIER)
|
||||
assert_response :bad_request
|
||||
|
||||
# ...and every token descended from the replayed code is revoked.
|
||||
assert access.reload.revoked?
|
||||
assert refresh.reload.revoked?
|
||||
end
|
||||
|
||||
test "token endpoint refuses a PKCE-required client whose device_code lacks a challenge" do
|
||||
OidcUserConsent.create!(user: @user, application: @cli, scopes_granted: "openid", granted_at: Time.current)
|
||||
# A device_code minted without PKCE (e.g. slipped past the front door) must
|
||||
# never redeem tokens for a public client.
|
||||
dc = OidcDeviceCode.create!(application: @cli, scope: "openid")
|
||||
dc.approve!(user: @user, acr: "1", auth_time: Time.current.to_i)
|
||||
|
||||
poll(dc)
|
||||
assert_response :bad_request
|
||||
assert_equal "invalid_grant", JSON.parse(@response.body)["error"]
|
||||
end
|
||||
|
||||
# --- Verification page -----------------------------------------------------
|
||||
|
||||
test "verification page shows the approval prompt for a signed-in allowed user" do
|
||||
sign_in_as(@user)
|
||||
dc = OidcDeviceCode.create!(application: @cli, scope: "openid groups")
|
||||
|
||||
get "/device", params: {user_code: dc.user_code}
|
||||
assert_response :success
|
||||
assert_match(/Approve/, @response.body)
|
||||
assert_match(dc.user_code, @response.body)
|
||||
end
|
||||
|
||||
test "approving records consent and approves the device code" do
|
||||
sign_in_as(@user)
|
||||
dc = OidcDeviceCode.create!(application: @cli, scope: "openid groups")
|
||||
|
||||
post "/device", params: {user_code: dc.user_code}
|
||||
assert_response :success
|
||||
assert_match(/approved/i, @response.body)
|
||||
|
||||
dc.reload
|
||||
assert dc.approved?
|
||||
assert_equal @user, dc.user
|
||||
assert OidcUserConsent.exists?(user: @user, application: @cli)
|
||||
end
|
||||
|
||||
test "approving a narrower device request merges into existing consent" do
|
||||
# User already consented to a broader scope set (with stored claims) via the
|
||||
# browser flow.
|
||||
existing = OidcUserConsent.create!(
|
||||
user: @user, application: @cli,
|
||||
scopes_granted: "openid email profile groups",
|
||||
claims_requests: {"userinfo" => {"email" => nil}},
|
||||
granted_at: 1.day.ago
|
||||
)
|
||||
|
||||
sign_in_as(@user)
|
||||
dc = OidcDeviceCode.create!(application: @cli, scope: "openid")
|
||||
post "/device", params: {user_code: dc.user_code}
|
||||
assert_response :success
|
||||
|
||||
existing.reload
|
||||
# Prior scopes are preserved (union), not shrunk to the device request's "openid".
|
||||
assert_equal %w[openid email profile groups].sort, existing.scopes.sort
|
||||
# Stored claims are not wiped.
|
||||
assert_equal({"userinfo" => {"email" => nil}}, existing.parsed_claims_requests)
|
||||
end
|
||||
|
||||
test "denying marks the device code denied" do
|
||||
sign_in_as(@user)
|
||||
dc = OidcDeviceCode.create!(application: @cli, scope: "openid")
|
||||
|
||||
post "/device", params: {user_code: dc.user_code, deny: "1"}
|
||||
assert_response :success
|
||||
dc.reload
|
||||
assert dc.denied?
|
||||
end
|
||||
|
||||
test "a user without access cannot approve" do
|
||||
outsider = User.create!(email_address: "outsider@example.com", password: "password123")
|
||||
sign_in_as(outsider)
|
||||
dc = OidcDeviceCode.create!(application: @cli, scope: "openid")
|
||||
|
||||
post "/device", params: {user_code: dc.user_code}
|
||||
assert_response :success
|
||||
assert_match(/not allowed/i, @response.body)
|
||||
dc.reload
|
||||
assert dc.pending?, "device code must stay pending when approval is refused"
|
||||
end
|
||||
|
||||
test "verification page requires authentication" do
|
||||
dc = OidcDeviceCode.create!(application: @cli, scope: "openid")
|
||||
get "/device", params: {user_code: dc.user_code}
|
||||
assert_redirected_to signin_path
|
||||
end
|
||||
|
||||
# --- Terminal-state rendering (shared resolver + partial) ------------------
|
||||
|
||||
test "show prompts for a code when none is given" do
|
||||
sign_in_as(@user)
|
||||
get "/device"
|
||||
assert_response :success
|
||||
assert_match(/Enter device code/i, @response.body)
|
||||
end
|
||||
|
||||
test "show renders the not-found terminal state for an unknown code" do
|
||||
sign_in_as(@user)
|
||||
get "/device", params: {user_code: "ZZZZ9999"}
|
||||
assert_response :success
|
||||
assert_match(/Code not found/i, @response.body)
|
||||
end
|
||||
|
||||
test "show renders the expired terminal state" do
|
||||
sign_in_as(@user)
|
||||
dc = OidcDeviceCode.create!(application: @cli, scope: "openid", expires_at: 1.minute.ago)
|
||||
get "/device", params: {user_code: dc.user_code}
|
||||
assert_response :success
|
||||
assert_match(/Code expired/i, @response.body)
|
||||
end
|
||||
|
||||
test "verify renders the terminal state for an already-handled code" do
|
||||
sign_in_as(@user)
|
||||
dc = OidcDeviceCode.create!(application: @cli, scope: "openid")
|
||||
dc.deny!
|
||||
post "/device", params: {user_code: dc.user_code}
|
||||
assert_response :success
|
||||
assert_match(/Code already used/i, @response.body)
|
||||
end
|
||||
|
||||
# Introspection is covered in depth in oidc_introspection_test.rb.
|
||||
|
||||
private
|
||||
|
||||
# A valid PKCE verifier (48 chars, RFC 7636 charset) and its S256 challenge.
|
||||
CODE_VERIFIER = "device_flow_pkce_code_verifier_0123456789_abcdef".freeze
|
||||
|
||||
def code_challenge_for(verifier)
|
||||
Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false)
|
||||
end
|
||||
|
||||
def poll(device_code, code_verifier: nil)
|
||||
params = {
|
||||
grant_type: DEVICE_GRANT,
|
||||
device_code: device_code.plaintext_device_code,
|
||||
client_id: @cli.client_id
|
||||
}
|
||||
params[:code_verifier] = code_verifier if code_verifier
|
||||
post "/oauth/token", params: params
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,136 @@
|
||||
require "test_helper"
|
||||
|
||||
# RFC 7662 token introspection: authorization (who may introspect which token)
|
||||
# and claim scope-gating (only disclose identity claims the token was granted).
|
||||
class OidcIntrospectionTest < ActionDispatch::IntegrationTest
|
||||
RESOURCE = "https://api.example.com".freeze
|
||||
|
||||
def setup
|
||||
@group = Group.create!(name: "introspection-testers", description: "test")
|
||||
@user = User.create!(email_address: "introspect@example.com", password: "password123")
|
||||
@user.groups << @group
|
||||
|
||||
# The OAuth client the tokens are issued to (a public CLI-style client).
|
||||
@client = Application.create!(name: "Introspect Client", slug: "introspect-client",
|
||||
app_type: "oidc", is_public_client: true, active: true)
|
||||
|
||||
# The resource server that serves RESOURCE and is allowed to introspect
|
||||
# tokens bound to it.
|
||||
@rs_secret = "rs-secret-value-abcdefghijklmnop"
|
||||
@rs = Application.create!(name: "Introspect RS", slug: "introspect-rs", app_type: "oidc",
|
||||
client_secret: @rs_secret, active: true, resource_identifiers: [RESOURCE].to_json)
|
||||
|
||||
# A confidential client that neither issued the token nor serves its resource.
|
||||
@other_secret = "other-secret-value-abcdefghijklmn"
|
||||
@other = Application.create!(name: "Introspect Other", slug: "introspect-other",
|
||||
app_type: "oidc", client_secret: @other_secret, active: true)
|
||||
end
|
||||
|
||||
def teardown
|
||||
[@client, @rs, @other].each do |app|
|
||||
OidcAccessToken.where(application: app).delete_all
|
||||
OidcUserConsent.where(application: app).delete_all
|
||||
end
|
||||
end
|
||||
|
||||
# --- Authorization ---------------------------------------------------------
|
||||
|
||||
test "a resource server may introspect a token bound to a resource it serves" do
|
||||
token = issue(scope: "openid groups email", resource: RESOURCE)
|
||||
body = introspect(token, @rs.client_id, @rs_secret)
|
||||
|
||||
assert_equal true, body["active"]
|
||||
assert_equal @client.client_id, body["client_id"]
|
||||
assert_equal RESOURCE, body["aud"]
|
||||
end
|
||||
|
||||
test "a client may introspect its own token" do
|
||||
token = OidcAccessToken.create!(application: @rs, user: @user, scope: "openid")
|
||||
body = introspect(token, @rs.client_id, @rs_secret)
|
||||
|
||||
assert_equal true, body["active"]
|
||||
assert_equal @rs.client_id, body["aud"]
|
||||
end
|
||||
|
||||
test "a caller cannot introspect a token bound to a resource it does not serve" do
|
||||
token = issue(scope: "openid groups email", resource: RESOURCE)
|
||||
body = introspect(token, @other.client_id, @other_secret)
|
||||
|
||||
assert_equal false, body["active"], "unauthorized caller must learn nothing"
|
||||
assert_nil body["username"]
|
||||
assert_nil body["groups"]
|
||||
end
|
||||
|
||||
test "a caller cannot introspect an unbound token it did not issue" do
|
||||
token = issue(scope: "openid groups", resource: nil)
|
||||
body = introspect(token, @rs.client_id, @rs_secret)
|
||||
|
||||
assert_equal false, body["active"]
|
||||
end
|
||||
|
||||
# --- Claim scope-gating ----------------------------------------------------
|
||||
|
||||
test "omits email and groups when the token lacks those scopes" do
|
||||
token = issue(scope: "openid", resource: RESOURCE)
|
||||
body = introspect(token, @rs.client_id, @rs_secret)
|
||||
|
||||
assert_equal true, body["active"]
|
||||
assert_not body.key?("username"), "email must not leak without the email scope"
|
||||
assert_not body.key?("groups"), "groups must not leak without the groups scope"
|
||||
end
|
||||
|
||||
test "includes email only with the email scope" do
|
||||
token = issue(scope: "openid email", resource: RESOURCE)
|
||||
body = introspect(token, @rs.client_id, @rs_secret)
|
||||
|
||||
assert_equal @user.email_address, body["username"]
|
||||
assert_not body.key?("groups")
|
||||
end
|
||||
|
||||
test "includes groups only with the groups scope" do
|
||||
token = issue(scope: "openid groups", resource: RESOURCE)
|
||||
body = introspect(token, @rs.client_id, @rs_secret)
|
||||
|
||||
assert_includes body["groups"], @group.name
|
||||
assert_not body.key?("username")
|
||||
end
|
||||
|
||||
# --- Token / caller validity ----------------------------------------------
|
||||
|
||||
test "reports inactive for a revoked token even to an authorized caller" do
|
||||
token = issue(scope: "openid groups", resource: RESOURCE)
|
||||
token.revoke!
|
||||
assert_equal false, introspect(token, @rs.client_id, @rs_secret)["active"]
|
||||
end
|
||||
|
||||
test "requires valid caller credentials" do
|
||||
token = issue(scope: "openid", resource: RESOURCE)
|
||||
post "/oauth/introspect", params: {token: token.plaintext_token, client_id: @rs.client_id, client_secret: "wrong"}
|
||||
assert_response :unauthorized
|
||||
end
|
||||
|
||||
test "rejects a public (non-confidential) caller" do
|
||||
token = issue(scope: "openid", resource: RESOURCE)
|
||||
post "/oauth/introspect", params: {token: token.plaintext_token, client_id: @client.client_id}
|
||||
assert_response :unauthorized
|
||||
end
|
||||
|
||||
test "requires a token parameter" do
|
||||
post "/oauth/introspect", params: {client_id: @rs.client_id, client_secret: @rs_secret}
|
||||
assert_response :bad_request
|
||||
assert_equal "invalid_request", JSON.parse(@response.body)["error"]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def issue(scope:, resource:)
|
||||
OidcAccessToken.create!(application: @client, user: @user, scope: scope, resource: resource)
|
||||
end
|
||||
|
||||
def introspect(token, client_id, secret)
|
||||
plaintext = token.respond_to?(:plaintext_token) ? token.plaintext_token : token
|
||||
post "/oauth/introspect", params: {token: plaintext, client_id: client_id, client_secret: secret}
|
||||
assert_response :success
|
||||
JSON.parse(@response.body)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,114 @@
|
||||
require "test_helper"
|
||||
|
||||
# Tokens must not be minted for a user who has lost access (deactivated, or removed
|
||||
# from the application's allowed group) between authorization and the token request.
|
||||
# Every grant re-checks Application#user_allowed? at mint time.
|
||||
class OidcMintAuthorizationTest < ActionDispatch::IntegrationTest
|
||||
DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code".freeze
|
||||
|
||||
def setup
|
||||
@group = Group.create!(name: "mint-authz-testers", description: "test")
|
||||
@user = User.create!(email_address: "mint_authz@example.com", password: "password123")
|
||||
@user.groups << @group
|
||||
|
||||
@secret = "mint-authz-secret-value-abcdefghij"
|
||||
@application = Application.create!(name: "Mint Authz App", slug: "mint-authz-app", app_type: "oidc",
|
||||
client_secret: @secret, active: true, require_pkce: false,
|
||||
redirect_uris: ["https://app.example.com/cb"].to_json)
|
||||
@application.allowed_groups << @group
|
||||
|
||||
OidcUserConsent.create!(user: @user, application: @application, scopes_granted: "openid", granted_at: Time.current)
|
||||
end
|
||||
|
||||
def teardown
|
||||
OidcRefreshToken.where(application: @application).delete_all
|
||||
OidcAccessToken.where(application: @application).delete_all
|
||||
OidcDeviceCode.where(application: @application).delete_all
|
||||
OidcAuthorizationCode.where(application: @application).delete_all
|
||||
OidcUserConsent.where(application: @application).delete_all
|
||||
end
|
||||
|
||||
# --- Device grant ----------------------------------------------------------
|
||||
|
||||
test "device grant issues tokens for a still-allowed user" do
|
||||
poll(approved_device_code)
|
||||
assert_response :success
|
||||
assert JSON.parse(@response.body)["access_token"].present?
|
||||
end
|
||||
|
||||
test "device grant refuses a user removed from the allowed group after approval" do
|
||||
dc = approved_device_code
|
||||
revoke_group!
|
||||
poll(dc)
|
||||
assert_access_denied
|
||||
end
|
||||
|
||||
test "device grant refuses a deactivated user after approval" do
|
||||
dc = approved_device_code
|
||||
@user.disabled!
|
||||
poll(dc)
|
||||
assert_access_denied
|
||||
end
|
||||
|
||||
# --- Authorization code grant ---------------------------------------------
|
||||
|
||||
test "authorization_code grant refuses a user removed from the allowed group" do
|
||||
code = OidcAuthorizationCode.create!(application: @application, user: @user,
|
||||
redirect_uri: "https://app.example.com/cb", scope: "openid", auth_time: Time.current.to_i, acr: "1")
|
||||
revoke_group!
|
||||
post "/oauth/token", params: {grant_type: "authorization_code", code: code.plaintext_code,
|
||||
redirect_uri: "https://app.example.com/cb", client_id: @application.client_id, client_secret: @secret}
|
||||
assert_access_denied
|
||||
end
|
||||
|
||||
# --- Refresh grant ---------------------------------------------------------
|
||||
|
||||
test "refresh_token grant refuses a deactivated user" do
|
||||
refresh = issue_refresh_token
|
||||
@user.disabled!
|
||||
refresh_with(refresh)
|
||||
assert_access_denied
|
||||
end
|
||||
|
||||
test "refresh_token grant refuses a removed user and leaves the token intact" do
|
||||
refresh = issue_refresh_token
|
||||
revoke_group!
|
||||
refresh_with(refresh)
|
||||
assert_access_denied
|
||||
assert_not refresh.reload.revoked?, "a denied refresh must not rotate/revoke the token"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def approved_device_code
|
||||
dc = OidcDeviceCode.create!(application: @application, scope: "openid")
|
||||
dc.approve!(user: @user, acr: "1", auth_time: Time.current.to_i)
|
||||
dc
|
||||
end
|
||||
|
||||
def issue_refresh_token
|
||||
access = OidcAccessToken.create!(application: @application, user: @user, scope: "openid")
|
||||
OidcRefreshToken.create!(application: @application, user: @user, oidc_access_token: access,
|
||||
scope: "openid", auth_time: Time.current.to_i, acr: "1")
|
||||
end
|
||||
|
||||
def revoke_group!
|
||||
UserGroup.where(user: @user, group: @group).delete_all
|
||||
@user.reload
|
||||
end
|
||||
|
||||
def poll(dc)
|
||||
post "/oauth/token", params: {grant_type: DEVICE_GRANT, device_code: dc.plaintext_device_code,
|
||||
client_id: @application.client_id, client_secret: @secret}
|
||||
end
|
||||
|
||||
def refresh_with(refresh)
|
||||
post "/oauth/token", params: {grant_type: "refresh_token", refresh_token: refresh.token,
|
||||
client_id: @application.client_id, client_secret: @secret}
|
||||
end
|
||||
|
||||
def assert_access_denied
|
||||
assert_response :bad_request
|
||||
assert_equal "access_denied", JSON.parse(@response.body)["error"]
|
||||
end
|
||||
end
|
||||
@@ -97,6 +97,27 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
|
||||
assert_match(/error_description=.*code_challenge_method/, @response.location)
|
||||
end
|
||||
|
||||
test "authorize error redirect preserves an existing query string in redirect_uri" do
|
||||
redirect_with_query = "http://localhost:4000/callback?tenant=acme"
|
||||
app = Application.create!(
|
||||
name: "Query RU App", slug: "query-ru-app", app_type: "oidc",
|
||||
redirect_uris: [redirect_with_query].to_json, active: true
|
||||
)
|
||||
grant_everyone_access(app)
|
||||
|
||||
get "/oauth/authorize", params: {
|
||||
response_type: "token", # unsupported → triggers an error redirect
|
||||
client_id: app.client_id,
|
||||
redirect_uri: redirect_with_query,
|
||||
scope: "openid"
|
||||
}
|
||||
|
||||
assert_response :redirect
|
||||
# The error is appended with "&" onto the existing query, not a second "?".
|
||||
assert_equal 1, @response.location.count("?"), "must not introduce a second question mark"
|
||||
assert_match(%r{\Ahttp://localhost:4000/callback\?tenant=acme&error=unsupported_response_type}, @response.location)
|
||||
end
|
||||
|
||||
test "authorization endpoint rejects invalid code_challenge format" do
|
||||
# Contains + character which is not base64url
|
||||
auth_params = {
|
||||
@@ -322,6 +343,7 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
|
||||
require_pkce: false
|
||||
)
|
||||
legacy_app.generate_new_client_secret!
|
||||
grant_everyone_access(legacy_app)
|
||||
|
||||
# Create consent for token endpoint
|
||||
OidcUserConsent.create!(
|
||||
@@ -379,6 +401,7 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
|
||||
active: true,
|
||||
is_public_client: true
|
||||
)
|
||||
grant_everyone_access(public_app)
|
||||
|
||||
assert public_app.public_client?
|
||||
assert public_app.requires_pkce?
|
||||
@@ -442,6 +465,7 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
|
||||
active: true,
|
||||
is_public_client: true
|
||||
)
|
||||
grant_everyone_access(public_app)
|
||||
|
||||
assert public_app.public_client?
|
||||
assert public_app.requires_pkce?
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
require "test_helper"
|
||||
|
||||
class OidcRegistrationControllerTest < ActionDispatch::IntegrationTest
|
||||
JSON_HEADERS = {"Content-Type" => "application/json"}.freeze
|
||||
|
||||
def teardown
|
||||
Setting.where(key: Application::DCR_SETTING_KEY).delete_all
|
||||
Application.where("slug LIKE ?", "%-%").where(metadata: nil).delete_all
|
||||
Application.where("metadata LIKE ?", "%dynamically_registered%").destroy_all
|
||||
end
|
||||
|
||||
def enable_dcr
|
||||
Setting.set(Application::DCR_SETTING_KEY, true)
|
||||
end
|
||||
|
||||
def register(body)
|
||||
post "/oauth/register", params: body.to_json, headers: JSON_HEADERS
|
||||
end
|
||||
|
||||
test "registration is disabled by default" do
|
||||
register(redirect_uris: ["https://client.example.com/cb"], token_endpoint_auth_method: "none")
|
||||
assert_response :forbidden
|
||||
assert_equal "access_denied", JSON.parse(@response.body)["error"]
|
||||
end
|
||||
|
||||
test "registers a public client and returns no secret" do
|
||||
enable_dcr
|
||||
register(
|
||||
redirect_uris: ["https://client.example.com/cb"],
|
||||
token_endpoint_auth_method: "none",
|
||||
grant_types: ["authorization_code", "refresh_token"],
|
||||
client_name: "My MCP Connector"
|
||||
)
|
||||
|
||||
assert_response :created
|
||||
body = JSON.parse(@response.body)
|
||||
assert body["client_id"].present?
|
||||
assert_not body.key?("client_secret")
|
||||
assert_equal ["https://client.example.com/cb"], body["redirect_uris"]
|
||||
assert_equal "none", body["token_endpoint_auth_method"]
|
||||
|
||||
app = Application.find_by(client_id: body["client_id"])
|
||||
assert app.public_client?
|
||||
assert app.require_pkce?
|
||||
assert_empty app.allowed_groups, "a freshly registered client must be default-deny"
|
||||
end
|
||||
|
||||
test "registers a confidential client and returns a secret once" do
|
||||
enable_dcr
|
||||
register(
|
||||
redirect_uris: ["https://client.example.com/cb"],
|
||||
token_endpoint_auth_method: "client_secret_basic"
|
||||
)
|
||||
|
||||
assert_response :created
|
||||
body = JSON.parse(@response.body)
|
||||
assert body["client_secret"].present?
|
||||
assert_equal 0, body["client_secret_expires_at"]
|
||||
|
||||
app = Application.find_by(client_id: body["client_id"])
|
||||
assert app.confidential_client?
|
||||
assert app.authenticate_client_secret(body["client_secret"])
|
||||
end
|
||||
|
||||
test "requires at least one redirect_uri" do
|
||||
enable_dcr
|
||||
register(token_endpoint_auth_method: "none")
|
||||
assert_response :bad_request
|
||||
assert_equal "invalid_redirect_uri", JSON.parse(@response.body)["error"]
|
||||
end
|
||||
|
||||
test "rejects non-loopback http redirect_uris" do
|
||||
enable_dcr
|
||||
register(redirect_uris: ["http://evil.example.com/cb"], token_endpoint_auth_method: "none")
|
||||
assert_response :bad_request
|
||||
assert_equal "invalid_redirect_uri", JSON.parse(@response.body)["error"]
|
||||
end
|
||||
|
||||
test "allows http redirect_uris for loopback" do
|
||||
enable_dcr
|
||||
register(redirect_uris: ["http://localhost:8123/cb"], token_endpoint_auth_method: "none")
|
||||
assert_response :created
|
||||
end
|
||||
|
||||
test "allows http redirect_uris for IPv6 loopback (RFC 8252)" do
|
||||
enable_dcr
|
||||
register(redirect_uris: ["http://[::1]:49152/callback"], token_endpoint_auth_method: "none")
|
||||
assert_response :created
|
||||
end
|
||||
|
||||
test "allows http redirect_uris for IPv4 loopback" do
|
||||
enable_dcr
|
||||
register(redirect_uris: ["http://127.0.0.1:49152/callback"], token_endpoint_auth_method: "none")
|
||||
assert_response :created
|
||||
end
|
||||
|
||||
test "rejects unsupported grant types" do
|
||||
enable_dcr
|
||||
register(redirect_uris: ["https://client.example.com/cb"], grant_types: ["client_credentials"])
|
||||
assert_response :bad_request
|
||||
assert_equal "invalid_client_metadata", JSON.parse(@response.body)["error"]
|
||||
end
|
||||
|
||||
test "registers a client requesting the device_code grant advertised in discovery" do
|
||||
enable_dcr
|
||||
register(
|
||||
redirect_uris: ["https://client.example.com/cb"],
|
||||
token_endpoint_auth_method: "none",
|
||||
grant_types: ["urn:ietf:params:oauth:grant-type:device_code", "refresh_token"]
|
||||
)
|
||||
assert_response :created
|
||||
assert_includes JSON.parse(@response.body)["grant_types"], "urn:ietf:params:oauth:grant-type:device_code"
|
||||
end
|
||||
|
||||
test "registration accepts exactly the grant types discovery advertises" do
|
||||
get "/.well-known/openid-configuration"
|
||||
advertised = JSON.parse(@response.body)["grant_types_supported"]
|
||||
# Single source of truth: what we advertise is what registration accepts.
|
||||
assert_equal OidcController::SUPPORTED_GRANT_TYPES, advertised
|
||||
assert_includes advertised, "urn:ietf:params:oauth:grant-type:device_code"
|
||||
end
|
||||
|
||||
test "rejects a non-JSON body" do
|
||||
enable_dcr
|
||||
post "/oauth/register", params: "not json", headers: JSON_HEADERS
|
||||
assert_response :bad_request
|
||||
assert_equal "invalid_client_metadata", JSON.parse(@response.body)["error"]
|
||||
end
|
||||
|
||||
# --- Discovery advertisement ----------------------------------------------
|
||||
|
||||
test "discovery advertises registration_endpoint only when enabled" do
|
||||
get "/.well-known/openid-configuration"
|
||||
assert_not JSON.parse(@response.body).key?("registration_endpoint")
|
||||
|
||||
enable_dcr
|
||||
get "/.well-known/openid-configuration"
|
||||
assert JSON.parse(@response.body)["registration_endpoint"].end_with?("/oauth/register")
|
||||
end
|
||||
|
||||
test "RFC 8414 metadata alias mirrors OIDC discovery" do
|
||||
get "/.well-known/oauth-authorization-server"
|
||||
assert_response :success
|
||||
config = JSON.parse(@response.body)
|
||||
assert config["token_endpoint"].end_with?("/oauth/token")
|
||||
assert config["authorization_endpoint"].end_with?("/oauth/authorize")
|
||||
end
|
||||
|
||||
# --- Admin runtime toggle --------------------------------------------------
|
||||
|
||||
test "admin can toggle the registration window at runtime" do
|
||||
sign_in_as(users(:alice)) # alice is in the admin group
|
||||
|
||||
patch "/admin/dynamic_client_registration", params: {enabled: "true"}
|
||||
assert_redirected_to admin_applications_path
|
||||
assert Application.dynamic_registration_enabled?
|
||||
|
||||
patch "/admin/dynamic_client_registration", params: {enabled: "false"}
|
||||
assert_not Application.dynamic_registration_enabled?
|
||||
end
|
||||
|
||||
test "non-admins cannot toggle registration" do
|
||||
sign_in_as(users(:one)) # not an admin
|
||||
patch "/admin/dynamic_client_registration", params: {enabled: "true"}
|
||||
assert_redirected_to root_path
|
||||
assert_not Application.dynamic_registration_enabled?
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,133 @@
|
||||
require "test_helper"
|
||||
|
||||
# RFC 8707 Resource Indicators: a client names the target resource server via the
|
||||
# `resource` parameter; clinch binds it to the token as `aud` and reports it at
|
||||
# introspection. Validation is syntax-only (pass-through) — the resource server
|
||||
# enforces the audience.
|
||||
class OidcResourceIndicatorsTest < ActionDispatch::IntegrationTest
|
||||
DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code".freeze
|
||||
RESOURCE = "https://c2a2.example.com".freeze
|
||||
# RFC 7636 Appendix B example PKCE pair.
|
||||
PKCE_VERIFIER = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk".freeze
|
||||
PKCE_CHALLENGE = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM".freeze
|
||||
|
||||
def setup
|
||||
@group = Group.create!(name: "resource-testers", description: "test")
|
||||
@user = User.create!(email_address: "resource_test@example.com", password: "password123")
|
||||
@user.groups << @group
|
||||
|
||||
@cli = Application.create!(name: "Resource CLI", slug: "resource-cli", app_type: "oidc", is_public_client: true, active: true)
|
||||
@cli.allowed_groups << @group
|
||||
|
||||
@web = Application.create!(name: "Resource Web", slug: "resource-web", app_type: "oidc",
|
||||
is_public_client: true, active: true, skip_consent: true, redirect_uris: ["https://app.example.com/cb"].to_json)
|
||||
@web.allowed_groups << @group
|
||||
|
||||
@resource_secret = "resource-server-secret-value-abcdefghij"
|
||||
# The resource server is registered to serve RESOURCE, so it is authorized to
|
||||
# introspect tokens bound to it (see OidcController#caller_may_introspect?).
|
||||
@resource = Application.create!(name: "Resource RS", slug: "resource-rs2", app_type: "oidc",
|
||||
client_secret: @resource_secret, active: true, resource_identifiers: [RESOURCE].to_json)
|
||||
end
|
||||
|
||||
def teardown
|
||||
Current.session = nil
|
||||
[@cli, @web, @resource].each do |app|
|
||||
OidcRefreshToken.where(application: app).delete_all
|
||||
OidcAccessToken.where(application: app).delete_all
|
||||
OidcDeviceCode.where(application: app).delete_all
|
||||
OidcAuthorizationCode.where(application: app).delete_all
|
||||
OidcUserConsent.where(application: app).delete_all
|
||||
end
|
||||
end
|
||||
|
||||
# --- Authorization code flow ----------------------------------------------
|
||||
|
||||
test "authorize binds the resource so introspection reports it as aud" do
|
||||
sign_in_as(@user)
|
||||
get "/oauth/authorize", params: {
|
||||
response_type: "code", client_id: @web.client_id,
|
||||
redirect_uri: "https://app.example.com/cb", scope: "openid",
|
||||
code_challenge: PKCE_CHALLENGE, code_challenge_method: "S256",
|
||||
resource: RESOURCE
|
||||
}
|
||||
assert_response :redirect
|
||||
code = Rack::Utils.parse_query(URI(@response.location).query)["code"]
|
||||
assert code.present?
|
||||
|
||||
post "/oauth/token", params: {
|
||||
grant_type: "authorization_code", code: code,
|
||||
redirect_uri: "https://app.example.com/cb",
|
||||
client_id: @web.client_id, code_verifier: PKCE_VERIFIER
|
||||
}
|
||||
assert_response :success
|
||||
tokens = JSON.parse(@response.body)
|
||||
|
||||
assert_equal RESOURCE, introspect(tokens["access_token"])["aud"]
|
||||
|
||||
# The bound audience survives refresh rotation.
|
||||
post "/oauth/token", params: {grant_type: "refresh_token", refresh_token: tokens["refresh_token"], client_id: @web.client_id}
|
||||
assert_response :success
|
||||
rotated = JSON.parse(@response.body)
|
||||
assert_equal RESOURCE, introspect(rotated["access_token"])["aud"]
|
||||
end
|
||||
|
||||
test "authorize rejects an invalid resource with invalid_target" do
|
||||
sign_in_as(@user)
|
||||
get "/oauth/authorize", params: {
|
||||
response_type: "code", client_id: @web.client_id,
|
||||
redirect_uri: "https://app.example.com/cb", scope: "openid",
|
||||
resource: "https://c2a2.example.com/path#frag"
|
||||
}
|
||||
assert_response :redirect
|
||||
assert_includes @response.location, "error=invalid_target"
|
||||
end
|
||||
|
||||
# --- Device flow -----------------------------------------------------------
|
||||
|
||||
test "device flow binds the resource to the issued token" do
|
||||
post "/oauth/device_authorization", params: {
|
||||
client_id: @cli.client_id, scope: "openid", resource: RESOURCE,
|
||||
code_challenge: PKCE_CHALLENGE, code_challenge_method: "S256"
|
||||
}
|
||||
assert_response :success
|
||||
auth = JSON.parse(@response.body)
|
||||
|
||||
dc = OidcDeviceCode.find_by_user_code(auth["user_code"])
|
||||
assert_equal RESOURCE, dc.resource
|
||||
|
||||
OidcUserConsent.create!(user: @user, application: @cli, scopes_granted: "openid", granted_at: Time.current)
|
||||
dc.approve!(user: @user, acr: "1", auth_time: Time.current.to_i)
|
||||
|
||||
post "/oauth/token", params: {grant_type: DEVICE_GRANT, device_code: auth["device_code"], client_id: @cli.client_id, code_verifier: PKCE_VERIFIER}
|
||||
assert_response :success
|
||||
access = JSON.parse(@response.body)["access_token"]
|
||||
|
||||
assert_equal RESOURCE, introspect(access)["aud"]
|
||||
end
|
||||
|
||||
test "device_authorization rejects an invalid resource" do
|
||||
post "/oauth/device_authorization", params: {
|
||||
client_id: @cli.client_id, resource: "not-an-absolute-uri",
|
||||
code_challenge: PKCE_CHALLENGE, code_challenge_method: "S256"
|
||||
}
|
||||
assert_response :bad_request
|
||||
assert_equal "invalid_target", JSON.parse(@response.body)["error"]
|
||||
end
|
||||
|
||||
# --- Fallback --------------------------------------------------------------
|
||||
|
||||
test "introspection aud falls back to the client when no resource was bound" do
|
||||
# A confidential client introspecting its own unbound token (no RFC 8707
|
||||
# resource) sees aud fall back to the client_id.
|
||||
token = OidcAccessToken.create!(application: @resource, user: @user, scope: "openid")
|
||||
assert_equal @resource.client_id, introspect(token.plaintext_token)["aud"]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def introspect(token)
|
||||
post "/oauth/introspect", params: {token: token, client_id: @resource.client_id, client_secret: @resource_secret}
|
||||
JSON.parse(@response.body)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,24 @@
|
||||
require "test_helper"
|
||||
|
||||
class OidcHelperTest < ActionView::TestCase
|
||||
test "scope_description returns a human-readable label for each supported scope" do
|
||||
user = User.new(email_address: "person@example.com")
|
||||
assert_equal "Verify your identity", scope_description("openid", user: user)
|
||||
assert_equal "Access your email address (person@example.com)", scope_description("email", user: user)
|
||||
assert_equal "Access your profile information", scope_description("profile", user: user)
|
||||
assert_equal "Access your group memberships", scope_description("groups", user: user)
|
||||
assert_equal "Stay signed in (refresh access)", scope_description("offline_access", user: user)
|
||||
end
|
||||
|
||||
test "scope_description covers every SUPPORTED_SCOPE (so the consent screens can't silently drop one)" do
|
||||
user = User.new(email_address: "person@example.com")
|
||||
OidcScopes::SUPPORTED.each do |scope|
|
||||
assert_not_equal scope, scope_description(scope, user: user),
|
||||
"#{scope} has no description and would render as its raw name"
|
||||
end
|
||||
end
|
||||
|
||||
test "scope_description falls back to the raw scope name for unknown scopes" do
|
||||
assert_equal "somethingelse", scope_description("somethingelse", user: User.new)
|
||||
end
|
||||
end
|
||||
@@ -48,4 +48,35 @@ class OidcTokenCleanupJobTest < ActiveJob::TestCase
|
||||
user&.destroy
|
||||
application&.destroy
|
||||
end
|
||||
|
||||
# Device codes (RFC 8628) are created by anonymous callers and would grow the
|
||||
# table without bound; the cleanup job must purge expired ones (pending, denied,
|
||||
# or abandoned) while leaving live codes alone.
|
||||
test "deletes expired device codes and keeps live ones" do
|
||||
application = Application.create!(
|
||||
name: "Device Cleanup App",
|
||||
slug: "device-cleanup-app",
|
||||
app_type: "oidc",
|
||||
is_public_client: true,
|
||||
active: true
|
||||
)
|
||||
|
||||
expired_pending = nil
|
||||
expired_denied = nil
|
||||
travel_to(2.hours.ago) do
|
||||
expired_pending = OidcDeviceCode.create!(application: application, scope: "openid")
|
||||
expired_denied = OidcDeviceCode.create!(application: application, scope: "openid")
|
||||
expired_denied.deny!
|
||||
end
|
||||
live = OidcDeviceCode.create!(application: application, scope: "openid")
|
||||
|
||||
OidcTokenCleanupJob.new.perform
|
||||
|
||||
assert_not OidcDeviceCode.exists?(expired_pending.id), "expired pending code should be deleted"
|
||||
assert_not OidcDeviceCode.exists?(expired_denied.id), "expired denied code should be deleted"
|
||||
assert OidcDeviceCode.exists?(live.id), "live code should be kept"
|
||||
ensure
|
||||
OidcDeviceCode.where(application: application).delete_all if application
|
||||
application&.destroy
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
require "test_helper"
|
||||
|
||||
class OidcDeviceCodeTest < ActiveSupport::TestCase
|
||||
def setup
|
||||
@application = Application.create!(
|
||||
name: "Device Code Model Test",
|
||||
slug: "device-code-model-test",
|
||||
app_type: "oidc",
|
||||
is_public_client: true,
|
||||
active: true
|
||||
)
|
||||
@user = User.create!(email_address: "device_model@example.com", password: "password123")
|
||||
end
|
||||
|
||||
test "generates an opaque device_code stored as HMAC and looked up by plaintext" do
|
||||
dc = OidcDeviceCode.create!(application: @application)
|
||||
|
||||
assert dc.plaintext_device_code.present?
|
||||
assert dc.device_code_hmac.present?
|
||||
assert_not_equal dc.plaintext_device_code, dc.device_code_hmac
|
||||
assert_equal dc, OidcDeviceCode.find_by_plaintext_device_code(dc.plaintext_device_code)
|
||||
assert_nil OidcDeviceCode.find_by_plaintext_device_code("wrong")
|
||||
end
|
||||
|
||||
test "generates a short user_code from the unambiguous alphabet" do
|
||||
dc = OidcDeviceCode.create!(application: @application)
|
||||
|
||||
assert_equal 8, dc.user_code.length
|
||||
# No visually ambiguous characters (0/O, 1/I) and only the allowed alphabet.
|
||||
assert_match(/\A[A-HJ-NP-Z2-9]{8}\z/, dc.user_code)
|
||||
end
|
||||
|
||||
test "find_by_user_code normalizes case, hyphens, and whitespace" do
|
||||
dc = OidcDeviceCode.create!(application: @application)
|
||||
formatted = "#{dc.user_code[0, 4]}-#{dc.user_code[4, 4]}".downcase
|
||||
|
||||
assert_equal dc, OidcDeviceCode.find_by_user_code(formatted)
|
||||
assert_equal dc, OidcDeviceCode.find_by_user_code(" #{dc.user_code} ")
|
||||
assert_nil OidcDeviceCode.find_by_user_code("nope")
|
||||
end
|
||||
|
||||
test "regenerates the user_code when generation collides with an existing code" do
|
||||
existing = OidcDeviceCode.create!(application: @application)
|
||||
taken = existing.user_code
|
||||
fresh = "ABCDEFGH" # in-alphabet, effectively guaranteed != the random `taken`
|
||||
|
||||
# First candidate collides with the existing code, the second is unique — the
|
||||
# generator must retry rather than surface a uniqueness error.
|
||||
candidates = [taken, fresh].each
|
||||
dc = OidcDeviceCode.new(application: @application)
|
||||
dc.define_singleton_method(:random_user_code) { candidates.next }
|
||||
dc.save!
|
||||
|
||||
assert_equal fresh, dc.user_code
|
||||
assert_not_equal taken, dc.user_code
|
||||
end
|
||||
|
||||
test "starts pending and approve! attaches the user and auth context" do
|
||||
dc = OidcDeviceCode.create!(application: @application)
|
||||
assert dc.pending?
|
||||
|
||||
dc.approve!(user: @user, acr: "1", auth_time: 1_700_000_000)
|
||||
|
||||
assert dc.approved?
|
||||
assert_equal @user, dc.user
|
||||
assert_equal "1", dc.acr
|
||||
assert_equal 1_700_000_000, dc.auth_time
|
||||
end
|
||||
|
||||
test "deny! marks the code denied" do
|
||||
dc = OidcDeviceCode.create!(application: @application)
|
||||
dc.deny!
|
||||
assert dc.denied?
|
||||
end
|
||||
|
||||
test "expired? reflects expires_at" do
|
||||
assert OidcDeviceCode.create!(application: @application, expires_at: 1.minute.ago).expired?
|
||||
assert_not OidcDeviceCode.create!(application: @application).expired?
|
||||
end
|
||||
|
||||
test "uses_pkce? and rejects malformed code_challenge" do
|
||||
assert_not OidcDeviceCode.create!(application: @application).uses_pkce?
|
||||
|
||||
valid_challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
|
||||
dc = OidcDeviceCode.create!(application: @application, code_challenge: valid_challenge, code_challenge_method: "S256")
|
||||
assert dc.uses_pkce?
|
||||
|
||||
bad = OidcDeviceCode.new(application: @application, code_challenge: "too-short")
|
||||
assert_not bad.valid?
|
||||
assert_includes bad.errors[:code_challenge], "must be 43-128 characters of base64url encoding"
|
||||
end
|
||||
end
|
||||
@@ -206,6 +206,45 @@ class OidcUserConsentTest < ActiveSupport::TestCase
|
||||
assert consent.covers_scopes?(["openid", "email", "profile"])
|
||||
end
|
||||
|
||||
# --- .record! upsert semantics --------------------------------------------
|
||||
|
||||
test "record! creates a new consent with the given scopes and claims" do
|
||||
user = users(:alice)
|
||||
app = applications(:another_app)
|
||||
OidcUserConsent.where(user: user, application: app).delete_all
|
||||
|
||||
consent = OidcUserConsent.record!(user: user, application: app,
|
||||
scopes: %w[openid email], claims_requests: {"userinfo" => {"email" => nil}})
|
||||
|
||||
assert_equal %w[openid email], consent.scopes
|
||||
assert_equal({"userinfo" => {"email" => nil}}, consent.parsed_claims_requests)
|
||||
end
|
||||
|
||||
test "record! without merge overwrites scopes (browser flow)" do
|
||||
user = users(:alice)
|
||||
app = applications(:another_app)
|
||||
OidcUserConsent.create!(user: user, application: app,
|
||||
scopes_granted: "openid email profile", granted_at: 1.day.ago)
|
||||
|
||||
consent = OidcUserConsent.record!(user: user, application: app, scopes: %w[openid],
|
||||
claims_requests: {})
|
||||
|
||||
assert_equal %w[openid], consent.scopes, "browser flow records exactly what was consented"
|
||||
end
|
||||
|
||||
test "record! with merge unions scopes and preserves stored claims (device flow)" do
|
||||
user = users(:alice)
|
||||
app = applications(:another_app)
|
||||
OidcUserConsent.create!(user: user, application: app,
|
||||
scopes_granted: "openid email profile", claims_requests: {"userinfo" => {"email" => nil}},
|
||||
granted_at: 1.day.ago)
|
||||
|
||||
consent = OidcUserConsent.record!(user: user, application: app, scopes: %w[openid], merge: true)
|
||||
|
||||
assert_equal %w[openid email profile].sort, consent.scopes.sort, "merge must not shrink a prior grant"
|
||||
assert_equal({"userinfo" => {"email" => nil}}, consent.parsed_claims_requests, "merge must not wipe claims")
|
||||
end
|
||||
|
||||
test "should validate scope coverage logic with real OIDC scenarios" do
|
||||
# Typical OIDC consent scenario
|
||||
@consent.scopes_granted = "openid profile email"
|
||||
|
||||
Reference in New Issue
Block a user