Previously the copy-pasteable env-var block only appeared right after
creating an app or regenerating credentials. Operators had no easy way
back to it, so they had to reconstruct OIDC_DISCOVERY_URL etc. from
memory.
Adds a collapsed <details> disclosure inside the OIDC Configuration
card with the same env vars (placeholder for the secret, which can't
be re-shown). Extracts the env-line construction into an
oidc_env_lines helper so the flash panel and the persistent display
share one source of truth.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Surfaces OIDC_CLIENT_ID/SECRET, discovery URL, provider name, and PKCE
flag in a single textarea on the credentials flash so the client config
can be dropped straight into a consuming app's .env file.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Patch release covering the Ruby 4.0.3, Rails 8.1.3, and transitive
gem updates landed since 0.10.0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Routine bundle update on the Ruby 4.0.3 install. No app code changes;
test suite green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Patch release within 4.0.x — security and bug fixes only, no source
changes required. Updated both .ruby-version and the Dockerfile ARG
they're explicitly told to keep in sync.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Substantial scope since 0.9.0: API keys for forward auth, SecurityMailer
alerts on 8 account-security events, dark mode, Remember-me with proper
browser-session cookie semantics, SvgScrubber for icon XSS, OIDC
auth-code replay revocation, forward-auth caching + rate limiting, and
fixes for broken invitation / password-reset emails.
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>
Production switched the queue adapter back to :solid_queue but the Puma
plugin had been removed, so jobs (e.g. invitation resend) enqueued fine
but never ran. Clinch ships as a single container, so always start the
supervisor in production rather than gating on SOLID_QUEUE_IN_PUMA.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both templates called `@user.password_reset_token` and
`@user.password_reset_token_expires_in`, which don't exist —
`generates_token_for` only adds class-level helpers, not instance
accessors. Every password reset email was failing at render time.
Use `generate_token_for(:password_reset)` and a literal expiry string
matching the 1-hour TTL on the token.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The text part used non-existent helpers (`invite_url`,
`@user.invitation_login_token`) and Ruby string interpolation in an ERB
file, so multipart delivery failed at render time and no invite mail
went out. Mirror the HTML template instead.
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>
Replace the changelog-flavored "view no longer round-trips" line with a
one-liner naming the actual threat (session-holder substituting a secret
they control). Drop the narration comment above session.delete +
deliver_later -- the identifiers already say what the two lines do.
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 previous implementation iterated find_each(&:revoke!) on both the
access-token and refresh-token associations. OidcAccessToken#revoke!
also cascades to its refresh tokens, so a chain of N access tokens with
their refresh tokens produced ~3N UPDATEs (outer loop + cascade +
outer refresh loop double-writing) all while holding a pessimistic
lock on the auth_code row. Replace with scoped update_all on each
association -- 2 UPDATEs total, no behavior change.
Also hoist the repeated refresh_token_record.oidc_authorization_code
lookup in the rotation path to a named local and drop the duplicated
inline comment.
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>
The authorize action opened with ~55 lines of parameter validation that
ran before any business logic. Move the two RFC 6749 §4.1.2.1 checks
(client_id lookup, redirect_uri registration) into set_application and
validate_redirect_uri before_actions. The action body now starts at the
point where errors may legitimately redirect back to the client.
Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
The authorize action called validate_claims_against_scopes with
requested_scopes before that local was assigned (assignment was ~100
lines later), raising NameError whenever a client passed a claims=
parameter. Move the scope normalization above the claims validation.
Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
- Add "Remember me for 30 days" checkbox (30-day vs 24-hour session expiry)
- Center heading and constrain form width to max-w-md
- Preserve remember_me preference through TOTP and WebAuthn auth flows
Co-Authored-By: Claude Opus 4.6 (1M context) <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>
Safari's WebAuthn dialog can become undismissable when invoked without
a user gesture. Always require the user to click "Continue with Passkey"
instead of auto-triggering navigator.credentials.get().
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Uses Tailwind v4 class-based dark mode with a Stimulus controller for
toggling. Respects prefers-color-scheme as default, prevents FOUC with
an inline script, and persists the user's choice in localStorage. All
views updated with dark: variants for backgrounds, text, borders,
badges, buttons, and form inputs.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Install @tailwindcss/forms to fix missing padding on form inputs across
the app. Move the Application Type selector earlier in the new application
form (after slug, before description) so it gates type-specific fields
sooner. On the edit page, replace the confusing disabled dropdown with a
read-only badge since the type can't be changed after creation.
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>
Add error target to login page so WebAuthn errors are visible instead
of only appearing in the console. Use a helpful fallback message that
suggests a browser extension may be interfering.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When a user has both passkeys and TOTP configured, auto-trigger the
passkey flow on login to save them from the password→TOTP path. Also
add a "Use Passkey Instead" button on the TOTP verification page as
an escape hatch for users who end up there.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Document API keys / bearer tokens for forward auth
- Add VoidAuth to alternatives list
- Move OIDC conformance certification and test counts to top
- Update Ruby requirement to 4.0+, test count to 450
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>