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>
active? was only checked at the password step of sign-in. A user disabled
afterwards could (a) still complete the 2FA step and mint a valid session, and
(b) keep using any existing session until natural expiry, because per-request
auth only checked session expiry, not user status.
Three enforcement points:
- Mid-flow guard: verify_totp and webauthn_verify re-check active? before
start_new_session_for, clearing the pending session and rejecting if disabled.
- Request-time guard: find_session_by_cookie now uses Session.for_active_user,
so a session whose user is disabled no longer authenticates (authoritative,
catches any disable path including direct DB changes).
- Immediate cleanup: User#revoke_sessions_when_deactivated destroys a user's
sessions when status changes away from active, so access is revoked everywhere
at once rather than on the next request.
Tests cover the mid-flow TOTP rejection, request-time rejection of an existing
session after disable, session destruction on disable, and that unrelated
updates leave sessions intact.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
backchannel_logout_uri was validated only for scheme/HTTPS, so an admin (or a
compromised admin account) could point it at internal infrastructure — cloud
metadata (169.254.169.254), loopback, or RFC1918 hosts — and every user logout
would fire a server-side POST there.
Add PrivateAddressCheck (app/lib) and apply it as defense-in-depth:
- Application validation rejects URIs whose host is, or is a literal, internal
address (loopback / private / link-local / 0.0.0.0 / localhost / metadata
hostnames). Fast, DNS-free, immediate admin feedback.
- BackchannelLogoutJob re-checks at request time WITH DNS resolution and aborts
(no retry) if the host resolves to a non-public address — covering URIs that
predate the validation and public hostnames pointed at internal IPs.
Tests cover the address classification, the model validation, and updates an
existing test that used a localhost logout URI.
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>
determine_base_url fell back to request.host when CLINCH_HOST was unset. Rails
resolves request.host from X-Forwarded-Host behind a trusted proxy, so a spoofed
header could make the forward-auth login redirect point at an attacker origin
(host-header phishing).
- Add config/initializers/clinch_host.rb: fail fast at boot in any non-local
environment when CLINCH_HOST is blank. It anchors the OIDC issuer, WebAuthn
RP ID, and login redirect, so it must be explicit, never inferred.
- determine_base_url now uses CLINCH_HOST (guaranteed in production) with a safe
localhost default for dev/test, and never reads the request host.
- Simplify the spoofed-host regression test now that the fallback is safe.
Verified: production boot aborts with a clear message when CLINCH_HOST is blank,
and boots normally when set.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
render_unauthorized built the post-login return URL directly from the
attacker-influenceable X-Forwarded-Host / X-Forwarded-Uri headers, stored it
in the session, and reflected it into the signin `rd`. After authentication
that URL is followed with allow_other_host, so a spoofed host was an open
redirect.
Now the forwarded URL is only honoured if it resolves to a known, active
forward-auth application (via validate_redirect_url); otherwise it falls back
to a validated `rd` or the IdP's base URL. Once render_unauthorized only ever
stores a validated value, the sessions_controller "supplement, don't replace"
behaviour is safe, so no change is needed there.
Two integration tests were asserting the old behaviour by reflecting
unregistered hosts (grafana.example.com, app.example.com); they now register
those domains as forward-auth apps so they exercise the real feature. Adds a
regression test that a spoofed X-Forwarded-Host is neither stored nor
reflected.
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>
The previous <picture media="(prefers-color-scheme: dark)"> only fires
when the OS is in dark mode. Clinch toggles dark mode with a class on
<html> via dark_mode_controller and Tailwind's @custom-variant dark
(&:where(.dark, .dark *)), so the picture source never swapped when
users clicked the in-app theme toggle. Render both <img> tags and
use Tailwind's dark:hidden / hidden dark:block so the swap follows
whatever strategy Tailwind is configured for.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When an application has no icon attached, render a deterministic
monogram SVG instead of the generic picture-frame placeholder. Initials
are picked from capital letters in the name (ShelfLife -> SL); fall
back to the first two letters when fewer than two capitals exist
(Audiobookshelf -> AU). Background colour is hashed from the name for
stable per-app identity across visits.
Adds an optional second icon attachment, icon_dark, alongside the main
icon. When present, render a <picture> with a prefers-color-scheme:
dark source so the browser swaps automatically; when absent, the main
icon is used in both modes. The SVG sanitization, content-type fix,
and size/format validation now run over both attachments uniformly.
Bumps to 0.14.0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The sanitize_svg_icon before_validation callback called icon.download,
but Active Storage uploads pending blobs in before_save — so at
before_validation time the file only existed in the request tempfile,
not at the configured storage path. Read from the pending attachable
(UploadedFile / IO hash) instead. Guards against the recursive callback
that icon.attach would otherwise trigger by tracking the cleaned
attachable by object identity. Bumps to 0.13.1.
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>
Application#sanitize_svg_icon already runs a Loofah scrubber on every
icon upload, but the scrubber class itself was never tracked. Land it
along with tests covering the four shapes that matter:
- <script> elements stripped entirely
- on* event handlers (onload, onclick, …) removed but the carrying
element preserved
- attribute values pointing at javascript:/data: URIs rejected
- benign icons round-trip unchanged
Writing the benign-icon test caught a real bug: the attribute allowlist
holds canonical SVG case (viewBox, preserveAspectRatio, gradientUnits,
…) but safe_attribute? downcases the incoming name before comparing,
so legitimate icons were silently losing those attributes on upload.
Fix by comparing against a precomputed lowercase lookup set; the
constant stays readable as canonical SVG case for documentation.
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>
Previously only TOTP-enabled triggered an email. Every other
security-relevant change — password change, TOTP disable, passkey
add/remove, API key create/revoke, email address change, backup-code
regeneration — happened silently, so an attacker on a stolen session
could quietly drop 2FA or hijack the email with no signal to the
account holder.
Add SecurityMailer with one method per event. Each email carries the
request IP, user-agent, and timestamp so the user can spot unfamiliar
activity. Email-address changes notify both the old and new addresses
with directional language; the old-address copy explicitly warns that
whoever made the change can now receive password reset emails.
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 FK added in b7fa499 defaulted to ON DELETE RESTRICT, which means
OidcTokenCleanupJob#perform would fail when deleting auth codes older
than 7 days if any refresh token (whose expiry is days-to-weeks) still
referenced them. Switch both token FKs to ON DELETE SET NULL so token
rows survive the code deletion with a NULL FK, preserving the audit
trail the cleanup job deliberately keeps.
Add a regression test covering the exact scenario: a 10-day-old auth
code with a token still pointing at it -> cleanup deletes the code,
token survives, token FK is nulled.
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>