19 Commits
Author SHA1 Message Date
Dan MilneandClaude Opus 4.8 126e26685d Bump version to 0.17.1
Build and publish image / prepare (push) Has been cancelled
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / scan_container (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
Build and publish image / build (amd64, linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and publish image / build (arm64, linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and publish image / merge (push) Has been cancelled
Patch release: fix 500 when deleting an application with device codes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LARKs4cVsGrvKCkKZhPcy6
2026-07-19 20:39:59 +10:00
Dan MilneandClaude Opus 4.8 45a1203647 Fix 500 when deleting an application with device codes
Deleting an application left its oidc_device_codes orphaned, tripping the
FK constraint and raising SQLite3::ConstraintException. Unlike the other
child tables, oidc_device_codes had neither a dependent: :destroy
association nor an on_delete: :cascade FK, so any app that had started a
device-authorization flow could not be deleted (regardless of groups/users,
since a pending device code has a null user_id).

Add the missing has_many :oidc_device_codes, dependent: :destroy and a
migration to cascade at the DB level, mirroring application_user_claims.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LARKs4cVsGrvKCkKZhPcy6
2026-07-19 20:39:33 +10:00
Dan MilneandClaude Opus 4.8 51ddb42bc7 Bump Ruby to 4.0.6 and update gems
Build and publish image / prepare (push) Has been cancelled
Build and publish image / build (amd64, linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and publish image / build (arm64, linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and publish image / merge (push) Has been cancelled
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / scan_container (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
Updates the bundle (bundle update), most importantly crass 1.0.6 -> 1.0.7,
which clears the four ruby-advisory-db advisories (GHSA-6jxj-px6v-747w,
GHSA-6wmf-3r64-vcwv, GHSA-8vfg-2r28-hvhj, GHSA-wwpr-jff3-395c) that were
failing CI's bundler-audit (scan_ruby) step. Also bumps .ruby-version and the
Dockerfile build arg from 4.0.5 to 4.0.6.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DUaWqESTbUEdSi9erQ3jgt
2026-07-19 16:00:41 +10:00
Dan MilneandClaude Opus 4.8 808fb06500 Bump version to 0.17.0
Minor release for the OAuth device flow (RFC 8628), token introspection
(RFC 7662), dynamic client registration (RFC 7591), and resource indicators
(RFC 8707), plus the security hardening from the branch review.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DUaWqESTbUEdSi9erQ3jgt
2026-07-19 15:55:17 +10:00
Dan MilneandClaude Opus 4.8 8c370d0829 Extract shared token-minting helper for auth-code and device grants
handle_authorization_code_grant and handle_device_code_grant each inlined
the same ~50-line access-token / refresh-token / id-token minting and RFC
6749 §5.1 response block. Factor it into mint_and_render_tokens, parameterized
by the redeemed grant (auth code or device code), its belongs_to association,
and the claims request. Also moves the auth-code consent lookup ahead of minting
so a missing consent record can no longer leave orphaned tokens committed in the
transaction (a return inside the locked transaction does not roll back).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DUaWqESTbUEdSi9erQ3jgt
2026-07-19 15:55:02 +10:00
Dan MilneandClaude Opus 4.8 8b146db54e Device code: retry user_code generation on collision
Regenerate the user_code when a freshly generated one collides with an existing
code (up to USER_CODE_MAX_ATTEMPTS), so a client never gets a uniqueness error
just because two codes happened to match; the DB unique index stays the final
guard against a concurrent-insert race.

Replace the model test that asserted Rails' uniqueness validator message
("has already been taken") — per CLAUDE.md we don't test framework behavior —
with one that exercises the app-specific property: generation retries past a
collision and yields a unique code.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F7cwhwDJp3MJJDoNPVE6zq
2026-07-19 14:43:03 +10:00
Dan MilneandClaude Opus 4.8 6c4be7199c Extract OidcScopes::SUPPORTED as the shared scope list
The supported-scope list lived on OidcController, so the device authorization
controller (and a test) had to reach across into OidcController::SUPPORTED_SCOPES.
Move it to a shared OidcScopes::SUPPORTED module — one source of truth for the
OIDC controller, the device flow, and consent handling — and update every
reference. No behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Q4ATZHoMCWqvSpE2yYoie
2026-07-19 14:42:39 +10:00
Dan MilneandClaude Opus 4.8 f65f8da2e7 OIDC: IPv6 loopback DCR, dedup error redirects + scope labels
Security-review follow-ups on the OIDC flows.

IPv6 loopback redirect_uri in dynamic client registration (RFC 8252):
- valid_redirect_uri? compared URI#host, which returns the bracketed "[::1]"
  for IPv6 literals, so http://[::1]:PORT/... was always rejected. Compare
  #hostname (unbracketed) instead, unblocking IPv6-only native clients.

Extract redirect_authorize_error (removes 12 copies of the boilerplate):
- The authorize/consent flows built the error redirect by hand ~a dozen times
  (error_uri = "...?error=..."; += "&error_description=#{CGI.escape ...}"; +=
  state; redirect_to). One helper now composes the query, and — unlike every
  copy — appends "&error=..." when the redirect_uri already has a query string
  instead of a malformed second "?".

Extract token-endpoint helpers (removes ~80 duplicated lines):
- authenticate_token_client: the identical client-auth preamble shared by the
  authorization-code, refresh, and device-code grants.
- render_token_triple: the access + refresh + id_token mint and RFC 6749 §5.1
  response shared by the authorization-code and device-code grants.

Single source of truth for scope descriptions:
- New OidcHelper#scope_description + shared shared/_scope_list partial, used by
  both the browser consent screen and the device authorization screen; drops the
  scope_labels hash and the hard-coded per-scope blocks that would have drifted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Q4ATZHoMCWqvSpE2yYoie
2026-07-19 14:14:44 +10:00
Dan MilneandClaude Opus 4.8 79a7524fda Device flow: single state resolver + shared terminal-state partial
The terminal states (expired / already-used / not-found) were duplicated as
markup in show and result, and #verify re-implemented the nil→expired→!pending
cascade that #show already had — a copy edit meant four touch points.

- device_code_state(dc) resolves a code to :not_found / :expired /
  :already_handled / :ok. Both #show and #verify branch on it, so the cascade
  lives in one place.
- _terminal_state partial renders the terminal heading/message/link once;
  result.html.erb now renders it instead of inlining the markup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F7cwhwDJp3MJJDoNPVE6zq
2026-07-19 14:10:04 +10:00
Dan MilneandClaude Opus 4.8 e3f0bd4cab Extract OidcUserConsent.record! upsert for shared consent
The consent record is unique on user+application and shared between the browser
authorization flow and device-flow approval. Centralize the upsert in
OidcUserConsent.record!(merge:) so a narrower device request unions its scopes
into any existing grant (merge: true) rather than overwriting it and wiping
stored claims, while the browser consent screen records exactly the approved
scopes (merge: false). scopes is now nil-safe for not-yet-saved records.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F7cwhwDJp3MJJDoNPVE6zq
2026-07-19 13:59:44 +10:00
Dan MilneandClaude Opus 4.8 7eddea8356 DCR: accept every grant type discovery advertises
Discovery advertised the device_code grant in grant_types_supported, but dynamic
client registration only allowed authorization_code/refresh_token, so a client
listing the device grant in its RFC 7591 metadata was rejected with
invalid_client_metadata.

Introduce a single source of truth — OidcController::SUPPORTED_GRANT_TYPES —
used by discovery (grant_types_supported) and by registration validation, so
advertisement and registration can never drift. The token dispatcher already
handles exactly these grants for all clients (they are user-context grants gated
by consent + user_allowed?, so there is no per-client grant restriction), making
advertisement, registration, and enforcement consistent.

Tests: a client registering with the device_code grant now succeeds, plus an
assertion that registration's accepted set equals discovery's advertised set.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F7cwhwDJp3MJJDoNPVE6zq
2026-07-19 13:59:35 +10:00
Dan MilneandClaude Opus 4.8 71ee301dd2 Device flow: replay-revocation, user_code CSPRNG, /device rate limit
More device-flow security-review fixes.

Replay-revocation on device_code redemption (was: destroy on use):
- Redemption now marks the code redeemed_at and links the issued access/refresh
  tokens back to it (new oidc_device_code_id FKs, on_delete: :nullify) instead of
  destroying the row. A replayed redeemed code is detected as reuse — revoking
  every descended token and returning a distinguishable "already been used"
  invalid_grant rather than the generic "Invalid device_code" — mirroring the
  authorization-code reuse semantics (RFC 6749 §4.1.2). The device_code FK is
  carried forward across refresh rotation so revocation reaches the whole chain.
  Redeemed codes are reaped by the existing expiry cleanup sweep.

user_code CSPRNG (RFC 8628 §6.1):
- The 8-char user_code is a credential (type it + Approve mints tokens), so draw
  it from SecureRandom instead of Ruby's global Mersenne Twister (Array#sample).

Rate-limit the /device verification endpoint (RFC 8628 §5.1):
- Add the app's standard 10/min limit on show + verify so a signed-in user can't
  brute-force the short code space to deny or hijack a pending authorization.

Also carried in this commit (other agent's working-tree change):
- Give each OidcController rate_limit a distinct name: so frequent device polling
  on the token endpoint no longer shares one counter with (and 429s) unrelated
  token/refresh/revoke/introspect calls.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Q4ATZHoMCWqvSpE2yYoie
2026-07-19 13:38:40 +10:00
Dan MilneandClaude Opus 4.8 64410a0c50 Cap device-code poll interval and sweep expired device codes
Two more fixes from the device-flow security review:

slow_down interval grew without bound (OidcController):
- Each too-fast poll bumped the persisted interval by 5s with no ceiling, so a
  client polling slightly fast — or an attacker spamming a known device_code —
  could balloon it (5→10→15→…) past the 10-minute expiry and starve a legitimate
  client of its token. Clamp the bump to OidcDeviceCode::MAX_INTERVAL (30s); a
  client polling at the advertised interval is never throttled.

Expired device codes accumulated forever (OidcTokenCleanupJob):
- Anonymous callers can create device codes via /oauth/device_authorization, and
  expired/denied/abandoned rows were never reaped. Sweep rows past expiry (with a
  1-hour grace to stay clear of in-flight redemption); redeemed codes are already
  destroyed at token issuance.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Q4ATZHoMCWqvSpE2yYoie
2026-07-19 13:28:55 +10:00
Dan MilneandClaude Opus 4.8 017dfdff0e Harden OIDC token endpoints from security review
Fixes from review of the device flow / introspection / DCR work:

Introspection over-disclosure (RFC 7662):
- Restrict introspection to authorized callers — the client a token was issued
  to, or a resource server registered (resource_identifiers) to serve the token's
  bound RFC 8707 audience. Unauthorized callers get {active:false}, disclosing
  nothing, instead of any confidential client reading any token.
- Scope-gate disclosed claims: username only with the email scope, groups only
  with the groups scope (mirrors userinfo). ADR 0005.

Tokens minted for revoked users:
- Re-check application.user_allowed?(user) at mint time in the device, refresh,
  and authorization-code grants (covers app-active, user-active, group
  membership). A user deactivated or removed from the allowed group between
  approval and the token request is refused with access_denied. The refresh
  check runs before rotation so a denied refresh has no side effects.

Device authorization hardening:
- Require confidential clients to authenticate, and require PKCE (code_challenge)
  up front for clients that require it, so an intercepted device_code plus a
  known public client_id cannot redeem tokens.
- Merge (not overwrite) the shared consent record on device approval.

Tests: new oidc_introspection_test and oidc_mint_authorization_test; existing
PKCE/claims tests updated to grant app access (they created ungrouped apps and
relied on the token endpoint not checking authorization). Full suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F7cwhwDJp3MJJDoNPVE6zq
2026-07-19 13:19:41 +10:00
Dan MilneandClaude Opus 4.8 2defa26a87 Add RFC 8707 Resource Indicators (audience binding)
Clients can name a target resource server via the `resource` parameter at the
authorization and device-authorization endpoints. Clinch binds it to the issued
token as its audience and reports it at introspection as `aud`, so a token minted
for one API cannot be replayed against another that also trusts clinch.

- `resource` is validated syntax-only (absolute URI, no fragment) — pass-through,
  no resource registry; the resource server enforces the audience on introspection
- threaded from authorize / device_authorization onto the auth/device code, then
  the access and refresh tokens, and carried across refresh rotation
- invalid values are rejected with error=invalid_target
- introspection `aud` is the bound resource, falling back to the client_id
- ADR 0004 records the pass-through-vs-registry decision

Completes the clinch-side OAuth surface for MCP connectors (with DCR +
introspection); Protected Resource Metadata (RFC 9728) lives on the resource server.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F7cwhwDJp3MJJDoNPVE6zq
2026-07-19 12:41:10 +10:00
Dan MilneandClaude Opus 4.8 7149b98b7b Add OAuth device flow, token introspection, and dynamic client registration
Adds three OAuth surfaces to the OIDC provider so CLIs, terminal agents, and
MCP connectors can authenticate as real users instead of using static API keys.

Device Authorization Grant (RFC 8628):
- OidcDeviceCode model (HMAC device_code, short plaintext user_code, nullable
  user until approval, slow_down polling state), mirroring OidcAuthorizationCode
- POST /oauth/device_authorization issues the code pair + verification URIs
- device_code grant on /oauth/token returns authorization_pending / slow_down /
  access_denied / expired_token, then the standard token triple; single-use
- Authenticated /device approval page, gated by Application#user_allowed?

Token Introspection (RFC 7662):
- POST /oauth/introspect: confidential-caller-authenticated; returns active,
  scope, and the user's groups so resource servers can authorize on membership

Dynamic Client Registration (RFC 7591):
- POST /oauth/register creates public/confidential clients (PKCE required)
- Runtime toggle via a new Setting store + admin switch on the Applications page;
  off by default, env var CLINCH_DCR_ENABLED as bootstrap fallback
- New clients are default-deny (no allowed_groups) until an admin grants access
- RFC 8414 metadata alias at /.well-known/oauth-authorization-server;
  registration_endpoint advertised only while the window is open

Discovery advertises all three grants/endpoints. Seeds add a clinch-cli public
client and a c2a2-introspection confidential client. ADRs in docs/decisions
record the opaque-vs-JWT, device-flow, and DCR-security decisions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F7cwhwDJp3MJJDoNPVE6zq
2026-07-19 12:30:02 +10:00
Dan MilneandClaude Opus 4.8 c85d25c4b9 Untrack SECURITY_REVIEW_TODO.md and gitignore it
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / scan_container (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
Keep the findings tracker local-only; it should not be published.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 23:09:17 +10:00
Dan MilneandClaude Opus 4.8 1b0d323572 Bump version to 0.16.3
Build and publish image / prepare (push) Has been cancelled
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / scan_container (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
Build and publish image / build (amd64, linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and publish image / build (arm64, linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and publish image / merge (push) Has been cancelled
Ships the access-check GET-form fix (782e197) as a published image.
v0.16.2 was bumped before the version-bump build workflow existed, so it
never built; this bump triggers the build via the registered push trigger.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 18:29:39 +10:00
Dan MilneandClaude Opus 4.8 d1d626c540 Rework build workflow to trigger on version bump + manual dispatch
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / scan_container (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
Port the build pipeline from the splat sibling project. Instead of
triggering on git tags, the image now builds when
config/initializers/version.rb changes on main — a version bump IS the
release — plus a workflow_dispatch button for manual builds.

Reads Clinch::VERSION, tags the image :vX.Y.Z, and moves :latest only
for non-pre-release versions. Also builds multi-arch (amd64 + arm64) on
native runners and stitches a manifest, replacing the amd64-only build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 18:08:04 +10:00
53 changed files with 2895 additions and 325 deletions
+105 -28
View File
@@ -1,22 +1,63 @@
name: Build and publish image 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: on:
push: push:
branches: [ main ] branches: [ main ]
tags: [ 'v*' ] paths:
- config/initializers/version.rb
workflow_dispatch:
# Only one build per ref at a time; cancel superseded main builds. env:
concurrency: IMAGE: ghcr.io/${{ github.repository }}
group: build-${{ github.ref }}
cancel-in-progress: ${{ github.ref == 'refs/heads/main' }}
jobs: jobs:
build: # Read the SemVer constant; decide whether this release moves :latest.
prepare:
runs-on: ubuntu-latest 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: permissions:
contents: read contents: read
packages: write # Required to push to GHCR packages: write
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v5 uses: actions/checkout@v5
@@ -31,26 +72,62 @@ jobs:
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract image metadata (tags, labels) - name: Build and push by digest
id: meta id: build
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
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
context: . context: .
platforms: linux/amd64 platforms: ${{ matrix.platform }}
push: true cache-from: type=gha,scope=${{ matrix.arch }}
tags: ${{ steps.meta.outputs.tags }} cache-to: type=gha,mode=max,scope=${{ matrix.arch }}
labels: ${{ steps.meta.outputs.labels }} outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha
cache-to: type=gha,mode=max - 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
+3
View File
@@ -70,3 +70,6 @@ yarn-debug.log*
# Ignore bootsnap cache # Ignore bootsnap cache
/tmp/cache/bootsnap* /tmp/cache/bootsnap*
# Local-only: do not publish the security findings tracker
SECURITY_REVIEW_TODO.md
+1 -1
View File
@@ -1 +1 @@
4.0.5 4.0.6
+1 -1
View File
@@ -8,7 +8,7 @@
# For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html # For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html
# Make sure RUBY_VERSION matches the Ruby version in .ruby-version # Make sure RUBY_VERSION matches the Ruby version in .ruby-version
ARG RUBY_VERSION=4.0.5 ARG RUBY_VERSION=4.0.6
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
LABEL org.opencontainers.image.source=https://github.com/dkam/clinch LABEL org.opencontainers.image.source=https://github.com/dkam/clinch
+40 -45
View File
@@ -111,16 +111,15 @@ GEM
cose (1.3.1) cose (1.3.1)
cbor (~> 0.5.9) cbor (~> 0.5.9)
openssl-signature_algorithm (~> 1.0) openssl-signature_algorithm (~> 1.0)
crass (1.0.6) crass (1.0.7)
date (3.5.1) date (3.5.1)
debug (1.11.1) debug (1.11.1)
irb (~> 1.10) irb (~> 1.10)
reline (>= 0.3.8) reline (>= 0.3.8)
docile (1.4.1)
dotenv (3.2.0) dotenv (3.2.0)
drb (2.2.3) drb (2.2.3)
ed25519 (1.4.0) ed25519 (1.4.0)
erb (6.0.4) erb (6.0.5)
erubi (1.13.1) erubi (1.13.1)
et-orbi (1.4.0) et-orbi (1.4.0)
tzinfo tzinfo
@@ -131,10 +130,10 @@ GEM
ffi (1.17.4-arm64-darwin) ffi (1.17.4-arm64-darwin)
ffi (1.17.4-x86_64-linux-gnu) ffi (1.17.4-x86_64-linux-gnu)
ffi (1.17.4-x86_64-linux-musl) ffi (1.17.4-x86_64-linux-musl)
fugit (1.12.2) fugit (1.13.0)
et-orbi (~> 1.4) et-orbi (~> 1.4)
raabro (~> 1.4) raabro (~> 1.4)
globalid (1.3.0) globalid (1.4.0)
activesupport (>= 6.1) activesupport (>= 6.1)
i18n (1.15.2) i18n (1.15.2)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
@@ -154,7 +153,7 @@ GEM
jbuilder (2.15.1) jbuilder (2.15.1)
actionview (>= 7.0.0) actionview (>= 7.0.0)
activesupport (>= 7.0.0) activesupport (>= 7.0.0)
json (2.19.9) json (2.21.1)
jwt (3.2.0) jwt (3.2.0)
base64 base64
kamal (2.12.0) kamal (2.12.0)
@@ -168,7 +167,7 @@ GEM
sshkit (>= 1.23.0, < 2.0) sshkit (>= 1.23.0, < 2.0)
thor (~> 1.3) thor (~> 1.3)
zeitwerk (>= 2.6.18, < 3.0) zeitwerk (>= 2.6.18, < 3.0)
language_server-protocol (3.17.0.5) language_server-protocol (3.17.0.6)
launchy (3.1.1) launchy (3.1.1)
addressable (~> 2.8) addressable (~> 2.8)
childprocess (~> 5.0) childprocess (~> 5.0)
@@ -177,10 +176,10 @@ GEM
launchy (>= 2.2, < 4) launchy (>= 2.2, < 4)
lint_roller (1.1.0) lint_roller (1.1.0)
logger (1.7.0) logger (1.7.0)
loofah (2.25.1) loofah (2.25.2)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.12.0) nokogiri (>= 1.12.0)
mail (2.9.0) mail (2.9.1)
logger logger
mini_mime (>= 0.1.1) mini_mime (>= 0.1.1)
net-imap net-imap
@@ -188,7 +187,7 @@ GEM
net-smtp net-smtp
marcel (1.2.1) marcel (1.2.1)
matrix (0.4.3) matrix (0.4.3)
mini_magick (5.3.1) mini_magick (5.3.2)
logger logger
mini_mime (1.1.5) mini_mime (1.1.5)
minitest (5.27.0) minitest (5.27.0)
@@ -206,7 +205,7 @@ GEM
net-ssh (>= 5.0.0, < 8.0.0) net-ssh (>= 5.0.0, < 8.0.0)
net-smtp (0.5.1) net-smtp (0.5.1)
net-protocol net-protocol
net-ssh (7.3.2) net-ssh (7.3.3)
nio4r (2.7.5) nio4r (2.7.5)
nokogiri (1.19.4-aarch64-linux-gnu) nokogiri (1.19.4-aarch64-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
@@ -227,10 +226,10 @@ GEM
openssl (> 2.0) openssl (> 2.0)
ostruct (0.6.3) ostruct (0.6.3)
parallel (2.1.0) parallel (2.1.0)
parser (3.3.11.1) parser (3.3.12.0)
ast (~> 2.4.1) ast (~> 2.4.1)
racc racc
pp (0.6.3) pp (0.6.4)
prettyprint prettyprint
prettyprint (0.2.0) prettyprint (0.2.0)
prism (1.9.0) prism (1.9.0)
@@ -238,9 +237,6 @@ GEM
actionpack (>= 7.0.0) actionpack (>= 7.0.0)
activesupport (>= 7.0.0) activesupport (>= 7.0.0)
rack rack
psych (5.4.0)
date
stringio
public_suffix (7.0.5) public_suffix (7.0.5)
puma (8.0.2) puma (8.0.2)
nio4r (~> 2.0) nio4r (~> 2.0)
@@ -272,8 +268,8 @@ GEM
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
minitest minitest
nokogiri (>= 1.6) nokogiri (>= 1.6)
rails-html-sanitizer (1.7.0) rails-html-sanitizer (1.7.1)
loofah (~> 2.25) 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) 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) railties (8.1.3)
actionpack (= 8.1.3) actionpack (= 8.1.3)
@@ -286,9 +282,14 @@ GEM
zeitwerk (~> 2.6) zeitwerk (~> 2.6)
rainbow (3.1.1) rainbow (3.1.1)
rake (13.4.2) rake (13.4.2)
rdoc (7.2.0) rbs (4.0.3)
logger
prism (>= 1.6.0)
tsort
rdoc (8.0.0)
erb erb
psych (>= 4.0.0) prism (>= 1.6.0)
rbs (>= 4.0.0)
tsort tsort
regexp_parser (2.12.0) regexp_parser (2.12.0)
reline (0.6.3) reline (0.6.3)
@@ -299,7 +300,7 @@ GEM
chunky_png (~> 1.0) chunky_png (~> 1.0)
rqrcode_core (~> 2.0) rqrcode_core (~> 2.0)
rqrcode_core (2.1.0) rqrcode_core (2.1.0)
rubocop (1.87.0) rubocop (1.88.2)
json (~> 2.3) json (~> 2.3)
language_server-protocol (~> 3.17.0.2) language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0) lint_roller (~> 1.1.0)
@@ -310,7 +311,7 @@ GEM
rubocop-ast (>= 1.49.0, < 2.0) rubocop-ast (>= 1.49.0, < 2.0)
ruby-progressbar (~> 1.7) ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0) unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.49.1) rubocop-ast (1.50.0)
parser (>= 3.3.7.2) parser (>= 3.3.7.2)
prism (~> 1.7) prism (~> 1.7)
rubocop-performance (1.26.1) rubocop-performance (1.26.1)
@@ -321,11 +322,11 @@ GEM
ruby-vips (2.3.0) ruby-vips (2.3.0)
ffi (~> 1.12) ffi (~> 1.12)
logger logger
rubyzip (3.4.0) rubyzip (3.4.1)
safety_net_attestation (0.5.0) safety_net_attestation (0.5.0)
jwt (>= 2.0, < 4.0) jwt (>= 2.0, < 4.0)
securerandom (0.4.1) securerandom (0.4.1)
selenium-webdriver (4.45.0) selenium-webdriver (4.46.0)
base64 (~> 0.2) base64 (~> 0.2)
logger (~> 1.4) logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5) rexml (~> 3.2, >= 3.2.5)
@@ -338,13 +339,8 @@ GEM
bigdecimal bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2) concurrent-ruby (~> 1.0, >= 1.0.2)
logger logger
simplecov (0.22.0) simplecov (1.0.2)
docile (~> 1.1) solid_cable (4.0.2)
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)
actioncable (>= 7.2) actioncable (>= 7.2)
activejob (>= 7.2) activejob (>= 7.2)
activerecord (>= 7.2) activerecord (>= 7.2)
@@ -374,10 +370,10 @@ GEM
net-sftp (>= 2.1.2) net-sftp (>= 2.1.2)
net-ssh (>= 2.8.0) net-ssh (>= 2.8.0)
ostruct ostruct
standard (1.55.0) standard (1.56.0)
language_server-protocol (~> 3.17.0.2) language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.0) lint_roller (~> 1.0)
rubocop (~> 1.87.0) rubocop (~> 1.88.0)
standard-custom (~> 1.0.0) standard-custom (~> 1.0.0)
standard-performance (~> 1.8) standard-performance (~> 1.8)
standard-custom (1.0.2) standard-custom (1.0.2)
@@ -388,21 +384,20 @@ GEM
rubocop-performance (~> 1.26.0) rubocop-performance (~> 1.26.0)
stimulus-rails (1.3.4) stimulus-rails (1.3.4)
railties (>= 6.0.0) railties (>= 6.0.0)
stringio (3.2.0)
tailwindcss-rails (4.6.0) tailwindcss-rails (4.6.0)
railties (>= 7.0.0) railties (>= 7.0.0)
tailwindcss-ruby (~> 4.0) tailwindcss-ruby (~> 4.0)
tailwindcss-ruby (4.3.1) tailwindcss-ruby (4.3.2)
tailwindcss-ruby (4.3.1-aarch64-linux-gnu) tailwindcss-ruby (4.3.2-aarch64-linux-gnu)
tailwindcss-ruby (4.3.1-aarch64-linux-musl) tailwindcss-ruby (4.3.2-aarch64-linux-musl)
tailwindcss-ruby (4.3.1-arm64-darwin) tailwindcss-ruby (4.3.2-arm64-darwin)
tailwindcss-ruby (4.3.1-x86_64-linux-gnu) tailwindcss-ruby (4.3.2-x86_64-linux-gnu)
tailwindcss-ruby (4.3.1-x86_64-linux-musl) tailwindcss-ruby (4.3.2-x86_64-linux-musl)
thor (1.5.0) thor (1.5.0)
thruster (0.1.21) thruster (0.1.23)
thruster (0.1.21-aarch64-linux) thruster (0.1.23-aarch64-linux)
thruster (0.1.21-arm64-darwin) thruster (0.1.23-arm64-darwin)
thruster (0.1.21-x86_64-linux) thruster (0.1.23-x86_64-linux)
timeout (0.6.1) timeout (0.6.1)
tpm-key_attestation (0.14.1) tpm-key_attestation (0.14.1)
bindata (~> 2.4) bindata (~> 2.4)
@@ -432,7 +427,7 @@ GEM
safety_net_attestation (~> 0.5.0) safety_net_attestation (~> 0.5.0)
tpm-key_attestation (~> 0.14.0) tpm-key_attestation (~> 0.14.0)
websocket (1.2.11) websocket (1.2.11)
websocket-driver (0.8.1) websocket-driver (0.8.2)
base64 base64
websocket-extensions (>= 0.1.0) websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5) websocket-extensions (0.1.5)
-110
View File
@@ -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
View File
@@ -1,25 +1,51 @@
class OidcController < ApplicationController 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 # Discovery and JWKS endpoints are public
# authorize is also unauthenticated to handle prompt=none and prompt=login specially # authorize is also unauthenticated to handle prompt=none and prompt=login specially
allow_unauthenticated_access only: [:discovery, :jwks, :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/userinfo) and pure redirect handlers # Machine-to-machine endpoints (token/revoke/introspect/userinfo/device_authorization)
# (logout/authorize) legitimately skip CSRF. The consent endpoint is browser-facing # and pure redirect handlers (logout/authorize) legitimately skip CSRF. The consent
# and state-changing (it grants OAuth scopes), so it MUST keep CSRF protection — the # endpoint is browser-facing and state-changing (it grants OAuth scopes), so it MUST
# consent form already embeds the token via form_with. # 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] 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 # 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. # other error can be reported via redirect. Failures here render a plain page.
before_action :set_application, only: :authorize before_action :set_application, only: :authorize
before_action :validate_redirect_uri, only: :authorize before_action :validate_redirect_uri, only: :authorize
# Rate limiting to prevent brute force and abuse # Rate limiting to prevent brute force and abuse.
rate_limit to: 60, within: 1.minute, only: [:token, :revoke], with: -> { #
# 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 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 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", authorization_endpoint: "#{base_url}/oauth/authorize",
token_endpoint: "#{base_url}/oauth/token", token_endpoint: "#{base_url}/oauth/token",
revocation_endpoint: "#{base_url}/oauth/revoke", revocation_endpoint: "#{base_url}/oauth/revoke",
introspection_endpoint: "#{base_url}/oauth/introspect",
userinfo_endpoint: "#{base_url}/oauth/userinfo", userinfo_endpoint: "#{base_url}/oauth/userinfo",
device_authorization_endpoint: "#{base_url}/oauth/device_authorization",
jwks_uri: "#{base_url}/.well-known/jwks.json", jwks_uri: "#{base_url}/.well-known/jwks.json",
end_session_endpoint: "#{base_url}/logout", end_session_endpoint: "#{base_url}/logout",
response_types_supported: ["code"], response_types_supported: ["code"],
response_modes_supported: ["query"], response_modes_supported: ["query"],
grant_types_supported: ["authorization_code", "refresh_token"], grant_types_supported: SUPPORTED_GRANT_TYPES,
subject_types_supported: ["pairwise"], subject_types_supported: ["pairwise"],
id_token_signing_alg_values_supported: ["RS256"], 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"], token_endpoint_auth_methods_supported: ["client_secret_post", "client_secret_basic"],
claims_supported: [ claims_supported: [
"sub", # Always included "sub", # Always included
@@ -60,6 +88,11 @@ class OidcController < ApplicationController
claims_parameter_supported: true 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 render json: config
end end
@@ -68,6 +101,82 @@ class OidcController < ApplicationController
render json: OidcJwtService.jwks render json: OidcJwtService.jwks
end 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 # GET /oauth/authorize
def authorize def authorize
# @application and a validated redirect_uri are guaranteed by the before_actions. # @application and a validated redirect_uri are guaranteed by the before_actions.
@@ -80,6 +189,7 @@ class OidcController < ApplicationController
response_type = params[:response_type] response_type = params[:response_type]
code_challenge = params[:code_challenge] code_challenge = params[:code_challenge]
code_challenge_method = params[:code_challenge_method] || "S256" 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). # client_id and redirect_uri are already validated (see before_actions).
@@ -92,20 +202,21 @@ class OidcController < ApplicationController
# return request_not_supported error # return request_not_supported error
if params[:request].present? || params[:request_uri].present? if params[:request].present? || params[:request_uri].present?
Rails.logger.error "OAuth: Request object not supported" Rails.logger.error "OAuth: Request object not supported"
error_uri = "#{redirect_uri}?error=request_not_supported" redirect_authorize_error(redirect_uri, "request_not_supported", description: "Request objects are not supported", state: state)
error_uri += "&error_description=#{CGI.escape("Request objects are not supported")}"
error_uri += "&state=#{CGI.escape(state)}" if state.present?
redirect_to error_uri, allow_other_host: true
return return
end end
# Validate response_type (now we can safely redirect with error) # Validate response_type (now we can safely redirect with error)
unless response_type == "code" unless response_type == "code"
Rails.logger.error "OAuth: Invalid response_type: #{response_type}" Rails.logger.error "OAuth: Invalid response_type: #{response_type}"
error_uri = "#{redirect_uri}?error=unsupported_response_type" redirect_authorize_error(redirect_uri, "unsupported_response_type", description: "Only 'code' response_type is supported", state: state)
error_uri += "&error_description=#{CGI.escape("Only 'code' response_type is supported")}" return
error_uri += "&state=#{CGI.escape(state)}" if state.present? end
redirect_to error_uri, allow_other_host: true
# 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 return
end end
@@ -113,20 +224,14 @@ class OidcController < ApplicationController
if code_challenge.present? if code_challenge.present?
unless code_challenge_method == "S256" unless code_challenge_method == "S256"
Rails.logger.error "OAuth: Invalid code_challenge_method: #{code_challenge_method}" Rails.logger.error "OAuth: Invalid code_challenge_method: #{code_challenge_method}"
error_uri = "#{redirect_uri}?error=invalid_request" redirect_authorize_error(redirect_uri, "invalid_request", description: "Invalid code_challenge_method: only 'S256' is supported", state: state)
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
return return
end end
# Validate code challenge format (base64url-encoded, 43-128 characters) # Validate code challenge format (base64url-encoded, 43-128 characters)
unless code_challenge.match?(/\A[A-Za-z0-9\-_]{43,128}\z/) unless code_challenge.match?(/\A[A-Za-z0-9\-_]{43,128}\z/)
Rails.logger.error "OAuth: Invalid code_challenge format" Rails.logger.error "OAuth: Invalid code_challenge format"
error_uri = "#{redirect_uri}?error=invalid_request" redirect_authorize_error(redirect_uri, "invalid_request", description: "Invalid code_challenge format: must be 43-128 characters of base64url encoding", state: state)
error_uri += "&error_description=#{CGI.escape("Invalid code_challenge format: must be 43-128 characters of base64url encoding")}"
error_uri += "&state=#{CGI.escape(state)}" if state.present?
redirect_to error_uri, allow_other_host: true
return return
end end
end end
@@ -134,7 +239,7 @@ class OidcController < ApplicationController
# Normalize requested scopes to the set we support. Needed here so claims # Normalize requested scopes to the set we support. Needed here so claims
# validation below can check claim→scope coverage against what will actually # validation below can check claim→scope coverage against what will actually
# be granted. # be granted.
requested_scopes = scope.split(" ") & SUPPORTED_SCOPES requested_scopes = scope.split(" ") & OidcScopes::SUPPORTED
scope = requested_scopes.join(" ") scope = requested_scopes.join(" ")
# Parse claims parameter (JSON string) for OIDC claims request # Parse claims parameter (JSON string) for OIDC claims request
@@ -146,10 +251,7 @@ class OidcController < ApplicationController
# Validate claims parameter format if present # Validate claims parameter format if present
if claims_parameter.present? && parsed_claims.nil? if claims_parameter.present? && parsed_claims.nil?
Rails.logger.error "OAuth: Invalid claims parameter format" Rails.logger.error "OAuth: Invalid claims parameter format"
error_uri = "#{redirect_uri}?error=invalid_request" redirect_authorize_error(redirect_uri, "invalid_request", description: "Invalid claims parameter: must be valid JSON", state: state)
error_uri += "&error_description=#{CGI.escape("Invalid claims parameter: must be valid JSON")}"
error_uri += "&state=#{CGI.escape(state)}" if state.present?
redirect_to error_uri, allow_other_host: true
return return
end end
@@ -158,10 +260,7 @@ class OidcController < ApplicationController
validation_result = validate_claims_against_scopes(parsed_claims, requested_scopes) validation_result = validate_claims_against_scopes(parsed_claims, requested_scopes)
unless validation_result[:valid] unless validation_result[:valid]
Rails.logger.error "OAuth: Claims parameter requests claims not covered by scopes: #{validation_result[:errors]}" Rails.logger.error "OAuth: Claims parameter requests claims not covered by scopes: #{validation_result[:errors]}"
error_uri = "#{redirect_uri}?error=invalid_scope" redirect_authorize_error(redirect_uri, "invalid_scope", description: "Claims parameter requests claims not covered by granted scopes", state: state)
error_uri += "&error_description=#{CGI.escape("Claims parameter requests claims not covered by granted scopes")}"
error_uri += "&state=#{CGI.escape(state)}" if state.present?
redirect_to error_uri, allow_other_host: true
return return
end end
end end
@@ -169,9 +268,7 @@ class OidcController < ApplicationController
# Check if application is active (now we can safely redirect with error) # Check if application is active (now we can safely redirect with error)
unless @application.active? unless @application.active?
Rails.logger.error "OAuth: Application is not active: #{@application.name}" Rails.logger.error "OAuth: Application is not active: #{@application.name}"
error_uri = "#{redirect_uri}?error=unauthorized_client&error_description=Application+is+not+active" redirect_authorize_error(redirect_uri, "unauthorized_client", description: "Application is not active", state: state)
error_uri += "&state=#{CGI.escape(state)}" if state.present?
redirect_to error_uri, allow_other_host: true
return return
end end
@@ -181,9 +278,7 @@ class OidcController < ApplicationController
# Per OIDC Core spec §3.1.2.6: If prompt=none and user not authenticated, # Per OIDC Core spec §3.1.2.6: If prompt=none and user not authenticated,
# return login_required error without showing any UI # return login_required error without showing any UI
if params[:prompt] == "none" if params[:prompt] == "none"
error_uri = "#{redirect_uri}?error=login_required" redirect_authorize_error(redirect_uri, "login_required", state: state)
error_uri += "&state=#{CGI.escape(state)}" if state.present?
redirect_to error_uri, allow_other_host: true
return return
end end
@@ -196,6 +291,7 @@ class OidcController < ApplicationController
scope: scope, scope: scope,
code_challenge: code_challenge, code_challenge: code_challenge,
code_challenge_method: code_challenge_method, code_challenge_method: code_challenge_method,
resource: resource,
claims_requests: parsed_claims&.to_json claims_requests: parsed_claims&.to_json
} }
# Store the current URL (with all OAuth params) for redirect after authentication # Store the current URL (with all OAuth params) for redirect after authentication
@@ -261,9 +357,7 @@ class OidcController < ApplicationController
end end
unless requested_scopes.include?("openid") unless requested_scopes.include?("openid")
error_uri = "#{redirect_uri}?error=invalid_scope&error_description=#{CGI.escape("The 'openid' scope is required")}" redirect_authorize_error(redirect_uri, "invalid_scope", description: "The 'openid' scope is required", state: state)
error_uri += "&state=#{CGI.escape(state)}" if state.present?
redirect_to error_uri, allow_other_host: true
return return
end end
@@ -286,6 +380,7 @@ class OidcController < ApplicationController
nonce: nonce, nonce: nonce,
code_challenge: code_challenge, code_challenge: code_challenge,
code_challenge_method: code_challenge_method, code_challenge_method: code_challenge_method,
resource: resource,
claims_requests: parsed_claims || {}, claims_requests: parsed_claims || {},
auth_time: Current.session.created_at.to_i, auth_time: Current.session.created_at.to_i,
acr: Current.session.acr, acr: Current.session.acr,
@@ -311,6 +406,7 @@ class OidcController < ApplicationController
nonce: nonce, nonce: nonce,
code_challenge: code_challenge, code_challenge: code_challenge,
code_challenge_method: code_challenge_method, code_challenge_method: code_challenge_method,
resource: resource,
claims_requests: parsed_claims || {}, claims_requests: parsed_claims || {},
auth_time: Current.session.created_at.to_i, auth_time: Current.session.created_at.to_i,
acr: Current.session.acr, acr: Current.session.acr,
@@ -333,6 +429,7 @@ class OidcController < ApplicationController
scope: scope, scope: scope,
code_challenge: code_challenge, code_challenge: code_challenge,
code_challenge_method: code_challenge_method, code_challenge_method: code_challenge_method,
resource: resource,
claims_requests: parsed_claims&.to_json claims_requests: parsed_claims&.to_json
} }
@@ -374,9 +471,7 @@ class OidcController < ApplicationController
# User denied consent # User denied consent
if params[:deny].present? if params[:deny].present?
session.delete(:oauth_params) session.delete(:oauth_params)
error_uri = "#{oauth_params["redirect_uri"]}?error=access_denied" redirect_authorize_error(oauth_params["redirect_uri"], "access_denied", state: oauth_params["state"])
error_uri += "&state=#{CGI.escape(oauth_params["state"])}" if oauth_params["state"]
redirect_to error_uri, allow_other_host: true
return return
end end
@@ -388,15 +483,13 @@ class OidcController < ApplicationController
unless application&.active? unless application&.active?
Rails.logger.error "OAuth: Application is not active: #{application&.name || client_id}" Rails.logger.error "OAuth: Application is not active: #{application&.name || client_id}"
session.delete(:oauth_params) session.delete(:oauth_params)
error_uri = "#{oauth_params["redirect_uri"]}?error=unauthorized_client&error_description=Application+is+not+active" redirect_authorize_error(oauth_params["redirect_uri"], "unauthorized_client", description: "Application is not active", state: oauth_params["state"])
error_uri += "&state=#{CGI.escape(oauth_params["state"])}" if oauth_params["state"].present?
redirect_to error_uri, allow_other_host: true
return return
end end
user = Current.session.user user = Current.session.user
requested_scopes = oauth_params["scope"].split(" ") & SUPPORTED_SCOPES requested_scopes = oauth_params["scope"].split(" ") & OidcScopes::SUPPORTED
parsed_claims = begin parsed_claims = begin
JSON.parse(oauth_params["claims_requests"]) JSON.parse(oauth_params["claims_requests"])
rescue rescue
@@ -418,6 +511,7 @@ class OidcController < ApplicationController
nonce: oauth_params["nonce"], nonce: oauth_params["nonce"],
code_challenge: oauth_params["code_challenge"], code_challenge: oauth_params["code_challenge"],
code_challenge_method: oauth_params["code_challenge_method"], code_challenge_method: oauth_params["code_challenge_method"],
resource: oauth_params["resource"],
claims_requests: parsed_claims, claims_requests: parsed_claims,
auth_time: Current.session.created_at.to_i, auth_time: Current.session.created_at.to_i,
acr: Current.session.acr, acr: Current.session.acr,
@@ -453,11 +547,146 @@ class OidcController < ApplicationController
handle_authorization_code_grant handle_authorization_code_grant
when "refresh_token" when "refresh_token"
handle_refresh_token_grant handle_refresh_token_grant
when "urn:ietf:params:oauth:grant-type:device_code"
handle_device_code_grant
else else
render json: {error: "unsupported_grant_type"}, status: :bad_request render json: {error: "unsupported_grant_type"}, status: :bad_request
end end
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 def handle_authorization_code_grant
# Get client credentials from Authorization header or params # Get client credentials from Authorization header or params
client_id, client_secret = extract_client_credentials client_id, client_secret = extract_client_credentials
@@ -556,26 +785,15 @@ class OidcController < ApplicationController
# Get the user # Get the user
user = auth_code.user user = auth_code.user
# Generate access token record (opaque token with BCrypt hashing) # Re-check authorization at mint time: the user may have been deactivated or
access_token_record = OidcAccessToken.create!( # removed from the allowed group between /authorize and this token request.
application: application, unless user && application.user_allowed?(user)
user: user, render json: {error: "access_denied", error_description: "User is no longer permitted to access this application"}, status: :bad_request
scope: auth_code.scope, return
oidc_authorization_code: auth_code end
)
# Generate refresh token (opaque, with hashing) # Find user consent for this application before minting, so a missing
refresh_token_record = OidcRefreshToken.create!( # consent record can't leave orphaned tokens committed in this transaction.
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
consent = OidcUserConsent.find_by(user: user, application: application) consent = OidcUserConsent.find_by(user: user, application: application)
unless consent unless consent
@@ -584,35 +802,16 @@ class OidcController < ApplicationController
return return
end end
# Generate ID token (JWT) with pairwise SID, at_hash, auth_time, and acr # auth_time, acr, and nonce come from the authorization code (captured at
# auth_time and acr come from the authorization code (captured at /authorize time) # /authorize time); the claims request filters which id_token claims appear.
# scopes determine which claims are included (per OIDC Core spec) mint_and_render_tokens(
# claims_requests parameter filters which claims are included application: application,
id_token = OidcJwtService.generate_id_token( user: user,
user, grant: auth_code,
application, grant_association: {oidc_authorization_code: auth_code},
consent: consent, 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 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 end
rescue ActiveRecord::RecordNotFound rescue ActiveRecord::RecordNotFound
render json: {error: "invalid_grant"}, status: :bad_request render json: {error: "invalid_grant"}, status: :bad_request
@@ -690,19 +889,32 @@ class OidcController < ApplicationController
# Get the user # Get the user
user = refresh_token_record.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) # Revoke the old refresh token (token rotation)
refresh_token_record.revoke! refresh_token_record.revoke!
# Generate new access token record (opaque token with BCrypt hashing) # Generate new access token record (opaque token with BCrypt hashing)
# Carry the authorization-code FK forward across rotations so replay # Carry the issuing-code FK forward across rotations so replay revocation
# revocation reaches every descendant token in the chain. # 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_auth_code = refresh_token_record.oidc_authorization_code
issuing_device_code = refresh_token_record.oidc_device_code
new_access_token = OidcAccessToken.create!( new_access_token = OidcAccessToken.create!(
application: application, application: application,
user: user, user: user,
scope: refresh_token_record.scope, 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) # Generate new refresh token (token rotation)
@@ -711,10 +923,12 @@ class OidcController < ApplicationController
user: user, user: user,
oidc_access_token: new_access_token, oidc_access_token: new_access_token,
oidc_authorization_code: issuing_auth_code, oidc_authorization_code: issuing_auth_code,
oidc_device_code: issuing_device_code,
scope: refresh_token_record.scope, scope: refresh_token_record.scope,
token_family_id: refresh_token_record.token_family_id, # Keep same family for rotation tracking 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 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 # Find user consent for this application
@@ -868,6 +1082,89 @@ class OidcController < ApplicationController
render json: claims render json: claims
end 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 # POST /oauth/revoke
# RFC 7009 - Token Revocation # RFC 7009 - Token Revocation
def revoke def revoke
@@ -980,6 +1277,71 @@ class OidcController < ApplicationController
private 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 # 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. # invalid client_id be reported on-page, not via redirect.
def set_application def set_application
@@ -1094,6 +1456,17 @@ class OidcController < ApplicationController
{valid: true} {valid: true}
end 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 def extract_client_credentials
# Try Authorization header first (Basic auth) # Try Authorization header first (Basic auth)
if request.headers["Authorization"]&.start_with?("Basic ") 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
+16
View File
@@ -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
+10
View File
@@ -25,5 +25,15 @@ class OidcTokenCleanupJob < ApplicationJob
old_auth_codes = OidcAuthorizationCode.where("created_at < ?", 7.days.ago) old_auth_codes = OidcAuthorizationCode.where("created_at < ?", 7.days.ago)
deleted_count = old_auth_codes.delete_all deleted_count = old_auth_codes.delete_all
Rails.logger.info "OIDC Token Cleanup: Deleted #{deleted_count} old authorization codes" 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
end end
+30
View File
@@ -36,6 +36,7 @@ class Application < ApplicationRecord
has_many :allowed_groups, through: :application_groups, source: :group has_many :allowed_groups, through: :application_groups, source: :group
has_many :application_user_claims, dependent: :destroy has_many :application_user_claims, dependent: :destroy
has_many :oidc_authorization_codes, dependent: :destroy has_many :oidc_authorization_codes, dependent: :destroy
has_many :oidc_device_codes, dependent: :destroy
has_many :oidc_access_tokens, dependent: :destroy has_many :oidc_access_tokens, dependent: :destroy
has_many :oidc_refresh_tokens, dependent: :destroy has_many :oidc_refresh_tokens, dependent: :destroy
has_many :oidc_user_consents, dependent: :destroy has_many :oidc_user_consents, dependent: :destroy
@@ -103,6 +104,22 @@ class Application < ApplicationRecord
app_type == "forward_auth" app_type == "forward_auth"
end 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) # Client type checks (for OIDC)
def public_client? def public_client?
client_secret_digest.blank? client_secret_digest.blank?
@@ -139,6 +156,19 @@ class Application < ApplicationRecord
redirect_uris.split("\n").map(&:strip).reject(&:blank?) redirect_uris.split("\n").map(&:strip).reject(&:blank?)
end 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 def parsed_metadata
return {} unless metadata.present? return {} unless metadata.present?
JSON.parse(metadata) JSON.parse(metadata)
+1
View File
@@ -2,6 +2,7 @@ class OidcAccessToken < ApplicationRecord
belongs_to :application belongs_to :application
belongs_to :user belongs_to :user
belongs_to :oidc_authorization_code, optional: true belongs_to :oidc_authorization_code, optional: true
belongs_to :oidc_device_code, optional: true
has_many :oidc_refresh_tokens, dependent: :destroy has_many :oidc_refresh_tokens, dependent: :destroy
before_validation :generate_token, on: :create before_validation :generate_token, on: :create
+150
View File
@@ -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
+1
View File
@@ -3,6 +3,7 @@ class OidcRefreshToken < ApplicationRecord
belongs_to :user belongs_to :user
belongs_to :oidc_access_token belongs_to :oidc_access_token
belongs_to :oidc_authorization_code, optional: true belongs_to :oidc_authorization_code, optional: true
belongs_to :oidc_device_code, optional: true
before_validation :generate_token, on: :create before_validation :generate_token, on: :create
before_validation :set_expiry, on: :create before_validation :set_expiry, on: :create
+7
View File
@@ -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
+22 -2
View File
@@ -8,9 +8,29 @@ class OidcUserConsent < ApplicationRecord
before_validation :set_granted_at, on: :create before_validation :set_granted_at, on: :create
before_validation :set_sid, 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 def scopes
scopes_granted.split(" ") scopes_granted.to_s.split(" ")
end end
# Set scopes from an array # Set scopes from an array
+25
View File
@@ -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> </div>
</dl> </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="mt-8 flow-root">
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8"> <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"> <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>
+1 -34
View File
@@ -18,40 +18,7 @@
<div class="mb-6"> <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> <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"> <%= render "shared/scope_list", scopes: @scopes %>
<% 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>
</div> </div>
<div class="rounded-md bg-blue-50 dark:bg-blue-900/30 p-4 mb-6"> <div class="rounded-md bg-blue-50 dark:bg-blue-900/30 p-4 mb-6">
+14
View File
@@ -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 -1
View File
@@ -1,5 +1,5 @@
# frozen_string_literal: true # frozen_string_literal: true
module Clinch module Clinch
VERSION = "0.16.2" VERSION = "0.17.1"
end end
+13
View File
@@ -25,14 +25,25 @@ Rails.application.routes.draw do
# OIDC (OpenID Connect) routes # OIDC (OpenID Connect) routes
get "/.well-known/openid-configuration", to: "oidc#discovery" get "/.well-known/openid-configuration", to: "oidc#discovery"
# 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" 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] match "/oauth/authorize", to: "oidc#authorize", via: [:get, :post]
post "/oauth/authorize/consent", to: "oidc#consent", as: :oauth_consent post "/oauth/authorize/consent", to: "oidc#consent", as: :oauth_consent
post "/oauth/token", to: "oidc#token" post "/oauth/token", to: "oidc#token"
post "/oauth/revoke", to: "oidc#revoke" post "/oauth/revoke", to: "oidc#revoke"
post "/oauth/introspect", to: "oidc#introspect"
match "/oauth/userinfo", to: "oidc#userinfo", via: [:get, :post] match "/oauth/userinfo", to: "oidc#userinfo", via: [:get, :post]
get "/logout", to: "oidc#logout" 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 # ForwardAuth / Trusted Header SSO
namespace :api do namespace :api do
get "/verify", to: "forward_auth#verify" get "/verify", to: "forward_auth#verify"
@@ -96,6 +107,8 @@ Rails.application.routes.draw do
end end
resources :groups resources :groups
get "access", to: "access_checks#new" 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 end
# Render dynamic PWA files from app/views/pwa/* (remember to link manifest in application.html.erb) # 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
@@ -0,0 +1,14 @@
class AddCascadeToOidcDeviceCodesApplicationFk < ActiveRecord::Migration[8.1]
# Deleting an application left its oidc_device_codes orphaned, tripping this
# FK and 500ing the destroy. Mirror application_user_claims: cascade at the DB
# level so the delete is safe even if the model-layer cascade is bypassed.
def up
remove_foreign_key :oidc_device_codes, :applications
add_foreign_key :oidc_device_codes, :applications, on_delete: :cascade
end
def down
remove_foreign_key :oidc_device_codes, :applications
add_foreign_key :oidc_device_codes, :applications
end
end
Generated
+47 -1
View File
@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2026_06_11_000001) do ActiveRecord::Schema[8.1].define(version: 2026_07_19_000006) do
create_table "active_storage_attachments", force: :cascade do |t| create_table "active_storage_attachments", force: :cascade do |t|
t.bigint "blob_id", null: false t.bigint "blob_id", null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
@@ -96,6 +96,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_11_000001) do
t.text "redirect_uris" t.text "redirect_uris"
t.integer "refresh_token_ttl", default: 2592000 t.integer "refresh_token_ttl", default: 2592000
t.boolean "require_pkce", default: true, null: false t.boolean "require_pkce", default: true, null: false
t.text "resource_identifiers"
t.boolean "skip_consent", default: false, null: false t.boolean "skip_consent", default: false, null: false
t.string "slug", null: false t.string "slug", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
@@ -123,6 +124,8 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_11_000001) do
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "expires_at", null: false t.datetime "expires_at", null: false
t.integer "oidc_authorization_code_id" t.integer "oidc_authorization_code_id"
t.integer "oidc_device_code_id"
t.string "resource"
t.datetime "revoked_at" t.datetime "revoked_at"
t.string "scope" t.string "scope"
t.string "token_hmac" 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 ["application_id"], name: "index_oidc_access_tokens_on_application_id"
t.index ["expires_at"], name: "index_oidc_access_tokens_on_expires_at" 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_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 ["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 ["token_hmac"], name: "index_oidc_access_tokens_on_token_hmac", unique: true
t.index ["user_id"], name: "index_oidc_access_tokens_on_user_id" 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.datetime "expires_at", null: false
t.string "nonce" t.string "nonce"
t.string "redirect_uri", null: false t.string "redirect_uri", null: false
t.string "resource"
t.string "scope" t.string "scope"
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.boolean "used", default: false, 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" t.index ["user_id"], name: "index_oidc_authorization_codes_on_user_id"
end 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| create_table "oidc_refresh_tokens", force: :cascade do |t|
t.string "acr" t.string "acr"
t.integer "application_id", null: false 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.datetime "expires_at", null: false
t.integer "oidc_access_token_id", null: false t.integer "oidc_access_token_id", null: false
t.integer "oidc_authorization_code_id" t.integer "oidc_authorization_code_id"
t.integer "oidc_device_code_id"
t.string "resource"
t.datetime "revoked_at" t.datetime "revoked_at"
t.string "scope" t.string "scope"
t.integer "token_family_id" 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 ["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_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_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 ["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_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 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" t.index ["user_id"], name: "index_sessions_on_user_id"
end 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| create_table "user_groups", force: :cascade do |t|
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.integer "group_id", 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 "application_user_claims", "users", on_delete: :cascade
add_foreign_key "oidc_access_tokens", "applications" 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_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_access_tokens", "users"
add_foreign_key "oidc_authorization_codes", "applications" add_foreign_key "oidc_authorization_codes", "applications"
add_foreign_key "oidc_authorization_codes", "users" add_foreign_key "oidc_authorization_codes", "users"
add_foreign_key "oidc_device_codes", "applications", on_delete: :cascade
add_foreign_key "oidc_device_codes", "users"
add_foreign_key "oidc_refresh_tokens", "applications" add_foreign_key "oidc_refresh_tokens", "applications"
add_foreign_key "oidc_refresh_tokens", "oidc_access_tokens" 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_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_refresh_tokens", "users"
add_foreign_key "oidc_user_consents", "applications" add_foreign_key "oidc_user_consents", "applications"
add_foreign_key "oidc_user_consents", "users" add_foreign_key "oidc_user_consents", "users"
+51
View File
@@ -7,3 +7,54 @@
# ["Action", "Comedy", "Drama", "Horror"].each do |genre_name| # ["Action", "Comedy", "Drama", "Horror"].each do |genre_name|
# MovieGenre.find_or_create_by!(name: genre_name) # MovieGenre.find_or_create_by!(name: genre_name)
# end # 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.
+14
View File
@@ -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! @application.generate_new_client_secret!
@plain_client_secret = @application.client_secret @plain_client_secret = @application.client_secret
@application.save! @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 end
def teardown 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
+136
View File
@@ -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) assert_match(/error_description=.*code_challenge_method/, @response.location)
end 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 test "authorization endpoint rejects invalid code_challenge format" do
# Contains + character which is not base64url # Contains + character which is not base64url
auth_params = { auth_params = {
@@ -322,6 +343,7 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
require_pkce: false require_pkce: false
) )
legacy_app.generate_new_client_secret! legacy_app.generate_new_client_secret!
grant_everyone_access(legacy_app)
# Create consent for token endpoint # Create consent for token endpoint
OidcUserConsent.create!( OidcUserConsent.create!(
@@ -379,6 +401,7 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
active: true, active: true,
is_public_client: true is_public_client: true
) )
grant_everyone_access(public_app)
assert public_app.public_client? assert public_app.public_client?
assert public_app.requires_pkce? assert public_app.requires_pkce?
@@ -442,6 +465,7 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
active: true, active: true,
is_public_client: true is_public_client: true
) )
grant_everyone_access(public_app)
assert public_app.public_client? assert public_app.public_client?
assert public_app.requires_pkce? 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
+24
View File
@@ -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
+31
View File
@@ -48,4 +48,35 @@ class OidcTokenCleanupJobTest < ActiveJob::TestCase
user&.destroy user&.destroy
application&.destroy application&.destroy
end 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 end
+10
View File
@@ -81,4 +81,14 @@ class ApplicationTest < ActiveSupport::TestCase
assert app.valid?, app.errors.full_messages.to_sentence assert app.valid?, app.errors.full_messages.to_sentence
end end
test "destroying an application with a pending device code succeeds (regression for FK 500)" do
app = applications(:kavita_app)
device_code = app.oidc_device_codes.create!
assert_difference("OidcDeviceCode.count", -1) do
assert_nothing_raised { app.destroy }
end
refute OidcDeviceCode.exists?(device_code.id)
end
end end
+92
View File
@@ -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
+39
View File
@@ -206,6 +206,45 @@ class OidcUserConsentTest < ActiveSupport::TestCase
assert consent.covers_scopes?(["openid", "email", "profile"]) assert consent.covers_scopes?(["openid", "email", "profile"])
end 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 test "should validate scope coverage logic with real OIDC scenarios" do
# Typical OIDC consent scenario # Typical OIDC consent scenario
@consent.scopes_granted = "openid profile email" @consent.scopes_granted = "openid profile email"