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
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
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
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
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
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
The access check form POSTed and re-rendered :new with a 200 HTML
response, which Turbo rejects ("Form responses must redirect to
another location"), so the result panel never appeared. Since the
check is a read-only query, switch to a GET form and fold the lookup
into the new action. Results are now bookmarkable via the URL.
Bump version to 0.16.2.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bumps dependencies (jwt 3.2.0, puma 8.0.2, net-imap 0.6.4.1 and others
via bundle update) to resolve bundler-audit advisories, and applies
standardrb autofixes so the lint job passes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
revoke_family! revoked only the refresh tokens in a rotation family. When reuse
of a revoked refresh token was detected (a token-theft signal), the access
tokens issued across that chain stayed valid at /userinfo until expiry — up to
the access-token TTL — so an attacker holding a stolen access token kept access.
revoke_family! now also revokes every access token referenced by the family's
refresh tokens. Adds a regression test: rotate once, reuse the revoked token,
and assert both the original and rotated-in access tokens are revoked.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
verify_totp called ROTP without `after:`, so a captured 6-digit code stayed
valid for the full ~90s drift window and could be replayed in a separate
sign-in. Add a last_otp_at column, pass it as ROTP's `after:`, and persist the
matched timestep on success so a code (or any earlier one) cannot be reused.
Also fixes a latent bug surfaced by the new replay path: enable_totp! did
`self.backup_codes = generate_backup_codes`, reassigning backup_codes to the
plaintext return value (generate_backup_codes already stores the BCrypt hashes
internally). That stored backup codes in plaintext and broke verification.
enable_totp! is test-only today, but it is public and backup_codes is not
encrypted, so this is a real footgun. Now it just calls generate_backup_codes.
Rewrites the mislabeled "TOTP code cannot be reused" test to actually assert
that replaying an accepted code is rejected.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The ApiKey model only validates group access on creation (user_must_have_access
runs on create). The bearer path in /api/verify never re-checked, so a user
removed from an application's allowed groups kept access via an existing key
until it was manually revoked.
Add an app.user_allowed?(user) check to authenticate_bearer_token, matching the
session path, returning 401 when the user no longer has group access. Adds a
regression test that revokes membership after key creation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two HIGH-severity findings from the security review:
- ForwardAuth: when no host header was present, /api/verify skipped the
application lookup and group check entirely, returning 200 with identity
headers (including all of the user's groups). This bypassed per-domain
access control. Now fails closed with 403, and the unreachable
DEFAULT_HEADERS fallback (the bypass path) is removed so headers are
always scoped to a resolved, active application.
- OIDC: the consent endpoint was in the verify_authenticity_token skip
list, so a forged cross-site POST could silently grant OAuth scopes.
Removed :consent from the skip list (the form already embeds the token).
Adds regression tests for both: fail-closed with no identity headers when
host is absent, and 422 on a tokenless consent POST.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Applications index used to render "All users" whenever an app had
no allowed_groups; under default-deny that's the opposite of the truth.
Replaced with a "No one" badge and, when groups are present, a
"N users · M groups" cell so the access reality is visible at a glance.
Added a small stats strip above the apps table: applications, users
with access, and groups granting access. Backed by preloaded counts in
the controller to avoid N+1.
Added /admin/access — a small "Access check" tool that takes a user
and an application and reports whether the user can reach it, with the
granting group(s) when allowed, and the specific reason when not
(inactive app/user, no allowed groups, or no shared group). Wired into
the admin sidebar.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the implicit "empty allowed_groups means public" rule with
explicit default-deny across both OIDC and ForwardAuth. Adds two boolean
flags on Group — auto_assign (Keycloak-style auto-join on user create)
and admin (members can reach the admin panel) — and drops the
users.admin column entirely. Adds "Users with access" and "Accessible
applications" panels with via-group badges on the application/user show
pages.
BEHAVIOR CHANGE: a ForwardAuth app with no allowed_groups previously
bypassed authentication entirely; it now returns 403 like any other
unauthorized request. The data migration seeds an "everyone" group and
attaches it to all previously group-less apps to preserve behavior on
existing installs. An "admins" group is seeded and backfilled from any
user with the old admin column.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds an "Assigned Applications" checkbox list to the group new/edit
form so admins can grant a group access to multiple apps from one
screen, instead of editing each application individually.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Without Remember-me the session cookie was still being written via
`cookies.signed.permanent`, so it survived browser restart on shared
devices — surprising for a user who explicitly opted out of Remember-me.
Issue a browser-session cookie (no Expires) when remember_me is off;
the server-side Session#expires_at still bounds the 24h / 30d window.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The refute_match on response.location already proves create_forward_auth_token
did nothing: the cache.write and the URL rewrite are back-to-back with no
branch between them, so the URL lacking fa_token= implies no cache entry
was written. The instance_variable_get(:@data) inspection was both redundant
and coupled to MemoryStore's private layout.
Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
An observed fa_token (via Referer leaks, access logs, JS monitors)
could previously be redeemed against a different reverse-proxied app
within the 60s TTL. The token now stores the destination host at
creation and the verifier rejects mismatches without burning the cache
entry, so legitimate destinations can still redeem.
Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
TOTP enrollment previously round-tripped the generated secret through a
hidden form field and saved whatever the client submitted, letting an
attacker with session access enroll a 2FA device they control by posting
their own secret plus a matching code. Stash the secret in the session
at GET /totp/new, read it only from the session at POST /totp, and drop
the hidden field from the view. Notify the user by email on successful
enrollment so unauthorized activations are visible even if a new vector
appears later.
Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
The replay handler previously used a created_at time-range filter to
target access tokens and called update_all(expires_at:), which left
revoked_at nil, skipped refresh tokens entirely, and could miss or
falsely catch tokens from concurrent flows. Add an oidc_authorization_code
FK on both token tables, carry it through refresh-token rotation, and
use the association to revoke every descendant via revoke! (which sets
revoked_at and cascades access -> refresh).
Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
Remove PKCE plain method support (S256 only), enforce openid scope requirement,
filter to supported scopes, strip reserved claims from custom claims as
defense-in-depth, sanitize SVG icons with Loofah, add global input padding,
switch session cookies to SameSite=Lax, use Session.active scope, and remove
unsafe-eval from CSP.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Test ApplicationGroup cache busting on add and remove
- Test first failure persists in rate limit cache (increment fallback)
- Test bearer token failures count toward rate limit
- Test rd parameter rejected for deactivated applications
- Test last_activity_at updates after debounce window expires
- Test successful requests don't reset failure counter
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Remove duplicated app_allows_user_cached?/headers_for_user_cached methods; call model methods directly
- Fix sliding-window rate limit bug by using increment instead of write (avoids TTL reset)
- Use cached app lookup in validate_redirect_url instead of hitting DB on every unauthorized request
- Add cache busting to ApplicationGroup so group assignment changes invalidate the cache
- Eager-load user groups (includes(user: :groups)) to eliminate N+1 queries
- Replace pluck(:name) with map(&:name) to use already-loaded associations
- Remove hardcoded fallback domain, dead methods, and unnecessary comments
- Fix test indentation and make group-order assertions deterministic
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Rate limit failed attempts (50/min per IP) with 429 + Retry-After.
Cache forward auth applications in a dedicated MemoryStore (8MB LRU)
to avoid loading all apps from SQLite on every request. Debounce
last_activity_at writes to at most once per minute per session.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace CGI.parse (removed in Ruby 4.0) with Rack::Utils.parse_query
in application controller, sessions controller, and OIDC tests.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Enables server-to-server authentication for forward auth applications
(e.g., video players accessing WebDAV) where browser cookies aren't
available. API keys use clk_ prefixed tokens stored as HMAC hashes.
Bearer token auth is checked before cookie auth in /api/verify.
Invalid tokens return 401 JSON (no redirect). Requests without
bearer tokens fall through to existing cookie flow unchanged.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>