36 Commits

Author SHA1 Message Date
Dan Milne
c85d25c4b9 Untrack SECURITY_REVIEW_TODO.md and gitignore it
Some checks are pending
CI / scan_ruby (push) Waiting to run
CI / scan_js (push) Waiting to run
CI / scan_container (push) Waiting to run
CI / lint (push) Waiting to run
CI / test (push) Waiting to run
CI / system-test (push) Waiting to run
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 Milne
1b0d323572 Bump version to 0.16.3
Some checks failed
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 Milne
d1d626c540 Rework build workflow to trigger on version bump + manual dispatch
Some checks failed
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
Dan Milne
782e197d91 Fix access check form: use GET so results render
Some checks failed
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 (push) Has been cancelled
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>
2026-06-21 15:42:57 +10:00
Dan Milne
020759bfb3 Fix invalid require-trusted-types-for CSP directive
require-trusted-types-for only accepts 'script'; emitting 'none'
produced an invalid directive that browsers rejected. Omit the
directive entirely to leave Trusted Types unenforced (needed for
WebAuthn). Bump version to 0.16.1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 15:39:35 +10:00
Dan Milne
85f50bfc96 Add GitHub Actions workflow to build and publish image to GHCR
Builds the production Docker image and pushes it to
ghcr.io/dkam/clinch on pushes to main (edge + sha tags) and on v*
release tags (vX.Y.Z, vX.Y, latest). amd64 only, with GHA layer caching.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 14:02:29 +10:00
Dan Milne
b55139eb1c Fix Sentry config to use Sentry.init API
Some checks failed
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
The Sentry setup used a config.sentry.* accessor that sentry-rails has
never provided, so booting with SENTRY_DSN set raised NoMethodError
during environment load (e.g. db:prepare). The code only ran once a DSN
was configured, which is why it surfaced in production now.

Rewrites config/initializers/sentry.rb to call Sentry.init, the actual
sentry-ruby API, and removes the duplicate broken block from
production.rb. Verified production boots with SENTRY_DSN set
(Sentry.initialized? == true) and that the no-DSN path still early-returns.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 13:57:26 +10:00
Dan Milne
8f578ed3f4 Upgrade Ruby to 4.0.5
Some checks failed
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
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 13:51:28 +10:00
Dan Milne
aa5736ddab Update gems and fix lint to clear CI failures
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>
2026-06-21 13:51:23 +10:00
Dan Milne
49068aa344 Add tests
Some checks failed
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
2026-06-15 08:22:23 +10:00
Dan Milne
07ea031b61 Remove hardcoded internal IP from production hosts allowlist
Some checks failed
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
192.168.2.246 was redundant with the 192.168.0.0/16 regex already in the
CLINCH_ALLOW_INTERNAL_IPS block, and baked a specific lab IP into the repo.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 23:55:02 +10:00
Dan Milne
209c5496d8 Fix asset precompile boot and bump version to 0.16.0
Some checks failed
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
The CLINCH_HOST initializer raised during `assets:precompile` in the
Docker build, where no real host is set. Skip the check when
SECRET_KEY_BASE_DUMMY is present (the build-time precompile step);
deployed boots still require CLINCH_HOST.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 23:53:09 +10:00
Dan Milne
d49e7ce4f5 Move CSP to nonces; remove unsafe-inline from script-src and style-src
unsafe-inline on script-src neutered CSP as an XSS defense on the login and OAuth
consent pages (the highest-value targets in an IdP). Switch to a per-response
nonce for both script-src and style-src and drop unsafe-inline entirely.

- Add a random per-response nonce generator and apply it to script-src/style-src.
- Remove :unsafe_inline from both directives.
- Nonce the one hand-written inline script (dark-mode detection in the layout).
- Convert the 2 static style="display:none" attributes to class="hidden" (their
  runtime toggle is done via element.style in JS, which CSP does not govern).

importmap-rails (2.2.3) already stamps the nonce onto its generated inline
importmap/module/preload tags, and Turbo (2.0.23) reads csp_meta_tag for its
injected <style>, so no other view changes were needed. Adds an integration test
asserting the enforcing header carries nonces, omits unsafe-inline, and that the
inline script's nonce matches the header.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 20:42:28 +10:00
Dan Milne
44892e3301 Make WebAuthn clone detection actually block, and fix false positives
Two problems with sign-count clone detection:

- suspicious_sign_count? flagged the case where both the stored and presented
  counts are 0. Most synced passkeys (Apple/Google) report 0 every time, so every
  legitimate sign-in was flagged — drowning real signals in noise. Per WebAuthn
  §6.1.1 a 0 counter means "no counter"; only flag when BOTH counts are non-zero
  and the new one does not advance.

- On a suspicious count the controller only logged a warning and then continued
  to authenticate and overwrite the stored counter. A cloned credential therefore
  worked indefinitely. webauthn_verify now rejects the sign-in (no session, no
  counter update) and emails the user via a new SecurityMailer#suspicious_passkey_used.

Tests cover the corrected classification (synced/first-use/normal vs equal/
decreasing) and the new alert email.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 20:28:38 +10:00
Dan Milne
24266872f9 Revoke access tokens too on refresh-token reuse detection
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>
2026-06-11 20:23:17 +10:00
Dan Milne
cd862c7cd7 Filter code params from logs (TOTP, backup, OAuth code, PKCE)
The TOTP and backup-code form field is named `code`, which was not covered by
the filter list, so live one-time codes landed in production logs. Adding :code
(partial match) also redacts the OAuth authorization `code` and PKCE
`code_verifier`/`code_challenge`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 20:21:41 +10:00
Dan Milne
89bd5f1432 Enforce account-active status across the auth lifecycle
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>
2026-06-11 19:53:50 +10:00
Dan Milne
57d7d1f691 Anchor host-authorization regex to prevent look-alike domain bypass
The DNS-rebinding allowlist used /.*#{registrable_domain}/, which is unanchored:
for example.com it also matched evil-example.com, notexample.com,
example.computer, and example.com.attacker.com. Any of those hosts would pass
Rails' HostAuthorization middleware.

Anchor the pattern as /\A(.+\.)?DOMAIN\z/i so it matches only the registrable
domain and its subdomains (now also case-insensitively). Verified against a
real production boot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 19:47:35 +10:00
Dan Milne
406a79d9eb Block SSRF via backchannel_logout_uri
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>
2026-06-11 08:14:45 +10:00
Dan Milne
f38ac2ecc8 Prevent TOTP code replay within the drift window
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>
2026-06-11 08:10:34 +10:00
Dan Milne
84ed462f40 Require CLINCH_HOST in deployed environments; drop request-host fallback
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>
2026-06-11 08:04:42 +10:00
Dan Milne
96a657e349 Validate X-Forwarded-Host before using it as a post-login redirect target
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>
2026-06-11 08:00:12 +10:00
Dan Milne
8a095e4939 Enforce group access on Bearer API key forward-auth at use-time
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>
2026-06-11 07:54:48 +10:00
Dan Milne
703d24e4e4 Fix ForwardAuth fail-open and consent CSRF bypass
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>
2026-06-11 07:52:56 +10:00
Dan Milne
2843790cef Apps index access column + summary + admin access checker
Some checks failed
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
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>
2026-06-07 18:38:56 +10:00
Dan Milne
0e9ec71013 Link the user show page from the admin users index
Some checks failed
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
The admin users index only exposed Edit / Delete actions per row, so
the Accessible-applications panel on the user show page was unreachable
without typing the URL by hand. Adds a View action and turns the email
into a link to the show page — mirroring how the applications and
groups indexes already work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 18:26:55 +10:00
Dan Milne
fe68f6e81e Use Tailwind dark: toggles for dark-mode icons
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>
2026-06-07 17:19:36 +10:00
Dan Milne
c5ab7dc2a5 Compact icon uploader shared between light and dark icon fields
Some checks failed
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
Extracts the icon dropzone into a reusable partial so the dark mode
icon gets the same upload / drag-and-drop / paste affordances as the
light icon. Slims the dropzone to a single-row layout (small cloud
icon plus Upload / drag-and-drop / paste hint) and a tiny format hint
below, instead of the previous tall vertically-centred block.
2026-06-07 17:13:52 +10:00
Dan Milne
bfad9c4e9d Generated monogram fallback + optional dark-mode icon per application
Some checks failed
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
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>
2026-06-07 17:02:53 +10:00
Dan Milne
5b41db2c6a Fix FileNotFoundError when uploading an SVG icon
Some checks failed
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
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>
2026-06-07 16:43:24 +10:00
Dan Milne
03dfdbd83a Default-deny access control with group flags and access enumeration
Some checks failed
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
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>
2026-06-07 15:53:27 +10:00
Dan Milne
6b58b685c4 Bump version to 0.12.0
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 21:18:35 +10:00
Dan Milne
a399907dfd Allow assigning applications to a group from the group form
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>
2026-05-28 21:17:43 +10:00
Dan Milne
bbfb564e1c Show Clinch, Rails and Ruby versions in sidebar footer; bump to 0.11.0
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 23:39:11 +10:00
Dan Milne
9663110938 Bump version to 0.10.2
Some checks failed
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
2026-05-26 18:32:25 +10:00
Dan Milne
0bca1d2bac Allow OAuth redirect_uri host in form-action CSP on sign-in pages
Safari enforces form-action against every hop in a form submission's
redirect chain. When a user signed in (with TOTP, or through a
skip_consent OIDC app), the chain /signin or /totp-verification ->
/oauth/authorize -> external client got blocked at the cross-origin
hop because form-action was 'self'. The existing dynamic CSP widening
in OidcController#authorize only ran when the consent page rendered,
so skip_consent and pre-consented flows had no widening at all.

Add allow_oauth_redirect_in_csp on the sign-in and TOTP pages, which
pulls the OAuth redirect_uri out of session[:return_to_after_authenticating]
and appends its host to form-action for the rendered page.
2026-05-23 11:03:32 +10:00
83 changed files with 2138 additions and 479 deletions

133
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,133 @@
name: Build and publish image
# Publishes the multi-arch image (amd64 + arm64) to GitHub Packages
# (ghcr.io/dkam/clinch) whenever config/initializers/version.rb changes on
# main — a version bump IS the release. Each arch builds natively (no QEMU); a
# merge job stitches them into one manifest tagged :vX.Y.Z (+ :latest for
# non-pre-releases).
#
# To cut a release: edit Clinch::VERSION in config/initializers/version.rb,
# commit, push. For a dev build: set a pre-release version (e.g. "1.1.0-dev") —
# it publishes :v1.1.0-dev but does not move :latest. Or run this workflow
# manually from the Actions tab.
on:
push:
branches: [ main ]
paths:
- config/initializers/version.rb
workflow_dispatch:
env:
IMAGE: ghcr.io/${{ github.repository }}
jobs:
# Read the SemVer constant; decide whether this release moves :latest.
prepare:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
latest: ${{ steps.version.outputs.latest }}
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Read version from config/initializers/version.rb
id: version
run: |
V=$(ruby -e "require './config/initializers/version'; puts Clinch::VERSION")
echo "version=$V" >> "$GITHUB_OUTPUT"
# A pre-release (e.g. 1.1.0-dev) publishes its own tag but not :latest.
if [[ "$V" == *-* ]]; then latest=false; else latest=true; fi
echo "latest=$latest" >> "$GITHUB_OUTPUT"
echo "Building v$V (move :latest = $latest)"
build:
needs: prepare
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
arch: amd64
runner: ubuntu-latest
- platform: linux/arm64
arch: arm64
runner: ubuntu-24.04-arm
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v5
- 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: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
context: .
platforms: ${{ matrix.platform }}
cache-from: type=gha,scope=${{ matrix.arch }}
cache-to: type=gha,mode=max,scope=${{ matrix.arch }}
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ matrix.arch }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
merge:
needs: [prepare, build]
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create and push the multi-arch manifest
working-directory: /tmp/digests
run: |
tags="-t ${{ env.IMAGE }}:v${{ needs.prepare.outputs.version }}"
if [ "${{ needs.prepare.outputs.latest }}" = "true" ]; then
tags="$tags -t ${{ env.IMAGE }}:latest"
fi
docker buildx imagetools create $tags $(printf '${{ env.IMAGE }}@sha256:%s ' *)
- name: Inspect result
run: docker buildx imagetools inspect ${{ env.IMAGE }}:latest

3
.gitignore vendored
View File

@@ -70,3 +70,6 @@ yarn-debug.log*
# Ignore bootsnap cache
/tmp/cache/bootsnap*
# Local-only: do not publish the security findings tracker
SECURITY_REVIEW_TODO.md

View File

@@ -1 +1 @@
4.0.3
4.0.5

View File

@@ -8,7 +8,7 @@
# For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html
# Make sure RUBY_VERSION matches the Ruby version in .ruby-version
ARG RUBY_VERSION=4.0.3
ARG RUBY_VERSION=4.0.5
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
LABEL org.opencontainers.image.source=https://github.com/dkam/clinch

View File

@@ -1,7 +1,7 @@
GEM
remote: https://rubygems.org/
specs:
action_text-trix (2.1.18)
action_text-trix (2.1.19)
railties
actioncable (8.1.3)
actionpack (= 8.1.3)
@@ -85,9 +85,9 @@ GEM
bigdecimal (4.1.2)
bindata (2.5.1)
bindex (0.8.1)
bootsnap (1.24.1)
bootsnap (1.24.6)
msgpack (~> 1.2)
brakeman (8.0.4)
brakeman (8.0.5)
racc
builder (3.3.0)
bundler-audit (0.9.3)
@@ -102,11 +102,11 @@ GEM
rack-test (>= 0.6.3)
regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2)
cbor (0.5.10.2)
cbor (0.5.10.3)
childprocess (5.1.0)
logger (~> 1.5)
chunky_png (1.4.0)
concurrent-ruby (1.3.6)
concurrent-ruby (1.3.7)
connection_pool (3.0.2)
cose (1.3.1)
cbor (~> 0.5.9)
@@ -131,12 +131,12 @@ GEM
ffi (1.17.4-arm64-darwin)
ffi (1.17.4-x86_64-linux-gnu)
ffi (1.17.4-x86_64-linux-musl)
fugit (1.12.1)
fugit (1.12.2)
et-orbi (~> 1.4)
raabro (~> 1.4)
globalid (1.3.0)
activesupport (>= 6.1)
i18n (1.14.8)
i18n (1.15.2)
concurrent-ruby (~> 1.0)
image_processing (1.14.0)
mini_magick (>= 4.9.5, < 6)
@@ -151,13 +151,13 @@ GEM
prism (>= 1.3.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
jbuilder (2.14.1)
jbuilder (2.15.1)
actionview (>= 7.0.0)
activesupport (>= 7.0.0)
json (2.19.4)
jwt (3.1.2)
json (2.19.9)
jwt (3.2.0)
base64
kamal (2.11.0)
kamal (2.12.0)
activesupport (>= 7.0)
base64 (~> 0.2)
bcrypt_pbkdf (~> 1.0)
@@ -186,14 +186,14 @@ GEM
net-imap
net-pop
net-smtp
marcel (1.1.0)
marcel (1.2.1)
matrix (0.4.3)
mini_magick (5.3.1)
logger
mini_mime (1.1.5)
minitest (5.27.0)
msgpack (1.8.0)
net-imap (0.6.4)
msgpack (1.8.3)
net-imap (0.6.4.1)
date
net-protocol
net-pop (0.1.2)
@@ -208,25 +208,25 @@ GEM
net-protocol
net-ssh (7.3.2)
nio4r (2.7.5)
nokogiri (1.19.3-aarch64-linux-gnu)
nokogiri (1.19.4-aarch64-linux-gnu)
racc (~> 1.4)
nokogiri (1.19.3-aarch64-linux-musl)
nokogiri (1.19.4-aarch64-linux-musl)
racc (~> 1.4)
nokogiri (1.19.3-arm-linux-gnu)
nokogiri (1.19.4-arm-linux-gnu)
racc (~> 1.4)
nokogiri (1.19.3-arm-linux-musl)
nokogiri (1.19.4-arm-linux-musl)
racc (~> 1.4)
nokogiri (1.19.3-arm64-darwin)
nokogiri (1.19.4-arm64-darwin)
racc (~> 1.4)
nokogiri (1.19.3-x86_64-linux-gnu)
nokogiri (1.19.4-x86_64-linux-gnu)
racc (~> 1.4)
nokogiri (1.19.3-x86_64-linux-musl)
nokogiri (1.19.4-x86_64-linux-musl)
racc (~> 1.4)
openssl (4.0.1)
openssl (4.0.2)
openssl-signature_algorithm (1.3.0)
openssl (> 2.0)
ostruct (0.6.3)
parallel (1.28.0)
parallel (2.1.0)
parser (3.3.11.1)
ast (~> 2.4.1)
racc
@@ -238,11 +238,11 @@ GEM
actionpack (>= 7.0.0)
activesupport (>= 7.0.0)
rack
psych (5.3.1)
psych (5.4.0)
date
stringio
public_suffix (7.0.5)
puma (8.0.1)
puma (8.0.2)
nio4r (~> 2.0)
raabro (1.4.0)
racc (1.8.1)
@@ -299,11 +299,11 @@ GEM
chunky_png (~> 1.0)
rqrcode_core (~> 2.0)
rqrcode_core (2.1.0)
rubocop (1.84.2)
rubocop (1.87.0)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
parallel (~> 1.10)
parallel (>= 1.10)
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0)
@@ -321,20 +321,20 @@ GEM
ruby-vips (2.3.0)
ffi (~> 1.12)
logger
rubyzip (3.2.2)
rubyzip (3.4.0)
safety_net_attestation (0.5.0)
jwt (>= 2.0, < 4.0)
securerandom (0.4.1)
selenium-webdriver (4.43.0)
selenium-webdriver (4.45.0)
base64 (~> 0.2)
logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 4.0)
websocket (~> 1.0)
sentry-rails (6.5.0)
sentry-rails (6.6.2)
railties (>= 5.2.0)
sentry-ruby (~> 6.5.0)
sentry-ruby (6.5.0)
sentry-ruby (~> 6.6.2)
sentry-ruby (6.6.2)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
logger
@@ -344,7 +344,7 @@ GEM
simplecov_json_formatter (~> 0.1)
simplecov-html (0.13.2)
simplecov_json_formatter (0.1.4)
solid_cable (3.0.12)
solid_cable (4.0.0)
actioncable (>= 7.2)
activejob (>= 7.2)
activerecord (>= 7.2)
@@ -360,13 +360,13 @@ GEM
fugit (~> 1.11)
railties (>= 7.1)
thor (>= 1.3.1)
sqlite3 (2.9.3-aarch64-linux-gnu)
sqlite3 (2.9.3-aarch64-linux-musl)
sqlite3 (2.9.3-arm-linux-gnu)
sqlite3 (2.9.3-arm-linux-musl)
sqlite3 (2.9.3-arm64-darwin)
sqlite3 (2.9.3-x86_64-linux-gnu)
sqlite3 (2.9.3-x86_64-linux-musl)
sqlite3 (2.9.5-aarch64-linux-gnu)
sqlite3 (2.9.5-aarch64-linux-musl)
sqlite3 (2.9.5-arm-linux-gnu)
sqlite3 (2.9.5-arm-linux-musl)
sqlite3 (2.9.5-arm64-darwin)
sqlite3 (2.9.5-x86_64-linux-gnu)
sqlite3 (2.9.5-x86_64-linux-musl)
sshkit (1.25.0)
base64
logger
@@ -374,10 +374,10 @@ GEM
net-sftp (>= 2.1.2)
net-ssh (>= 2.8.0)
ostruct
standard (1.54.0)
standard (1.55.0)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.0)
rubocop (~> 1.84.0)
rubocop (~> 1.87.0)
standard-custom (~> 1.0.0)
standard-performance (~> 1.8)
standard-custom (1.0.2)
@@ -389,20 +389,20 @@ GEM
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringio (3.2.0)
tailwindcss-rails (4.4.0)
tailwindcss-rails (4.6.0)
railties (>= 7.0.0)
tailwindcss-ruby (~> 4.0)
tailwindcss-ruby (4.2.4)
tailwindcss-ruby (4.2.4-aarch64-linux-gnu)
tailwindcss-ruby (4.2.4-aarch64-linux-musl)
tailwindcss-ruby (4.2.4-arm64-darwin)
tailwindcss-ruby (4.2.4-x86_64-linux-gnu)
tailwindcss-ruby (4.2.4-x86_64-linux-musl)
tailwindcss-ruby (4.3.1)
tailwindcss-ruby (4.3.1-aarch64-linux-gnu)
tailwindcss-ruby (4.3.1-aarch64-linux-musl)
tailwindcss-ruby (4.3.1-arm64-darwin)
tailwindcss-ruby (4.3.1-x86_64-linux-gnu)
tailwindcss-ruby (4.3.1-x86_64-linux-musl)
thor (1.5.0)
thruster (0.1.20)
thruster (0.1.20-aarch64-linux)
thruster (0.1.20-arm64-darwin)
thruster (0.1.20-x86_64-linux)
thruster (0.1.21)
thruster (0.1.21-aarch64-linux)
thruster (0.1.21-arm64-darwin)
thruster (0.1.21-x86_64-linux)
timeout (0.6.1)
tpm-key_attestation (0.14.1)
bindata (~> 2.4)
@@ -432,13 +432,13 @@ GEM
safety_net_attestation (~> 0.5.0)
tpm-key_attestation (~> 0.14.0)
websocket (1.2.11)
websocket-driver (0.8.0)
websocket-driver (0.8.1)
base64
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
xpath (3.2.0)
nokogiri (~> 1.8)
zeitwerk (2.7.5)
zeitwerk (2.8.2)
PLATFORMS
aarch64-linux

View File

@@ -0,0 +1,20 @@
module Admin
class AccessChecksController < BaseController
def new
load_options
@user = User.find_by(id: params[:user_id])
@application = Application.find_by(id: params[:application_id])
return unless @user && @application
@allowed = @application.user_allowed?(@user)
@via = @user.groups & @application.allowed_groups
end
private
def load_options
@users = User.order(:email_address)
@applications = Application.order(:name)
end
end
end

View File

@@ -3,11 +3,31 @@ module Admin
before_action :set_application, only: [:show, :edit, :update, :destroy, :regenerate_credentials]
def index
@applications = Application.order(created_at: :desc)
@applications = Application.order(created_at: :desc).includes(:allowed_groups)
# Distinct active users that have access to each app, preloaded to avoid N+1.
@user_count_by_app = User.where(status: User.statuses[:active])
.joins(groups: :applications)
.group("applications.id")
.distinct
.count("users.id")
# Top-of-page summary
@total_users_with_access = User.where(status: User.statuses[:active])
.joins(groups: :applications)
.distinct
.count("users.id")
@total_groups_granting_access = Group.joins(:applications).distinct.count
end
def show
@allowed_groups = @application.allowed_groups
@users_with_access = User.where(status: User.statuses[:active])
.joins(groups: :applications)
.where(applications: {id: @application.id})
.distinct
.includes(:groups)
.order(:email_address)
end
def new
@@ -104,7 +124,7 @@ module Admin
permitted = params.require(:application).permit(
:name, :slug, :app_type, :active, :redirect_uris, :description, :metadata,
:domain_pattern, :landing_url, :access_token_ttl, :refresh_token_ttl, :id_token_ttl,
:icon, :backchannel_logout_uri, :is_public_client, :require_pkce, :skip_consent
:icon, :icon_dark, :backchannel_logout_uri, :is_public_client, :require_pkce, :skip_consent
)
# Handle headers_config - it comes as a JSON string from the text area

View File

@@ -15,6 +15,7 @@ module Admin
def new
@group = Group.new
@available_users = User.order(:email_address)
@available_applications = Application.order(:name)
end
def create
@@ -28,6 +29,7 @@ module Admin
@group = Group.new
@group.errors.add(:custom_claims, "must be valid JSON")
@available_users = User.order(:email_address)
@available_applications = Application.order(:name)
render :new, status: :unprocessable_entity
return
end
@@ -45,15 +47,23 @@ module Admin
@group.users = User.where(id: user_ids)
end
# Handle application assignments
if params[:group][:application_ids].present?
application_ids = params[:group][:application_ids].reject(&:blank?)
@group.applications = Application.where(id: application_ids)
end
redirect_to admin_group_path(@group), notice: "Group created successfully."
else
@available_users = User.order(:email_address)
@available_applications = Application.order(:name)
render :new, status: :unprocessable_entity
end
end
def edit
@available_users = User.order(:email_address)
@available_applications = Application.order(:name)
end
def update
@@ -66,6 +76,7 @@ module Admin
rescue JSON::ParserError
@group.errors.add(:custom_claims, "must be valid JSON")
@available_users = User.order(:email_address)
@available_applications = Application.order(:name)
render :edit, status: :unprocessable_entity
return
end
@@ -83,9 +94,18 @@ module Admin
@group.users = []
end
# Handle application assignments
if params[:group][:application_ids].present?
application_ids = params[:group][:application_ids].reject(&:blank?)
@group.applications = Application.where(id: application_ids)
else
@group.applications = []
end
redirect_to admin_group_path(@group), notice: "Group updated successfully."
else
@available_users = User.order(:email_address)
@available_applications = Application.order(:name)
render :edit, status: :unprocessable_entity
end
end
@@ -102,7 +122,7 @@ module Admin
end
def group_params
params.require(:group).permit(:name, :description, :custom_claims)
params.require(:group).permit(:name, :description, :custom_claims, :auto_assign, :admin)
end
end
end

View File

@@ -7,27 +7,38 @@ module Admin
end
def show
@accessible_applications = Application.where(active: true)
.joins(:allowed_groups)
.where(groups: {id: @user.groups})
.distinct
.includes(:allowed_groups)
.order(:name)
end
def new
@user = User.new
@available_groups = Group.order(:name)
end
def create
@user = User.new(user_params)
@user.password = SecureRandom.alphanumeric(16) if user_params[:password].blank?
@user.status = :pending_invitation
@user.skip_auto_assign = true if params[:auto_assign] == "0"
if @user.save
assign_groups_from_params(@user)
InvitationsMailer.invite_user(@user).deliver_later
redirect_to admin_users_path, notice: "User created successfully. Invitation email sent to #{@user.email_address}."
else
@available_groups = Group.order(:name)
render :new, status: :unprocessable_entity
end
end
def edit
@applications = Application.active.order(:name)
@available_groups = Group.order(:name)
end
def update
@@ -43,6 +54,7 @@ module Admin
rescue JSON::ParserError
@user.errors.add(:custom_claims, "must be valid JSON")
@applications = Application.active.order(:name)
@available_groups = Group.order(:name)
render :edit, status: :unprocessable_entity
return
end
@@ -52,9 +64,16 @@ module Admin
end
if @user.update(update_params)
unless assign_groups_from_params(@user)
@applications = Application.active.order(:name)
@available_groups = Group.order(:name)
render :edit, status: :unprocessable_entity
return
end
redirect_to admin_users_path, notice: "User updated successfully."
else
@applications = Application.active.order(:name)
@available_groups = Group.order(:name)
render :edit, status: :unprocessable_entity
end
end
@@ -122,14 +141,28 @@ module Admin
end
def user_params
permitted = [:email_address, :username, :name, :password, :status, :totp_required, :custom_claims]
# Only allow modifying admin status when editing other users (prevent self-demotion)
if params[:id] != Current.session.user.id.to_s
permitted << :admin
params.require(:user).permit(:email_address, :username, :name, :password, :status, :totp_required, :custom_claims)
end
params.require(:user).permit(*permitted)
# Apply group_ids from the form, with a guard preventing self-demotion when
# the user is the last member of the last admin group. Returns true on
# success, false if a guard fired (caller should re-render).
def assign_groups_from_params(user)
return true unless params[:user].key?(:group_ids)
raw_ids = Array(params[:user][:group_ids]).reject(&:blank?).map(&:to_i)
new_groups = Group.where(id: raw_ids)
if user == Current.session.user
losing_admin = user.groups.where(admin: true).any? && new_groups.none?(&:admin?)
if losing_admin
user.errors.add(:base, "you cannot remove yourself from all administrator groups")
return false
end
end
user.groups = new_groups
true
end
end
end

View File

@@ -64,26 +64,16 @@ module Api
return render_forbidden("No authentication rule configured for this domain")
end
else
Rails.logger.info "ForwardAuth: User #{user.email_address} authenticated (no domain specified)"
end
headers = if app
app.headers_for_user(user)
else
Application::DEFAULT_HEADERS.map { |key, header_name|
case key
when :user, :email, :name
[header_name, user.email_address]
when :username
[header_name, user.username] if user.username.present?
when :groups
user.groups.any? ? [header_name, user.groups.map(&:name).join(",")] : nil
when :admin
[header_name, user.admin? ? "true" : "false"]
end
}.compact.to_h
# Fail closed: with no host we cannot resolve an application or evaluate its
# group policy. Emitting identity headers here would bypass all per-domain
# access control, so reject instead.
Rails.logger.info "ForwardAuth: Access denied - no host header present"
return render_forbidden("No host header present")
end
# Reaching here implies a matching, active application was resolved above
# (every other path returns forbidden), so headers are always scoped to it.
headers = app.headers_for_user(user)
headers.each { |key, value| response.headers[key] = value }
Rails.logger.debug "ForwardAuth: Headers sent: #{headers.keys.join(", ")}" if headers.any?
@@ -148,6 +138,14 @@ module Api
return render_bearer_error("Application is inactive")
end
# Re-check group membership at use-time. The ApiKey model only validates
# access on creation, so a user removed from the app's allowed groups
# afterwards must not keep access via an existing key.
unless app.user_allowed?(user)
Rails.logger.info "ForwardAuth: API key '#{api_key.name}' denied - user #{user.email_address} lacks group access to #{app.domain_pattern}"
return render_bearer_error("Access denied: insufficient group membership")
end
api_key.touch_last_used!
headers = app.headers_for_user(user)
@@ -198,11 +196,14 @@ module Api
original_host = request.headers["X-Forwarded-Host"]
original_uri = request.headers["X-Forwarded-Uri"] || request.headers["X-Forwarded-Path"] || "/"
original_url = if original_host
"https://#{original_host}#{original_uri}"
else
redirect_url || base_url
end
# X-Forwarded-Host is attacker-influenceable, so only honour the forwarded
# URL as a post-login redirect target if it resolves to a known, active
# forward-auth application. Otherwise this is an open redirect: a spoofed
# host would be stored and reflected into the signin `rd`, then followed
# (with allow_other_host) after the user authenticates. Fall back to a
# validated `rd` or, failing that, the IdP's own base URL.
forwarded_url = "https://#{original_host}#{original_uri}" if original_host.present?
original_url = validate_redirect_url(forwarded_url) || redirect_url || base_url
session[:return_to_after_authenticating] = original_url
@@ -242,18 +243,13 @@ module Api
def determine_base_url(redirect_url)
return redirect_url if redirect_url.present?
if ENV["CLINCH_HOST"].present?
host = ENV["CLINCH_HOST"]
host.match?(/^https?:\/\//) ? host : "https://#{host}"
else
request_host = request.host || request.headers["X-Forwarded-Host"]
if request_host.present?
Rails.logger.warn "ForwardAuth: CLINCH_HOST not set, using request host: #{request_host}"
"https://#{request_host}"
else
raise StandardError, "ForwardAuth: CLINCH_HOST environment variable not set and no request host available."
end
end
# CLINCH_HOST is the IdP's canonical origin and is mandatory in deployed
# environments (enforced at boot in config/initializers/clinch_host.rb).
# We never fall back to the request host: a spoofed X-Forwarded-Host would
# otherwise redirect the login flow to an attacker-controlled origin. The
# localhost default only applies to local dev/test.
host = ENV["CLINCH_HOST"].presence || "http://localhost:3000"
host.match?(%r{\Ahttps?://}) ? host : "https://#{host}"
end
end
end

View File

@@ -31,7 +31,7 @@ module Authentication
end
def find_session_by_cookie
Session.active.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id]
Session.active.for_active_user.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id]
end
def request_authentication
@@ -43,6 +43,37 @@ module Authentication
session.delete(:return_to_after_authenticating) || root_url
end
# When a sign-in form will eventually redirect through /oauth/authorize to an
# external client, Safari enforces CSP form-action against every hop in the
# redirect chain. With the default form-action 'self', the final cross-origin
# hop to the OAuth client's redirect_uri gets blocked. Add the redirect_uri
# host to form-action so the chain completes.
def allow_oauth_redirect_in_csp
stored = session[:return_to_after_authenticating]
return if stored.blank?
uri = URI.parse(stored)
return unless uri.path&.start_with?("/oauth/")
redirect_uri = Rack::Utils.parse_query(uri.query.to_s)["redirect_uri"]
return if redirect_uri.blank?
redirect_host = URI.parse(redirect_uri).host
return if redirect_host.blank?
csp = request.content_security_policy
return unless csp
# NOTE: `csp.form_action` (no args) is destructive — it deletes the directive
# and returns its old value, so reading it twice yields nil. Mutate the
# underlying `directives` hash (a public reader of the real values) instead.
form_action = (csp.directives["form-action"] ||= ["'self'"])
host = "https://#{redirect_host}"
form_action << host unless form_action.include?(host)
rescue URI::InvalidURIError
nil
end
def start_new_session_for(user, acr: "1", remember_me: false)
user.update!(last_sign_in_at: Time.current)
user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip, acr: acr, remember_me: remember_me).tap do |session|

View File

@@ -4,7 +4,11 @@ class OidcController < ApplicationController
# Discovery and JWKS endpoints are public
# authorize is also unauthenticated to handle prompt=none and prompt=login specially
allow_unauthenticated_access only: [:discovery, :jwks, :token, :revoke, :userinfo, :logout, :authorize]
skip_before_action :verify_authenticity_token, only: [:token, :revoke, :userinfo, :logout, :authorize, :consent]
# Machine-to-machine endpoints (token/revoke/userinfo) and pure redirect handlers
# (logout/authorize) legitimately skip CSRF. The consent endpoint is browser-facing
# and state-changing (it grants OAuth scopes), so it MUST keep CSRF protection — the
# consent form already embeds the token via form_with.
skip_before_action :verify_authenticity_token, only: [:token, :revoke, :userinfo, :logout, :authorize]
# 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.

View File

@@ -28,6 +28,8 @@ class SessionsController < ApplicationController
end
end
allow_oauth_redirect_in_csp
respond_to do |format|
format.html # render HTML login page
format.json { render json: {error: "Authentication required"}, status: :unauthorized }
@@ -119,6 +121,16 @@ class SessionsController < ApplicationController
return
end
# Re-check account status: active? was verified at the password step, but an
# admin may have disabled the account while the user sat on this 2FA screen.
# Without this, a disabled account could still mint a valid session here.
unless user.active?
session.delete(:pending_totp_user_id)
session.delete(:pending_remember_me)
redirect_to signin_path, alert: "Your account is not active. Please contact an administrator."
return
end
remember_me = session.delete(:pending_remember_me) || false
# Try TOTP verification first (password + TOTP = 2FA)
@@ -154,6 +166,8 @@ class SessionsController < ApplicationController
@user_has_webauthn = user&.can_authenticate_with_webauthn?
@pending_email = user&.email_address
allow_oauth_redirect_in_csp
# Just render the form
end
@@ -237,6 +251,14 @@ class SessionsController < ApplicationController
return
end
# Re-check account status: an admin may have disabled the account between the
# password step and this passkey verification. Reject before creating a session.
unless user.active?
session.delete(:pending_webauthn_user_id)
render json: {error: "Your account is not active."}, status: :unauthorized
return
end
# Get the credential and assertion from params
credential_data = params[:credential]
if credential_data.blank?
@@ -273,10 +295,14 @@ class SessionsController < ApplicationController
sign_count: stored_credential.sign_count
)
# Check for suspicious sign count (possible clone)
# Clone detection: a non-advancing signature counter signals the credential
# may have been copied. Reject the sign-in (do NOT create a session or update
# the stored counter) and alert the user, per WebAuthn §6.1.1.
if stored_credential.suspicious_sign_count?(webauthn_credential.sign_count)
Rails.logger.warn "Suspicious WebAuthn sign count for user #{user.id}, credential #{stored_credential.id}"
# You might want to notify admins or temporarily disable the credential
Rails.logger.warn "Suspicious WebAuthn sign count for user #{user.id}, credential #{stored_credential.id} (stored=#{stored_credential.sign_count}, presented=#{webauthn_credential.sign_count})"
SecurityMailer.suspicious_passkey_used(user, nickname: stored_credential.display_name, **security_event_context).deliver_later
render json: {error: "Passkey authentication could not be completed. Please contact support."}, status: :unprocessable_entity
return
end
# Update credential usage

View File

@@ -8,12 +8,16 @@ class UsersController < ApplicationController
def create
@user = User.new(user_params)
# First user becomes admin automatically
@user.admin = true if User.count.zero?
@user.status = "active"
first_user = User.count.zero?
if @user.save
# First user automatically becomes a member of every admin group, so they
# can reach the admin panel without an existing admin to grant access.
if first_user
Group.where(admin: true).each { |g| @user.groups << g }
end
start_new_session_for @user
redirect_to root_path, notice: "Welcome to Clinch! Your account has been created."
else

View File

@@ -31,7 +31,7 @@ module ApplicationHelper
end
lines << "OIDC_DISCOVERY_URL=#{OidcJwtService.issuer_url}"
lines << "OIDC_PROVIDER_NAME='Clinch'"
lines << "OIDC_REQUIRE_PKCE=#{application.requires_pkce? ? 'true' : 'false'}"
lines << "OIDC_REQUIRE_PKCE=#{application.requires_pkce? ? "true" : "false"}"
lines
end
@@ -44,4 +44,49 @@ module ApplicationHelper
else "border-gray-200 dark:border-gray-700"
end
end
# Picks 1-2 character initials for a monogram fallback when an Application
# has no icon. Prefers capital letters (ShelfLife -> SL); falls back to the
# first two letters of the name (Audiobookshelf -> AU).
MONOGRAM_PALETTE = %w[
#4f46e5 #0891b2 #16a34a #ca8a04
#db2777 #9333ea #ea580c #475569
].freeze
def monogram_initials(name)
return "?" if name.blank?
caps = name.scan(/[A-Z]/)
initials = if caps.size >= 2
caps.first(2).join
else
name.upcase.gsub(/[^A-Z0-9]/, "").first(2)
end
initials.presence || "?"
end
def monogram_color(name)
return MONOGRAM_PALETTE.first if name.blank?
index = Digest::MD5.hexdigest(name).to_i(16) % MONOGRAM_PALETTE.size
MONOGRAM_PALETTE[index]
end
# Renders an application icon with optional dark-mode variant. If
# `icon_dark` is attached, we render both <img> tags and Tailwind's class-
# based `dark:` modifier hides the inactive one — so it follows the in-app
# theme toggle (.dark on <html>), not the OS preference. If only `icon` is
# attached, the same image is used in both modes. Caller must ensure at
# least app.icon is attached; the monogram fallback handles no-icon.
def app_icon_picture(app, class:, alt: nil)
img_class = binding.local_variable_get(:class)
alt ||= "#{app.name} icon"
if app.icon_dark.attached?
safe_join([
image_tag(app.icon, class: "#{img_class} dark:hidden", alt: alt),
image_tag(app.icon_dark, class: "#{img_class} hidden dark:block", alt: alt)
])
else
image_tag(app.icon, class: img_class, alt: alt)
end
end
end

View File

@@ -28,6 +28,14 @@ class BackchannelLogoutJob < ApplicationJob
# Send HTTP POST to the application's backchannel logout URI
uri = URI.parse(application.backchannel_logout_uri)
# SSRF guard: re-check at request time (with DNS resolution) in case the URI
# predates the validation, or a public hostname now resolves to an internal
# address. Abort without retrying — retries would not change the outcome.
if PrivateAddressCheck.internal_host?(uri.host) || PrivateAddressCheck.resolves_to_internal?(uri.host)
Rails.logger.error "BackchannelLogout: Refusing to send logout to #{application.name} - #{uri.host} is or resolves to a non-public address (SSRF guard)"
return
end
begin
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 5, read_timeout: 5) do |http|
request = Net::HTTP::Post.new(uri.path.presence || "/")

View File

@@ -0,0 +1,57 @@
require "ipaddr"
require "resolv"
# SSRF guard for outbound requests to admin-configured URLs (currently the OIDC
# backchannel logout endpoint). Blocks hosts that are, or resolve to, private,
# loopback, link-local (incl. the cloud metadata address 169.254.169.254) or
# otherwise non-public address space.
module PrivateAddressCheck
module_function
# Hostnames that are internal by definition and must never be dialled.
BLOCKED_HOSTNAMES = %w[localhost metadata.google.internal].freeze
# Fast, DNS-free check: catches IP literals and well-known internal hostnames.
# Suitable for model validation (deterministic, immediate admin feedback).
def internal_host?(host)
host = host.to_s.downcase
return true if host.blank?
return true if BLOCKED_HOSTNAMES.include?(host)
return true if host.end_with?(".localhost")
ip = parse_ip(host)
ip ? internal_ip?(ip) : false
end
# Authoritative check: resolves the hostname and blocks if ANY address is
# internal. Suitable for request time — also defeats a public hostname that
# has been pointed at an internal IP (DNS rebinding to internal space).
def resolves_to_internal?(host)
addresses(host).any? { |ip| internal_ip?(ip) }
end
def addresses(host)
ip = parse_ip(host)
return [ip] if ip
Resolv.getaddresses(host.to_s).filter_map { |a| parse_ip(a) }
rescue
# Resolution failure: surface no addresses. Callers treat "can't resolve" as
# not-provably-internal; the dial itself will then fail safely.
[]
end
def internal_ip?(ip)
ip.loopback? || ip.private? || ip.link_local? || unspecified?(ip)
end
def parse_ip(str)
IPAddr.new(str.to_s)
rescue IPAddr::Error
nil
end
def unspecified?(ip)
ip == IPAddr.new("0.0.0.0") || ip == IPAddr.new("::")
end
end

View File

@@ -40,6 +40,12 @@ class SecurityMailer < ApplicationMailer
mail subject: "#{SUBJECT_PREFIX}An API key was revoked on your account", to: user.email_address
end
def suspicious_passkey_used(user, nickname:, ip:, user_agent:, occurred_at:)
assign_context(user, ip, user_agent, occurred_at)
@nickname = nickname
mail subject: "#{SUBJECT_PREFIX}A passkey sign-in was blocked", to: user.email_address
end
def email_address_changed(user, recipient:, old_email:, new_email:, ip:, user_agent:, occurred_at:)
assign_context(user, ip, user_agent, occurred_at)
@recipient = recipient

View File

@@ -25,9 +25,12 @@ class Application < ApplicationRecord
after_commit :bust_forward_auth_cache, if: :forward_auth?
has_one_attached :icon
has_one_attached :icon_dark
before_validation :sanitize_svg_icon, if: -> { attachment_changes["icon"].present? }
after_save :fix_icon_content_type, if: -> { icon.attached? && saved_change_to_attribute?(:id) == false }
ICON_ATTACHMENTS = %i[icon icon_dark].freeze
before_validation :sanitize_svg_icons
after_save :fix_icon_content_types
has_many :application_groups, dependent: :destroy
has_many :allowed_groups, through: :application_groups, source: :group
@@ -53,9 +56,10 @@ class Application < ApplicationRecord
message: "must be a valid HTTP or HTTPS URL"
}
validate :backchannel_logout_uri_must_be_https_in_production, if: -> { backchannel_logout_uri.present? }
validate :backchannel_logout_uri_not_internal, if: -> { backchannel_logout_uri.present? }
# Icon validation using ActiveStorage validators
validate :icon_validation, if: -> { icon.attached? }
validate :icon_validation
# Token TTL validations (for OIDC apps)
validates :access_token_ttl, numericality: {greater_than_or_equal_to: 300, less_than_or_equal_to: 86400}, if: :oidc? # 5 min - 24 hours
@@ -118,14 +122,12 @@ class Application < ApplicationRecord
end
# Access control
# Default-deny: an empty allowed_groups list means no one gets in.
# To make an app accessible to "everyone", attach the seeded auto-assign
# group (or any group every user is in).
def user_allowed?(user)
return false unless active?
return false unless user.active?
# If no groups are specified, allow all active users
return true if allowed_groups.empty?
# Otherwise, user must be in at least one of the allowed groups
(user.groups & allowed_groups).any?
end
@@ -168,10 +170,6 @@ class Application < ApplicationRecord
return "deny" unless active?
return "deny" unless user.active?
# If no groups specified, bypass authentication
return "bypass" if allowed_groups.empty?
# If user is in allowed groups, determine auth level
if user_allowed?(user)
# Require 2FA if user has TOTP configured, otherwise one factor
user.totp_enabled? ? "two_factor" : "one_factor"
@@ -274,42 +272,84 @@ class Application < ApplicationRecord
Rails.application.config.forward_auth_cache&.delete("fa_apps")
end
def fix_icon_content_type
return unless icon.attached?
def fix_icon_content_types
ICON_ATTACHMENTS.each do |attr|
attachment = public_send(attr)
next unless attachment.attached?
# Fix SVG content type if it was detected incorrectly
if icon.filename.extension == "svg" && icon.content_type == "application/octet-stream"
icon.blob.update(content_type: "image/svg+xml")
if attachment.filename.extension == "svg" && attachment.content_type == "application/octet-stream"
attachment.blob.update(content_type: "image/svg+xml")
end
end
end
def sanitize_svg_icon
return unless icon.content_type == "image/svg+xml"
def sanitize_svg_icons
# Runs in before_validation. The blob has NOT yet been uploaded to disk at
# this point (Active Storage uploads in before_save), so we cannot call
# download — we must read from the pending attachable.
#
# attach below re-sets attachment_changes and would re-fire this callback;
# we skip if the pending attachable is the cleaned hash we just installed
# (tracked by object identity, per-attribute).
@svg_sanitized_attachables ||= {}
ICON_ATTACHMENTS.each do |attr|
change = attachment_changes[attr.to_s]
next unless change
attachable = change.attachable
next if attachable.equal?(@svg_sanitized_attachables[attr])
raw_svg, filename, content_type = read_pending_icon(attachable)
next unless raw_svg
next unless content_type == "image/svg+xml" || filename.to_s.downcase.end_with?(".svg")
raw_svg = icon.download
doc = Loofah.xml_document(raw_svg)
doc.scrub!(SvgScrubber.new)
clean_svg = doc.to_xml
icon.attach(
sanitized = {
io: StringIO.new(clean_svg),
filename: icon.filename.to_s,
filename: filename,
content_type: "image/svg+xml"
)
}
@svg_sanitized_attachables[attr] = sanitized
public_send(attr).attach(sanitized)
end
end
def read_pending_icon(attachable)
case attachable
when ActionDispatch::Http::UploadedFile, Rack::Test::UploadedFile
content = attachable.read
attachable.rewind
[content, attachable.original_filename, attachable.content_type]
when Hash
io = attachable[:io] || attachable["io"]
return [nil, nil, nil] unless io
content = io.read
io.rewind if io.respond_to?(:rewind)
[content,
attachable[:filename] || attachable["filename"],
attachable[:content_type] || attachable["content_type"]]
else
[nil, nil, nil]
end
end
def icon_validation
return unless icon.attached?
# Check content type
allowed_types = ["image/png", "image/jpg", "image/jpeg", "image/gif", "image/svg+xml"]
unless allowed_types.include?(icon.content_type)
errors.add(:icon, "must be a PNG, JPG, GIF, or SVG image")
ICON_ATTACHMENTS.each do |attr|
attachment = public_send(attr)
next unless attachment.attached?
unless allowed_types.include?(attachment.content_type)
errors.add(attr, "must be a PNG, JPG, GIF, or SVG image")
end
# Check file size (2MB limit)
if icon.blob.byte_size > 2.megabytes
errors.add(:icon, "must be less than 2MB")
if attachment.blob.byte_size > 2.megabytes
errors.add(attr, "must be less than 2MB")
end
end
end
@@ -351,4 +391,17 @@ class Application < ApplicationRecord
# Let the format validator handle invalid URIs
end
end
# SSRF guard: the backchannel logout URI is dialled server-side on every user
# logout, so it must not target internal infrastructure (loopback, private
# ranges, or the link-local cloud metadata endpoint). This is the fast,
# config-time check; BackchannelLogoutJob re-checks with DNS resolution.
def backchannel_logout_uri_not_internal
uri = URI.parse(backchannel_logout_uri)
if uri.host.present? && PrivateAddressCheck.internal_host?(uri.host)
errors.add(:backchannel_logout_uri, "must not point to a private, loopback, or link-local address")
end
rescue URI::InvalidURIError
# Let the format validator handle invalid URIs
end
end

View File

@@ -15,6 +15,11 @@ class Group < ApplicationRecord
normalizes :name, with: ->(name) { name.strip.downcase }
validate :no_reserved_claim_names
scope :auto_assign, -> { where(auto_assign: true) }
scope :admin, -> { where(admin: true) }
before_destroy :ensure_other_admin_group_exists
# Parse custom_claims JSON field
def parsed_custom_claims
return {} if custom_claims.blank?
@@ -23,6 +28,13 @@ class Group < ApplicationRecord
private
def ensure_other_admin_group_exists
return unless admin?
return if Group.where(admin: true).where.not(id: id).exists?
errors.add(:base, "cannot delete the last administrators group")
throw :abort
end
def no_reserved_claim_names
return if custom_claims.blank?

View File

@@ -49,11 +49,21 @@ class OidcRefreshToken < ApplicationRecord
update!(revoked_at: Time.current)
end
# Revoke all refresh tokens in the same family (token rotation security)
# Revoke all refresh tokens in the same family (token rotation security).
# Also revoke every access token issued within the family: on a detected reuse
# attack the stolen chain's access tokens must not remain usable at /userinfo
# until they expire.
def revoke_family!
return unless token_family_id.present?
OidcRefreshToken.in_family(token_family_id).update_all(revoked_at: Time.current)
now = Time.current
family = OidcRefreshToken.in_family(token_family_id)
access_token_ids = family.pluck(:oidc_access_token_id).compact.uniq
family.update_all(revoked_at: now)
if access_token_ids.any?
OidcAccessToken.where(id: access_token_ids, revoked_at: nil).update_all(revoked_at: now)
end
end
private

View File

@@ -7,6 +7,9 @@ class Session < ApplicationRecord
# Scopes
scope :active, -> { where("expires_at > ?", Time.current) }
scope :expired, -> { where("expires_at <= ?", Time.current) }
# Sessions whose owning user is currently active. Used at request time so a
# disabled account cannot continue to authenticate with an existing session.
scope :for_active_user, -> { joins(:user).where(users: {status: User.statuses[:active]}) }
def expired?
expires_at.present? && expires_at <= Time.current

View File

@@ -41,8 +41,24 @@ class User < ApplicationRecord
# Enum - automatically creates scopes (User.active, User.disabled, etc.)
enum :status, {active: 0, disabled: 1, pending_invitation: 2}
# When an account stops being active (e.g. an admin disables it), immediately
# terminate its sessions so access is revoked everywhere, not just on expiry.
# Defence-in-depth: session lookup also filters by active status at request time.
after_update_commit :revoke_sessions_when_deactivated
# Scopes
scope :admins, -> { where(admin: true) }
scope :admins, -> { joins(:groups).where(groups: {admin: true}).distinct }
# Set true on a user (or on the user_params) to skip the auto-assign callback
# for that record. Used by the admin invite form (opt-out checkbox) and by
# tests that want a clean slate.
attr_accessor :skip_auto_assign
after_create :add_to_auto_assign_groups, unless: :skip_auto_assign
def admin?
groups.any?(&:admin?)
end
# TOTP methods
def totp_enabled?
@@ -52,7 +68,10 @@ class User < ApplicationRecord
def enable_totp!
require "rotp"
self.totp_secret = ROTP::Base32.random
self.backup_codes = generate_backup_codes
# generate_backup_codes assigns the BCrypt hashes to self.backup_codes and
# returns the plaintext codes for display. Do NOT reassign backup_codes to the
# return value — that would store the plaintext codes and break verification.
generate_backup_codes
save!
end
@@ -75,7 +94,13 @@ class User < ApplicationRecord
require "rotp"
totp = ROTP::TOTP.new(totp_secret)
totp.verify(code, drift_behind: 30, drift_ahead: 30)
# Pass `after:` so a code can only be accepted once: ROTP rejects any timestep
# at or before the last accepted one, closing the ~90s drift-window replay.
verified_at = totp.verify(code, drift_behind: 30, drift_ahead: 30, after: last_otp_at)
return false unless verified_at
update_column(:last_otp_at, verified_at)
true
end
# Console/debug helper: get current TOTP code
@@ -222,6 +247,17 @@ class User < ApplicationRecord
private
def add_to_auto_assign_groups
Group.auto_assign.each { |g| groups << g }
end
def revoke_sessions_when_deactivated
return unless saved_change_to_status?
return if active?
sessions.destroy_all
end
def no_reserved_claim_names
return if custom_claims.blank?

View File

@@ -52,13 +52,17 @@ class WebauthnCredential < ApplicationRecord
end
end
# Check if sign count is suspicious (clone detection)
# Check if sign count is suspicious (clone detection).
#
# Per WebAuthn §6.1.1, a signature counter of 0 means the authenticator does
# not implement a counter (true of most synced passkeys — Apple/Google report
# 0 every time), so it cannot be used for clone detection. Only when BOTH the
# stored and presented counts are non-zero does a non-increasing value signal
# a possible clone.
def suspicious_sign_count?(new_sign_count)
return false if sign_count.zero? && new_sign_count > 0 # First use
return false if new_sign_count > sign_count # Normal increment
return false if sign_count.zero? || new_sign_count.zero?
# Sign count didn't increase - possible clone
true
new_sign_count <= sign_count
end
# Format for display in UI

View File

@@ -0,0 +1,77 @@
<div class="mb-6">
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">Access check</h1>
<p class="mt-2 text-sm text-gray-700 dark:text-gray-300">Pick a user and an application to see whether the user can access it and, if so, which group(s) grant that access.</p>
</div>
<div class="bg-white dark:bg-gray-800 shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<%= form_with url: admin_access_path, method: :get, class: "space-y-4" do |form| %>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<%= form.label :user_id, "User", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<%= form.select :user_id,
@users.map { |u| [u.email_address, u.id] },
{ include_blank: "Select a user…", selected: @user&.id },
class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
</div>
<div>
<%= form.label :application_id, "Application", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<%= form.select :application_id,
@applications.map { |a| [a.name, a.id] },
{ include_blank: "Select an application…", selected: @application&.id },
class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
</div>
</div>
<div>
<%= form.submit "Check access", class: "rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %>
</div>
<% end %>
<% if @user && @application %>
<div class="mt-6 rounded-md border <%= @allowed ? "border-green-200 dark:border-green-700 bg-green-50 dark:bg-green-900/30" : "border-red-200 dark:border-red-700 bg-red-50 dark:bg-red-900/30" %> p-4">
<div class="flex items-start gap-3">
<% if @allowed %>
<svg class="h-6 w-6 text-green-600 dark:text-green-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
<% else %>
<svg class="h-6 w-6 text-red-600 dark:text-red-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
<% end %>
<div class="flex-1">
<p class="text-sm font-medium <%= @allowed ? "text-green-800 dark:text-green-200" : "text-red-800 dark:text-red-200" %>">
<%= @user.email_address %> <%= @allowed ? "can access" : "cannot access" %> <%= @application.name %>.
</p>
<% if @allowed %>
<p class="mt-1 text-xs text-green-700 dark:text-green-300">
Granted via:
<% @via.each_with_index do |g, i| %>
<%= link_to g.name, admin_group_path(g), class: "underline" %><%= "," unless i == @via.size - 1 %>
<% end %>
</p>
<% else %>
<p class="mt-1 text-xs text-red-700 dark:text-red-300">
<% reasons = [] %>
<% reasons << "the application is inactive" unless @application.active? %>
<% reasons << "the user is #{@user.status.humanize.downcase}" unless @user.active? %>
<% if @application.active? && @user.active? %>
<% if @application.allowed_groups.empty? %>
<% reasons << "the application has no allowed groups (default deny)" %>
<% else %>
<% reasons << "the user shares no group with the application's allowed groups" %>
<% end %>
<% end %>
Reason: <%= reasons.join("; ") %>.
</p>
<% end %>
<p class="mt-2 text-xs text-gray-600 dark:text-gray-400">
<%= link_to "View user", admin_user_path(@user), class: "underline" %> ·
<%= link_to "View application", admin_application_path(@application), class: "underline" %>
</p>
</div>
</div>
</div>
<% end %>
</div>
</div>

View File

@@ -36,9 +36,9 @@
<%= form.text_area :description, rows: 3, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Optional description of this application" %>
</div>
<div>
<div class="flex items-center justify-between">
<%= form.label :icon, "Application Icon", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<div class="space-y-4">
<div class="flex items-center justify-between -mb-2">
<span class="block text-sm font-medium text-gray-700 dark:text-gray-300">Application Icons</span>
<a href="https://dashboardicons.com" target="_blank" rel="noopener noreferrer" class="text-xs text-blue-600 hover:text-blue-800 flex items-center gap-1">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path>
@@ -46,75 +46,22 @@
Browse icons at dashboardicons.com
</a>
</div>
<% if application.icon.attached? && application.persisted? %>
<% begin %>
<%# Only show icon if we can successfully get its URL (blob is persisted) %>
<% if application.icon.blob&.persisted? && application.icon.blob.key.present? %>
<div class="mt-2 mb-3 flex items-center gap-4">
<%= image_tag application.icon, class: "h-16 w-16 rounded-lg object-cover border border-gray-200 dark:border-gray-700", alt: "Current icon" %>
<div class="text-sm text-gray-600 dark:text-gray-400">
<p class="font-medium">Current icon</p>
<p class="text-xs"><%= number_to_human_size(application.icon.blob.byte_size) %></p>
</div>
</div>
<% end %>
<% rescue ArgumentError => e %>
<%# Handle case where icon attachment exists but can't generate signed_id %>
<% if e.message.include?("Cannot get a signed_id for a new record") %>
<div class="mt-2 mb-3 text-sm text-gray-600 dark:text-gray-400">
<p class="font-medium">Icon uploaded</p>
<p class="text-xs">File will be processed shortly</p>
</div>
<% else %>
<%# Re-raise if it's a different error %>
<% raise e %>
<% end %>
<% end %>
<% end %>
<div class="mt-2" data-controller="file-drop image-paste">
<div class="flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 dark:border-gray-600 border-dashed rounded-md hover:border-blue-400 transition-colors"
data-file-drop-target="dropzone"
data-image-paste-target="dropzone"
data-action="dragover->file-drop#dragover dragleave->file-drop#dragleave drop->file-drop#drop paste->image-paste#handlePaste"
tabindex="0">
<div class="space-y-1 text-center">
<svg class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500" stroke="currentColor" fill="none" viewBox="0 0 48 48">
<path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<div class="flex text-sm text-gray-600 dark:text-gray-400">
<label for="<%= form.field_id(:icon) %>" class="relative cursor-pointer bg-white dark:bg-gray-800 rounded-md font-medium text-blue-600 hover:text-blue-500 focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2 dark:focus-within:ring-offset-gray-900 focus-within:ring-blue-500">
<span>Upload a file</span>
<%= form.file_field :icon,
accept: "image/png,image/jpg,image/jpeg,image/gif,image/svg+xml",
class: "sr-only",
data: {
file_drop_target: "input",
image_paste_target: "input",
action: "change->file-drop#handleFiles"
} %>
</label>
<p class="pl-1">or drag and drop</p>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">PNG, JPG, GIF, or SVG up to 2MB</p>
<p class="text-xs text-blue-600 font-medium mt-2">💡 Tip: Click here and press Ctrl+V (or Cmd+V) to paste an image from your clipboard</p>
</div>
</div>
<div data-file-drop-target="preview" class="mt-3 hidden">
<div class="flex items-center gap-3 p-3 bg-blue-50 dark:bg-blue-900/30 rounded-md border border-blue-200 dark:border-blue-700">
<img data-file-drop-target="previewImage" class="h-12 w-12 rounded object-cover" alt="Preview">
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 dark:text-gray-100" data-file-drop-target="filename"></p>
<p class="text-xs text-gray-500 dark:text-gray-400" data-file-drop-target="filesize"></p>
</div>
<button type="button" data-action="click->file-drop#clear" class="text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300">
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</button>
</div>
</div>
</div>
<%= render "icon_uploader",
form: form,
field: :icon,
label: "Icon",
current_attached: (application.persisted? ? application.icon : nil),
current_label: "Current icon" %>
<%= render "icon_uploader",
form: form,
field: :icon_dark,
label: "Dark mode icon (optional)",
help: "Used in place of the main icon when the user's theme is dark. If omitted, the main icon is used in both modes.",
current_attached: (application.persisted? ? application.icon_dark : nil),
current_label: "Current dark-mode icon",
preview_extra_class: "bg-gray-900" %>
</div>
<div>

View File

@@ -0,0 +1,66 @@
<%# Compact icon uploader. Locals:
form - the form builder
field - symbol for the file field (:icon or :icon_dark)
label - heading text
help - small helper paragraph (optional)
current_attached - the attachment to show as "current" preview
current_label - text for the preview row (e.g. "Current icon")
preview_extra_class - extra css for the preview img (e.g. "bg-gray-900")
%>
<div>
<%= form.label field, label, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<% if local_assigns[:help].present? %>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"><%= help %></p>
<% end %>
<% if current_attached&.attached? && current_attached.blob&.persisted? && current_attached.blob.key.present? %>
<div class="mt-2 mb-3 flex items-center gap-3">
<%= image_tag current_attached, class: "h-12 w-12 rounded-md object-cover border border-gray-200 dark:border-gray-700 #{local_assigns[:preview_extra_class]}", alt: current_label %>
<div class="text-sm text-gray-600 dark:text-gray-400">
<p class="font-medium"><%= current_label %></p>
<p class="text-xs"><%= number_to_human_size(current_attached.blob.byte_size) %></p>
</div>
</div>
<% end %>
<div class="mt-2" data-controller="file-drop image-paste">
<div class="flex items-center gap-3 px-3 py-2 border border-dashed border-gray-300 dark:border-gray-600 rounded-md hover:border-blue-400 focus-within:border-blue-500 focus-within:ring-1 focus-within:ring-blue-500 transition-colors"
data-file-drop-target="dropzone"
data-image-paste-target="dropzone"
data-action="dragover->file-drop#dragover dragleave->file-drop#dragleave drop->file-drop#drop paste->image-paste#handlePaste"
tabindex="0">
<svg class="h-5 w-5 text-gray-400 dark:text-gray-500 shrink-0" stroke="currentColor" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M12 4v12m0-12l-4 4m4-4l4 4M4 17v2a2 2 0 002 2h12a2 2 0 002-2v-2"/>
</svg>
<div class="flex-1 text-sm">
<label for="<%= form.field_id(field) %>" class="cursor-pointer font-medium text-blue-600 hover:text-blue-500 focus-within:outline-none">
<span>Upload</span>
<%= form.file_field field,
accept: "image/png,image/jpg,image/jpeg,image/gif,image/svg+xml",
class: "sr-only",
data: {
file_drop_target: "input",
image_paste_target: "input",
action: "change->file-drop#handleFiles"
} %>
</label>
<span class="text-gray-600 dark:text-gray-400"> · drag and drop · or click and paste (⌘V)</span>
<p class="text-xs text-gray-500 dark:text-gray-400">PNG, JPG, GIF or SVG · max 2MB</p>
</div>
</div>
<div data-file-drop-target="preview" class="mt-2 hidden">
<div class="flex items-center gap-3 p-2 bg-blue-50 dark:bg-blue-900/30 rounded-md border border-blue-200 dark:border-blue-700">
<img data-file-drop-target="previewImage" class="h-10 w-10 rounded object-cover" alt="Preview">
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 dark:text-gray-100" data-file-drop-target="filename"></p>
<p class="text-xs text-gray-500 dark:text-gray-400" data-file-drop-target="filesize"></p>
</div>
<button type="button" data-action="click->file-drop#clear" class="text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300">
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</button>
</div>
</div>
</div>
</div>

View File

@@ -8,6 +8,21 @@
</div>
</div>
<dl class="mt-4 grid grid-cols-3 gap-4">
<div class="rounded-lg bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 px-4 py-3">
<dt class="text-xs text-gray-500 dark:text-gray-400">Applications</dt>
<dd class="mt-1 text-2xl font-semibold text-gray-900 dark:text-gray-100"><%= @applications.size %></dd>
</div>
<div class="rounded-lg bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 px-4 py-3">
<dt class="text-xs text-gray-500 dark:text-gray-400">Users with access</dt>
<dd class="mt-1 text-2xl font-semibold text-gray-900 dark:text-gray-100"><%= @total_users_with_access %></dd>
</div>
<div class="rounded-lg bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 px-4 py-3">
<dt class="text-xs text-gray-500 dark:text-gray-400">Groups granting access</dt>
<dd class="mt-1 text-2xl font-semibold text-gray-900 dark:text-gray-100"><%= @total_groups_granting_access %></dd>
</div>
</dl>
<div class="mt-8 flow-root">
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
@@ -18,7 +33,7 @@
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-100">Slug</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-100">Type</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-100">Status</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-100">Groups</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-100">Access</th>
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-0">
<span class="sr-only">Actions</span>
</th>
@@ -30,13 +45,9 @@
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 dark:text-gray-100 sm:pl-0">
<div class="flex items-center gap-3">
<% if application.icon.attached? %>
<%= image_tag application.icon, class: "h-10 w-10 rounded-lg object-cover border border-gray-200 dark:border-gray-700 flex-shrink-0", alt: "#{application.name} icon" %>
<%= app_icon_picture application, class: "h-10 w-10 rounded-lg object-cover border border-gray-200 dark:border-gray-700 flex-shrink-0" %>
<% else %>
<div class="h-10 w-10 rounded-lg bg-gray-100 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 flex items-center justify-center flex-shrink-0">
<svg class="h-6 w-6 text-gray-400 dark:text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
<%= render "shared/app_monogram", name: application.name, class: "h-10 w-10 rounded-lg flex-shrink-0" %>
<% end %>
<%= link_to application.name, admin_application_path(application), class: "text-blue-600 hover:text-blue-900" %>
</div>
@@ -62,10 +73,13 @@
<% end %>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
<% if application.allowed_groups.empty? %>
<span class="text-gray-400 dark:text-gray-500">All users</span>
<% groups_count = application.allowed_groups.size %>
<% users_count = @user_count_by_app[application.id] || 0 %>
<% if groups_count.zero? %>
<span class="inline-flex items-center rounded-full bg-amber-100 dark:bg-amber-900/40 px-2 py-0.5 text-xs font-medium text-amber-700 dark:text-amber-300">No one</span>
<% else %>
<%= application.allowed_groups.count %>
<span class="text-gray-700 dark:text-gray-200"><%= pluralize(users_count, "user") %></span>
<span class="text-gray-400 dark:text-gray-500"> · <%= pluralize(groups_count, "group") %></span>
<% end %>
</td>
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">

View File

@@ -49,13 +49,9 @@
<div class="sm:flex sm:items-start sm:justify-between">
<div class="flex items-start gap-4">
<% if @application.icon.attached? %>
<%= image_tag @application.icon, class: "h-16 w-16 rounded-lg object-cover border border-gray-200 dark:border-gray-700 shrink-0", alt: "#{@application.name} icon" %>
<%= app_icon_picture @application, class: "h-16 w-16 rounded-lg object-cover border border-gray-200 dark:border-gray-700 shrink-0" %>
<% else %>
<div class="h-16 w-16 rounded-lg bg-gray-100 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 flex items-center justify-center shrink-0">
<svg class="h-8 w-8 text-gray-400 dark:text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
<%= render "shared/app_monogram", name: @application.name, class: "h-16 w-16 rounded-lg shrink-0" %>
<% end %>
<div>
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100"><%= @application.name %></h1>
@@ -274,11 +270,11 @@
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Allowed Groups</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
<% if @allowed_groups.empty? %>
<div class="rounded-md bg-blue-50 dark:bg-blue-900/30 p-4">
<div class="rounded-md bg-amber-50 dark:bg-amber-900/30 p-4">
<div class="flex">
<div class="ml-3">
<p class="text-sm text-blue-700 dark:text-blue-300">
No groups assigned - all active users can access this application.
<p class="text-sm text-amber-700 dark:text-amber-300">
No groups assigned — no one can access this application. Attach a group to grant access.
</p>
</div>
</div>
@@ -299,4 +295,35 @@
</div>
</div>
</div>
<!-- Users with access -->
<div class="bg-white dark:bg-gray-800 shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-gray-100 mb-4">
Users with access (<%= @users_with_access.count %>)
</h3>
<% if @users_with_access.any? %>
<ul class="divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-md">
<% @users_with_access.each do |user| %>
<% via = user.groups & @application.allowed_groups %>
<li class="px-4 py-3 flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-900 dark:text-gray-100"><%= user.email_address %></p>
<div class="flex flex-wrap gap-1 mt-1">
<% via.each do |g| %>
<span class="inline-flex items-center rounded-full bg-gray-100 dark:bg-gray-700 px-2 py-0.5 text-xs font-medium text-gray-700 dark:text-gray-300">via <%= g.name %></span>
<% end %>
</div>
</div>
<%= link_to "View", admin_user_path(user), class: "text-blue-600 hover:text-blue-900 text-sm" %>
</li>
<% end %>
</ul>
<% else %>
<div class="rounded-md bg-gray-50 dark:bg-gray-700 p-4">
<p class="text-sm text-gray-500 dark:text-gray-400">No users currently have access. Attach a group to grant access.</p>
</div>
<% end %>
</div>
</div>
</div>

View File

@@ -12,6 +12,22 @@
<%= form.text_area :description, rows: 3, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Optional description of this group" %>
</div>
<div>
<div class="flex items-center">
<%= form.check_box :auto_assign, class: "h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500" %>
<%= form.label :auto_assign, "Auto Assign", class: "ml-2 text-sm text-gray-900 dark:text-gray-100" %>
</div>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">New users will be automatically added to this group when invited. You can mark multiple groups as auto-assigned.</p>
</div>
<div>
<div class="flex items-center">
<%= form.check_box :admin, class: "h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500" %>
<%= form.label :admin, "Administrators", class: "ml-2 text-sm text-gray-900 dark:text-gray-100" %>
</div>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Members of this group can access the admin panel. Does not grant automatic access to applications.</p>
</div>
<div>
<%= form.label :user_ids, "Group Members", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<div class="mt-2 space-y-2 max-h-64 overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-md p-3">
@@ -32,6 +48,29 @@
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Select which users should be members of this group.</p>
</div>
<div>
<%= form.label :application_ids, "Assigned Applications", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<div class="mt-2 space-y-2 max-h-48 overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-md p-3">
<% if @available_applications.any? %>
<% @available_applications.each do |application| %>
<div class="flex items-center">
<%= check_box_tag "group[application_ids][]", application.id, group.applications.include?(application), class: "h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500" %>
<%= label_tag "group_application_ids_#{application.id}", application.name, class: "ml-2 text-sm text-gray-900 dark:text-gray-100" %>
<% case application.app_type %>
<% when "oidc" %>
<span class="ml-2 inline-flex items-center rounded-full bg-purple-100 dark:bg-purple-900/50 px-2 py-0.5 text-xs font-medium text-purple-700 dark:text-purple-300">OIDC</span>
<% when "trusted_header" %>
<span class="ml-2 inline-flex items-center rounded-full bg-indigo-100 dark:bg-indigo-900/50 px-2 py-0.5 text-xs font-medium text-indigo-700 dark:text-indigo-300">ForwardAuth</span>
<% end %>
</div>
<% end %>
<% else %>
<p class="text-sm text-gray-500 dark:text-gray-400">No applications available.</p>
<% end %>
</div>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Select which applications this group grants access to.</p>
</div>
<div data-controller="json-validator" data-json-validator-valid-class="border-green-500 focus:border-green-500 focus:ring-green-500" data-json-validator-invalid-class="border-red-500 focus:border-red-500 focus:ring-red-500" data-json-validator-valid-status-class="text-green-600" data-json-validator-invalid-status-class="text-red-600">
<%= form.label :custom_claims, "Custom Claims (JSON)", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<%= form.text_area :custom_claims, value: (group.custom_claims.present? ? JSON.pretty_generate(group.custom_claims) : ""), rows: 8,

View File

@@ -1,7 +1,15 @@
<div class="mb-6">
<div class="sm:flex sm:items-center sm:justify-between">
<div>
<div class="flex items-center gap-2">
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100"><%= @group.name %></h1>
<% if @group.auto_assign? %>
<span class="inline-flex items-center rounded-full bg-green-100 dark:bg-green-900/50 px-2 py-0.5 text-xs font-medium text-green-700 dark:text-green-300">Auto Assign</span>
<% end %>
<% if @group.admin? %>
<span class="inline-flex items-center rounded-full bg-blue-100 dark:bg-blue-900/50 px-2 py-0.5 text-xs font-medium text-blue-700 dark:text-blue-300">Administrators</span>
<% end %>
</div>
<% if @group.description.present? %>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400"><%= @group.description %></p>
<% end %>

View File

@@ -33,11 +33,36 @@
<%= form.select :status, User.statuses.keys.map { |s| [s.titleize, s] }, {}, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
</div>
<div>
<%= form.label :group_ids, "Group Memberships", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<div class="mt-2 space-y-2 max-h-64 overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-md p-3">
<% if @available_groups.any? %>
<% @available_groups.each do |group| %>
<div class="flex items-center">
<%= form.check_box :admin, class: "h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500", disabled: (user == Current.session.user) %>
<%= form.label :admin, "Administrator", class: "ml-2 block text-sm text-gray-900 dark:text-gray-100" %>
<% if user == Current.session.user %>
<span class="ml-2 text-xs text-gray-500 dark:text-gray-400">(Cannot change your own admin status)</span>
<%= check_box_tag "user[group_ids][]", group.id, user.groups.include?(group), class: "h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500" %>
<%= label_tag "user_group_ids_#{group.id}", group.name, class: "ml-2 text-sm text-gray-900 dark:text-gray-100" %>
<% if group.admin? %>
<span class="ml-2 inline-flex items-center rounded-full bg-blue-100 dark:bg-blue-900/50 px-2 py-0.5 text-xs font-medium text-blue-700 dark:text-blue-300">Admin</span>
<% end %>
<% if group.auto_assign? %>
<span class="ml-2 inline-flex items-center rounded-full bg-green-100 dark:bg-green-900/50 px-2 py-0.5 text-xs font-medium text-green-700 dark:text-green-300">Auto Assign</span>
<% end %>
</div>
<% end %>
<% else %>
<p class="text-sm text-gray-500 dark:text-gray-400">No groups available. Create a group first.</p>
<% end %>
</div>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Administrators are members of any group with the Admin flag set. You cannot remove yourself from your last administrator group.</p>
<% unless user.persisted? %>
<% auto_names = Group.where(auto_assign: true).pluck(:name) %>
<% if auto_names.any? %>
<div class="mt-2 flex items-center">
<%= check_box_tag "auto_assign", "1", true, class: "h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500" %>
<%= label_tag "auto_assign", "Auto-assign to default groups (#{auto_names.join(", ")})", class: "ml-2 text-sm text-gray-900 dark:text-gray-100" %>
</div>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Uncheck to invite this user without auto-assigning the default group(s) — useful for restricted accounts.</p>
<% end %>
<% end %>
</div>

View File

@@ -61,7 +61,7 @@
<% @users.each do |user| %>
<tr>
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 dark:text-gray-100 sm:pl-0">
<%= user.email_address %>
<%= link_to user.email_address, admin_user_path(user), class: "text-blue-600 hover:text-blue-900" %>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
<% if user.status.present? %>
@@ -110,6 +110,7 @@
data: { turbo_method: :post },
class: "text-yellow-600 hover:text-yellow-900" %>
<% end %>
<%= link_to "View", admin_user_path(user), class: "text-blue-600 hover:text-blue-900" %>
<%= link_to "Edit", edit_admin_user_path(user), class: "text-blue-600 hover:text-blue-900" %>
<%= link_to "Delete", admin_user_path(user),
data: { turbo_method: :delete, turbo_confirm: "Are you sure you want to delete this user?" },

View File

@@ -0,0 +1,95 @@
<div class="mb-6">
<div class="sm:flex sm:items-center sm:justify-between">
<div>
<div class="flex items-center gap-2">
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100"><%= @user.email_address %></h1>
<% if @user.admin? %>
<span class="inline-flex items-center rounded-full bg-blue-100 dark:bg-blue-900/50 px-2 py-0.5 text-xs font-medium text-blue-700 dark:text-blue-300">Admin</span>
<% end %>
<% case @user.status %>
<% when "active" %>
<span class="inline-flex items-center rounded-full bg-green-100 dark:bg-green-900/50 px-2 py-0.5 text-xs font-medium text-green-700 dark:text-green-300">Active</span>
<% when "disabled" %>
<span class="inline-flex items-center rounded-full bg-gray-100 dark:bg-gray-700 px-2 py-0.5 text-xs font-medium text-gray-700 dark:text-gray-300">Disabled</span>
<% when "pending_invitation" %>
<span class="inline-flex items-center rounded-full bg-amber-100 dark:bg-amber-900/50 px-2 py-0.5 text-xs font-medium text-amber-700 dark:text-amber-300">Pending Invitation</span>
<% end %>
</div>
<% if @user.name.present? %>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400"><%= @user.name %></p>
<% end %>
</div>
<div class="mt-4 sm:mt-0 flex gap-3">
<%= link_to "Edit", edit_admin_user_path(@user), class: "rounded-md bg-white dark:bg-gray-700 px-3 py-2 text-sm font-semibold text-gray-900 dark:text-gray-200 shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600" %>
</div>
</div>
</div>
<div class="space-y-6">
<!-- Group memberships -->
<div class="bg-white dark:bg-gray-800 shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-gray-100 mb-4">
Group memberships (<%= @user.groups.count %>)
</h3>
<% if @user.groups.any? %>
<ul class="divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-md">
<% @user.groups.order(:name).each do |group| %>
<li class="px-4 py-3 flex items-center justify-between">
<div class="flex items-center gap-2">
<p class="text-sm font-medium text-gray-900 dark:text-gray-100"><%= group.name %></p>
<% if group.admin? %>
<span class="inline-flex items-center rounded-full bg-blue-100 dark:bg-blue-900/50 px-2 py-0.5 text-xs font-medium text-blue-700 dark:text-blue-300">Admin</span>
<% end %>
<% if group.auto_assign? %>
<span class="inline-flex items-center rounded-full bg-green-100 dark:bg-green-900/50 px-2 py-0.5 text-xs font-medium text-green-700 dark:text-green-300">Auto Assign</span>
<% end %>
</div>
<%= link_to "View", admin_group_path(group), class: "text-blue-600 hover:text-blue-900 text-sm" %>
</li>
<% end %>
</ul>
<% else %>
<p class="text-sm text-gray-500 dark:text-gray-400">This user is in no groups.</p>
<% end %>
</div>
</div>
<!-- Accessible applications -->
<div class="bg-white dark:bg-gray-800 shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-gray-100 mb-4">
Accessible applications (<%= @accessible_applications.count %>)
</h3>
<% unless @user.active? %>
<div class="rounded-md bg-amber-50 dark:bg-amber-900/30 p-4">
<p class="text-sm text-amber-700 dark:text-amber-300">
User is <%= @user.status.humanize.downcase %> — access is denied regardless of group memberships.
</p>
</div>
<% end %>
<% if @accessible_applications.any? %>
<ul class="divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-md">
<% @accessible_applications.each do |app| %>
<% via = app.allowed_groups & @user.groups %>
<li class="px-4 py-3 flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-900 dark:text-gray-100"><%= app.name %></p>
<div class="flex flex-wrap gap-1 mt-1">
<% via.each do |g| %>
<span class="inline-flex items-center rounded-full bg-gray-100 dark:bg-gray-700 px-2 py-0.5 text-xs font-medium text-gray-700 dark:text-gray-300">via <%= g.name %></span>
<% end %>
</div>
</div>
<%= link_to "View", admin_application_path(app), class: "text-blue-600 hover:text-blue-900 text-sm" %>
</li>
<% end %>
</ul>
<% else %>
<div class="rounded-md bg-gray-50 dark:bg-gray-700 p-4">
<p class="text-sm text-gray-500 dark:text-gray-400">No accessible applications. Add the user to a group that's attached to one or more applications.</p>
</div>
<% end %>
</div>
</div>
</div>

View File

@@ -130,13 +130,9 @@
<div class="p-6">
<div class="flex items-start gap-3 mb-4">
<% if app.icon.attached? %>
<%= image_tag app.icon, class: "h-12 w-12 rounded-lg object-cover border border-gray-200 dark:border-gray-700 shrink-0", alt: "#{app.name} icon" %>
<%= app_icon_picture app, class: "h-12 w-12 rounded-lg object-cover border border-gray-200 dark:border-gray-700 shrink-0" %>
<% else %>
<div class="h-12 w-12 rounded-lg bg-gray-100 dark:bg-gray-700 border border-gray-200 dark:border-gray-700 flex items-center justify-center shrink-0">
<svg class="h-6 w-6 text-gray-400 dark:text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
<%= render "shared/app_monogram", name: app.name, class: "h-12 w-12 rounded-lg shrink-0" %>
<% end %>
<div class="flex-1 min-w-0">
<div class="flex items-start justify-between">

View File

@@ -9,7 +9,7 @@
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<script>
<script nonce="<%= content_security_policy_nonce %>">
(function() {
var theme = localStorage.getItem('theme');
if (theme === 'dark' || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {

View File

@@ -2,12 +2,12 @@
<div class="bg-white dark:bg-gray-800 py-8 px-6 shadow rounded-lg sm:px-10">
<div class="mb-8 text-center">
<% if @application.icon.attached? %>
<%= image_tag @application.icon, class: "mx-auto h-20 w-20 rounded-xl object-cover border-2 border-gray-200 dark:border-gray-700 shadow-sm mb-4", alt: "#{@application.name} icon" %>
<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 h-20 w-20 rounded-xl bg-gray-100 dark:bg-gray-700 border-2 border-gray-200 dark:border-gray-700 flex items-center justify-center mb-4">
<svg class="h-10 w-10 text-gray-400 dark:text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<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 Application</h2>

View File

@@ -0,0 +1,16 @@
<p>Hello,</p>
<p>
A sign-in to your Clinch account (<strong><%= @user.email_address %></strong>)
using your passkey (<strong><%= @nickname %></strong>) was <strong>blocked</strong>
because its security counter did not advance as expected. This can indicate the
passkey has been copied (cloned).
</p>
<p>
If this was you and you are unable to sign in, remove this passkey and register
a new one. If you do not recognise this activity, treat it as a compromise:
remove the passkey and review your account security.
</p>
<%= render "event_metadata" %>

View File

@@ -0,0 +1,11 @@
Hello,
A sign-in to your Clinch account (<%= @user.email_address %>) using your passkey
("<%= @nickname %>") was BLOCKED because its security counter did not advance as
expected. This can indicate the passkey has been copied (cloned).
If this was you and you are unable to sign in, remove this passkey and register a
new one. If you do not recognise this activity, treat it as a compromise: remove
the passkey and review your account security.
<%= render "event_metadata" %>

View File

@@ -38,7 +38,7 @@
</svg>
Continue with Passkey
</button>
<div data-webauthn-target="error" class="mt-2 text-sm text-red-600" style="display: none;"></div>
<div data-webauthn-target="error" class="mt-2 text-sm text-red-600 hidden"></div>
</div>
<!-- Password section - shown by default, hidden if WebAuthn is required -->

View File

@@ -54,7 +54,7 @@
</svg>
Use Passkey Instead
</button>
<div data-webauthn-target="error" class="mt-2 text-sm text-red-600" style="display: none;"></div>
<div data-webauthn-target="error" class="mt-2 text-sm text-red-600 hidden"></div>
</div>
</div>
<% end %>

View File

@@ -0,0 +1,18 @@
<%# Renders a deterministic monogram SVG for an Application that has no icon.
Locals:
name - the application name (required)
class - css classes for the <svg> element (e.g. "h-12 w-12 rounded-lg")
%>
<% initials = monogram_initials(name) %>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"
class="<%= local_assigns[:class] || "h-12 w-12 rounded-lg" %>"
role="img" aria-label="<%= name %> icon">
<rect width="40" height="40" fill="<%= monogram_color(name) %>" />
<text x="50%" y="52%" dominant-baseline="middle" text-anchor="middle"
font-family="ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif"
font-weight="600" fill="#ffffff"
font-size="<%= initials.length == 1 ? 22 : 17 %>"
letter-spacing="-0.5">
<%= initials %>
</text>
</svg>

View File

@@ -66,6 +66,16 @@
Groups
<% end %>
</li>
<!-- Admin: Access check -->
<li>
<%= link_to admin_access_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path.start_with?('/admin/access') ? 'bg-gray-50 dark:bg-gray-800 text-blue-600 dark:text-blue-400' : 'text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-gray-50 dark:hover:bg-gray-800' }" do %>
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Access check
<% end %>
</li>
<% end %>
<!-- Profile -->
@@ -115,6 +125,10 @@
</li>
</ul>
</li>
<li class="mt-auto pt-4 border-t border-gray-200 dark:border-gray-700">
<%= render "shared/version_info" %>
</li>
</ul>
</nav>
</div>
@@ -192,6 +206,14 @@
Groups
<% end %>
</li>
<li>
<%= link_to admin_access_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path.start_with?('/admin/access') ? 'bg-gray-50 dark:bg-gray-800 text-blue-600 dark:text-blue-400' : 'text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-gray-50 dark:hover:bg-gray-800' }", data: { action: "click->mobile-sidebar#closeSidebar" } do %>
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Access check
<% end %>
</li>
<% end %>
<li>
<%= link_to profile_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path == '/profile' ? 'bg-gray-50 dark:bg-gray-800 text-blue-600 dark:text-blue-400' : 'text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-gray-50 dark:hover:bg-gray-800' }", data: { action: "click->mobile-sidebar#closeSidebar" } do %>
@@ -233,6 +255,10 @@
<% end %>
</li>
</ul>
<div class="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
<%= render "shared/version_info" %>
</div>
</nav>
</div>
</div>

View File

@@ -0,0 +1,4 @@
<div class="px-2 text-xs text-gray-500 dark:text-gray-500 space-y-0.5">
<div>Clinch <%= Clinch::VERSION %></div>
<div>Rails <%= Rails.version %> &middot; Ruby <%= RUBY_VERSION %></div>
</div>

View File

@@ -118,14 +118,17 @@ Rails.application.configure do
registrable_domain = domain.domain # Gets "example.com" from "auth.example.com"
if registrable_domain.present?
# Create regex to allow any subdomain of the registrable domain
allowed_hosts << /.*#{Regexp.escape(registrable_domain)}/
# Allow the registrable domain and any subdomain of it. The pattern is
# anchored (\A...\z) with a mandatory dot before the domain so that
# look-alikes such as "evil-example.com" or "example.com.attacker.com"
# do NOT match — an unanchored /.*example\.com/ would allow both.
allowed_hosts << /\A(.+\.)?#{Regexp.escape(registrable_domain)}\z/i
end
rescue PublicSuffix::DomainInvalid
# Fallback to simple domain extraction if PublicSuffix fails
Rails.logger.warn "Could not parse domain '#{host_domain}' with PublicSuffix, using fallback"
base_domain = host_domain.split(".").last(2).join(".")
allowed_hosts << /.*#{Regexp.escape(base_domain)}/
allowed_hosts << /\A(.+\.)?#{Regexp.escape(base_domain)}\z/i
end
end
@@ -136,9 +139,6 @@ Rails.application.configure do
# Allow internal IP access for cross-compose or host networking
if ENV["CLINCH_ALLOW_INTERNAL_IPS"] == "true"
# Specific host IP
allowed_hosts << "192.168.2.246"
# Private IP ranges for internal network access
allowed_hosts += [
/192\.168\.\d+\.\d+/, # 192.168.0.0/16 private network
@@ -157,17 +157,5 @@ Rails.application.configure do
# Skip DNS rebinding protection for the default health check endpoint.
config.host_authorization = {exclude: ->(request) { request.path == "/up" }}
# Sentry configuration for production
# Only enabled if SENTRY_DSN environment variable is set
if ENV["SENTRY_DSN"].present?
config.sentry.enabled = true
# Performance monitoring: sample 20% of transactions for traces
# Adjust based on your traffic volume and Sentry plan limits
config.sentry.traces_sample_rate = ENV.fetch("SENTRY_TRACES_SAMPLE_RATE", 0.2).to_f
# Continuous profiling: disabled by default in production due to cost
# Enable temporarily for performance investigations if needed
config.sentry.profiles_sample_rate = ENV.fetch("SENTRY_PROFILES_SAMPLE_RATE", 0.0).to_f
end
# Sentry is configured in config/initializers/sentry.rb, gated on SENTRY_DSN.
end

View File

@@ -0,0 +1,16 @@
# CLINCH_HOST is this IdP's canonical external origin, e.g. https://auth.example.com.
# It anchors the OIDC issuer, the WebAuthn RP ID, and the forward-auth login
# redirect. In deployed (non-local) environments it MUST be set explicitly and
# never inferred from request headers — X-Forwarded-Host is attacker-influenceable,
# so inferring the origin from it would allow host-header phishing and open
# redirects. Fail fast at boot rather than start in an unsafe configuration.
#
# Skipped during asset precompilation (e.g. the Docker build step, which sets
# SECRET_KEY_BASE_DUMMY): no real CLINCH_HOST exists yet and assets don't need it.
unless Rails.env.local? || ENV["SECRET_KEY_BASE_DUMMY"].present?
if ENV["CLINCH_HOST"].blank?
raise "CLINCH_HOST must be set (e.g. https://auth.example.com). It is the " \
"canonical origin of this Clinch instance and must not be inferred " \
"from request headers."
end
end

View File

@@ -9,13 +9,15 @@ Rails.application.configure do
# Default to self for everything, plus blob: for file downloads
policy.default_src :self, "blob:"
# Scripts: Allow self, importmaps, unsafe-inline for Turbo/StimulusJS, and blob: for downloads
# Note: unsafe_inline is needed for Stimulus controllers and Turbo navigation
policy.script_src :self, :unsafe_inline, "blob:"
# Scripts: self + per-response nonce (see nonce config below) + blob: for
# downloads. No unsafe-inline — importmap/Turbo/Stimulus inline tags carry the
# nonce automatically, and the one hand-written inline script is nonced.
policy.script_src :self, "blob:"
# Styles: Allow self and unsafe_inline for TailwindCSS dynamic classes
# and Stimulus controller style manipulations
policy.style_src :self, :unsafe_inline
# Styles: self + per-response nonce. No unsafe-inline Tailwind ships as an
# external stylesheet, Turbo's injected <style> carries the nonce, and Stimulus
# sets styles via the CSSOM (not governed by CSP).
policy.style_src :self
# Images: Allow self, data URLs, and https for external images
policy.img_src :self, :data, :https
@@ -51,14 +53,22 @@ Rails.application.configure do
# Child sources: Allow self for any future iframes
policy.child_src :self
# Additional security headers for WebAuthn
# Required for WebAuthn to work properly
policy.require_trusted_types_for :none
# Do not enforce Trusted Types. The only valid value for
# require-trusted-types-for is 'script'; there is no 'none' token, so
# emitting it produces an invalid directive that browsers reject. To leave
# Trusted Types unenforced (needed for WebAuthn), omit the directive entirely.
# CSP reporting using report_uri (supported method)
policy.report_uri "/api/csp-violation-report"
end
# Per-response random nonce applied to script-src and style-src. The app does
# not page-cache HTML, so a fresh random nonce per response is the most secure
# choice (no reuse across responses). csp_meta_tag (in the layout) and
# importmap-rails read this nonce automatically.
config.content_security_policy_nonce_generator = ->(_request) { SecureRandom.base64(16) }
config.content_security_policy_nonce_directives = %w[script-src style-src]
# Start with CSP in report-only mode for testing
# Set to false after verifying everything works in production
config.content_security_policy_report_only = Rails.env.development?

View File

@@ -4,5 +4,8 @@
# Use this to limit dissemination of sensitive information.
# See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors.
Rails.application.config.filter_parameters += [
:passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :cvv, :cvc, :backup
:passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :cvv, :cvc, :backup,
# :code partially matches the TOTP/backup `code` param, the OAuth authorization
# `code`, and the PKCE `code_verifier`/`code_challenge` — all sensitive in logs.
:code
]

View File

@@ -1,62 +1,44 @@
# Sentry configuration for error tracking and performance monitoring
# Only initializes if SENTRY_DSN environment variable is set
# Sentry configuration for error tracking and performance monitoring.
# Only initializes if the SENTRY_DSN environment variable is set.
return unless ENV["SENTRY_DSN"].present?
Rails.application.configure do
config.sentry.dsn = ENV["SENTRY_DSN"]
Sentry.init do |config|
config.dsn = ENV["SENTRY_DSN"]
# Set environment (defaults to Rails.env)
config.sentry.environment = ENV["SENTRY_ENVIRONMENT"] || Rails.env
# Environment label (defaults to Rails.env)
config.environment = ENV["SENTRY_ENVIRONMENT"] || Rails.env
# Set release version from Git or environment variable
config.sentry.release = ENV["SENTRY_RELEASE"] || `git rev-parse HEAD 2>/dev/null`.strip.presence || nil
# Release version from an env var or the current Git SHA
config.release = ENV["SENTRY_RELEASE"] || `git rev-parse HEAD 2>/dev/null`.strip.presence
# Sample rate for performance monitoring (0.0 to 1.0)
config.sentry.traces_sample_rate = ENV.fetch("SENTRY_TRACES_SAMPLE_RATE", 0.1).to_f
# Only report from production unless explicitly enabled elsewhere.
config.enabled_environments =
if ENV["SENTRY_ENABLED_IN_DEVELOPMENT"] == "true"
%w[production development]
else
%w[production]
end
# Enable profiling in development/staging, disable in production unless explicitly enabled
config.sentry.profiles_sample_rate = if Rails.env.production?
# Don't send cookies, request bodies, or user IPs by default.
config.send_default_pii = false
# Breadcrumbs for debugging
config.breadcrumbs_logger = [:active_support_logger, :http_logger]
# Performance monitoring sample rate (0.0 to 1.0)
config.traces_sample_rate = ENV.fetch("SENTRY_TRACES_SAMPLE_RATE", 0.1).to_f
# Profiling: disabled in production by default due to cost.
config.profiles_sample_rate =
if Rails.env.production?
ENV.fetch("SENTRY_PROFILES_SAMPLE_RATE", 0.0).to_f
else
ENV.fetch("SENTRY_PROFILES_SAMPLE_RATE", 0.5).to_f
end
# Include additional context
config.sentry.before_send = lambda do |event, hint|
# Filter out sensitive information
if event.context[:extra]
event.context[:extra].reject! { |key, value|
key.to_s.match?(/password|secret|token|key/i) || value.to_s.match?(/password|secret/i)
}
end
# Filter sensitive parameters
if event.context[:request]
event.context[:request].reject! { |key, value|
key.to_s.match?(/password|secret|token|key|authorization/i)
}
end
event
end
# Include breadcrumbs for debugging
config.sentry.breadcrumbs_logger = [:active_support_logger, :http_logger]
# Send session data for user context
config.sentry.user_context = lambda do
if Current.user.present?
{
id: Current.user.id,
email: Current.user.email_address,
admin: Current.user.admin?
}
end
end
# Ignore common non-critical exceptions
config.sentry.excluded_exceptions += [
config.excluded_exceptions += [
"ActionController::RoutingError",
"ActionController::InvalidAuthenticityToken",
"ActionController::UnknownFormat",
@@ -66,75 +48,38 @@ Rails.application.configure do
"ActiveRecord::RecordNotFound"
]
# Add CSP-specific tags for security events
config.sentry.tags = lambda do
{
# Add application context
# Attach application/user context and scrub anything sensitive before sending.
config.before_send = lambda do |event, _hint|
event.tags = (event.tags || {}).merge(
app_name: "clinch",
app_environment: Rails.env,
# Add CSP policy status
csp_enabled: defined?(Rails.application.config.content_security_policy) &&
Rails.application.config.content_security_policy.present?
}
app_environment: Rails.env
)
if defined?(Current) && Current.respond_to?(:user) && Current.user
event.user = (event.user || {}).merge(
id: Current.user.id,
email: Current.user.email_address,
admin: Current.user.admin?
)
end
# Enhance before_send to handle CSP events properly
config.sentry.before_send = lambda do |event, hint|
# Filter out sensitive information
if event.context[:extra]
event.context[:extra].reject! { |key, value|
if event.extra.is_a?(Hash)
event.extra.reject! do |key, value|
key.to_s.match?(/password|secret|token|key/i) || value.to_s.match?(/password|secret/i)
}
end
# Filter sensitive parameters
if event.context[:request]
event.context[:request].reject! { |key, value|
key.to_s.match?(/password|secret|token|key|authorization/i)
}
end
# Special handling for CSP violations
if event.tags&.dig(:csp_violation)
# Ensure CSP violations have proper security context
event.context[:server] = event.context[:server] || {}
event.context[:server][:name] = "clinch-auth-service"
event.context[:server][:environment] = Rails.env
# Add additional security context
event.context[:extra] ||= {}
event.context[:extra][:security_context] = {
csp_reporting: true,
user_authenticated: event.context[:user].present?,
request_origin: event.context[:request]&.dig(:headers, "Origin"),
request_referer: event.context[:request]&.dig(:headers, "Referer")
}
end
event
end
# Add CSP-specific breadcrumbs for security events
config.sentry.before_breadcrumb = lambda do |breadcrumb, hint|
# Filter out sensitive breadcrumb data
if breadcrumb[:data]
breadcrumb[:data].reject! { |key, value|
key.to_s.match?(/password|secret|token|key|authorization/i) ||
value.to_s.match?(/password|secret/i)
}
# Scrub sensitive data out of breadcrumbs.
config.before_breadcrumb = lambda do |breadcrumb, _hint|
if breadcrumb.data.is_a?(Hash)
breadcrumb.data.reject! do |key, value|
key.to_s.match?(/password|secret|token|key|authorization/i) || value.to_s.match?(/password|secret/i)
end
# Mark CSP-related events
if breadcrumb[:message]&.include?("CSP Violation") ||
breadcrumb[:category]&.include?("csp")
breadcrumb[:data] ||= {}
breadcrumb[:data][:security_event] = true
breadcrumb[:data][:csp_violation] = true
end
breadcrumb
end
# Only send errors in production unless explicitly enabled
config.sentry.enabled = Rails.env.production? || ENV["SENTRY_ENABLED_IN_DEVELOPMENT"] == "true"
end

View File

@@ -1,5 +1,5 @@
# frozen_string_literal: true
module Clinch
VERSION = "0.10.1"
VERSION = "0.16.3"
end

View File

@@ -95,6 +95,7 @@ Rails.application.routes.draw do
end
end
resources :groups
get "access", to: "access_checks#new"
end
# Render dynamic PWA files from app/views/pwa/* (remember to link manifest in application.html.erb)

View File

@@ -0,0 +1,8 @@
class AddAutoAssignAndAdminToGroups < ActiveRecord::Migration[8.1]
def change
add_column :groups, :auto_assign, :boolean, default: false, null: false
add_column :groups, :admin, :boolean, default: false, null: false
add_index :groups, :auto_assign, where: "auto_assign"
add_index :groups, :admin, where: "admin"
end
end

View File

@@ -0,0 +1,44 @@
class SeedDefaultGroupsAndMigrateAdmins < ActiveRecord::Migration[8.1]
# Data migration: seed "everyone" (auto_assign) and "admins" (admin) groups,
# backfill memberships from existing data, attach "everyone" to previously
# group-less applications. Idempotent.
#
# Must run before RemoveAdminFromUsers, because it reads the legacy
# users.admin column.
def up
unless Group.exists?(auto_assign: true)
everyone = Group.create!(
name: "everyone",
description: "Auto-assigned to new users. Safe to rename or remove.",
auto_assign: true
)
User.where(status: 0).find_each do |u|
UserGroup.find_or_create_by!(user_id: u.id, group_id: everyone.id)
end
Application.left_joins(:application_groups)
.where(application_groups: {id: nil})
.find_each do |app|
ApplicationGroup.find_or_create_by!(application_id: app.id, group_id: everyone.id)
end
end
unless Group.exists?(admin: true)
admins = Group.create!(
name: "admins",
description: "Members can access the admin panel.",
admin: true
)
User.where(admin: true).find_each do |u|
UserGroup.find_or_create_by!(user_id: u.id, group_id: admins.id)
end
end
end
def down
Group.where(name: ["everyone", "admins"]).destroy_all
end
end

View File

@@ -0,0 +1,5 @@
class RemoveAdminFromUsers < ActiveRecord::Migration[8.1]
def change
remove_column :users, :admin, :boolean, default: false, null: false
end
end

View File

@@ -0,0 +1,7 @@
class AddLastOtpAtToUsers < ActiveRecord::Migration[8.1]
def change
# Unix timestamp of the most recently accepted TOTP timestep, used to reject
# replay of a code within its drift window (passed to ROTP's `after:`).
add_column :users, :last_otp_at, :integer
end
end

8
db/schema.rb generated
View File

@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2026_04_20_080000) do
ActiveRecord::Schema[8.1].define(version: 2026_06_11_000001) do
create_table "active_storage_attachments", force: :cascade do |t|
t.bigint "blob_id", null: false
t.datetime "created_at", null: false
@@ -106,11 +106,15 @@ ActiveRecord::Schema[8.1].define(version: 2026_04_20_080000) do
end
create_table "groups", force: :cascade do |t|
t.boolean "admin", default: false, null: false
t.boolean "auto_assign", default: false, null: false
t.datetime "created_at", null: false
t.json "custom_claims", default: {}, null: false
t.text "description"
t.string "name", null: false
t.datetime "updated_at", null: false
t.index ["admin"], name: "index_groups_on_admin", where: "admin"
t.index ["auto_assign"], name: "index_groups_on_auto_assign", where: "auto_assign"
t.index ["name"], name: "index_groups_on_name", unique: true
end
@@ -225,11 +229,11 @@ ActiveRecord::Schema[8.1].define(version: 2026_04_20_080000) do
end
create_table "users", force: :cascade do |t|
t.boolean "admin", default: false, null: false
t.json "backup_codes"
t.datetime "created_at", null: false
t.json "custom_claims", default: {}, null: false
t.string "email_address", null: false
t.integer "last_otp_at"
t.datetime "last_sign_in_at"
t.string "name"
t.string "password_digest", null: false

View File

@@ -0,0 +1,47 @@
require "test_helper"
module Admin
class AccessChecksControllerTest < ActionDispatch::IntegrationTest
setup do
@admin = users(:two)
sign_in_as(@admin)
@kavita = applications(:kavita_app)
end
test "new renders the form with users and applications" do
get admin_access_path
assert_response :success
assert_match @kavita.name, response.body
assert_match "alice@example.com", response.body
end
test "returns 'can access' with via group when user is in an allowed group" do
get admin_access_path, params: {
user_id: users(:alice).id,
application_id: @kavita.id
}
assert_response :success
assert_match "can access", response.body
assert_match "Administrators", response.body # alice is in admin_group; kavita has admin_group
end
test "returns 'cannot access' with reason when user shares no group with the app" do
lonely = User.create!(email_address: "lonely@example.com", password: "password123", skip_auto_assign: true)
get admin_access_path, params: {
user_id: lonely.id,
application_id: @kavita.id
}
assert_response :success
assert_match "cannot access", response.body
assert_match "shares no group", response.body
end
test "renders form unchanged when ids are missing" do
get admin_access_path, params: {user_id: "", application_id: ""}
assert_response :success
# No result panel should render. The panel-only phrases:
refute_match "Granted via", response.body
refute_match "Reason:", response.body
end
end
end

View File

@@ -0,0 +1,70 @@
require "test_helper"
module Admin
class GroupsControllerTest < ActionDispatch::IntegrationTest
setup do
@admin = users(:two)
sign_in_as(@admin)
@group = groups(:one)
end
test "update assigns applications from application_ids" do
app_a = applications(:kavita_app)
app_b = applications(:another_app)
patch admin_group_path(@group), params: {
group: {
name: @group.name,
application_ids: [app_a.id, app_b.id]
}
}
assert_redirected_to admin_group_path(@group)
assert_equal [app_a, app_b].sort, @group.reload.applications.sort
end
test "update with no application_ids clears assigned applications" do
@group.applications = [applications(:kavita_app)]
patch admin_group_path(@group), params: {
group: {name: @group.name}
}
assert_redirected_to admin_group_path(@group)
assert_empty @group.reload.applications
end
test "create assigns applications from application_ids" do
app = applications(:audiobookshelf_app)
assert_difference -> { Group.count }, 1 do
post admin_groups_path, params: {
group: {
name: "New Group",
application_ids: [app.id]
}
}
end
assert_equal [app], Group.find_by(name: "new group").applications
end
test "can mark a group as auto_assign and admin" do
patch admin_group_path(@group), params: {
group: {name: @group.name, auto_assign: "1", admin: "1"}
}
@group.reload
assert @group.auto_assign?
assert @group.admin?
end
test "cannot delete the last admin group" do
admins = groups(:admin_group)
delete admin_group_path(admins)
# Destroy was aborted by the before_destroy guard
assert Group.exists?(admins.id), "admin group should not have been deleted"
end
end
end

View File

@@ -0,0 +1,69 @@
require "test_helper"
module Admin
class UsersControllerTest < ActionDispatch::IntegrationTest
setup do
@admin = users(:two) # in admin_group via fixtures
sign_in_as(@admin)
end
test "show loads accessible applications via the user's groups" do
kavita = applications(:kavita_app)
# alice is in admin_group via fixtures; kavita is attached to admin_group via app_groups
get admin_user_path(users(:alice))
assert_response :success
assert_match kavita.name, response.body
# The "via" badge mentions the granting group name
assert_match groups(:admin_group).name, response.body
end
test "update assigns group memberships from group_ids" do
target = users(:bob)
editors = groups(:editor_group)
one = groups(:one)
patch admin_user_path(target), params: {
user: {email_address: target.email_address, group_ids: [editors.id, one.id]}
}
assert_redirected_to admin_users_path
assert_equal [editors, one].sort, target.reload.groups.sort
end
test "cannot remove yourself from the last admin group" do
# @admin (users:two) is in admin_group. Removing them via the user form
# while no other admin exists is blocked.
sole_admin = users(:two)
# Strip alice (the other admin) so @admin is the last one.
users(:alice).groups.delete(groups(:admin_group))
patch admin_user_path(sole_admin), params: {
user: {email_address: sole_admin.email_address, group_ids: []}
}
assert_response :unprocessable_entity
assert sole_admin.reload.admin?, "should still be admin"
end
test "create with auto_assign=0 skips the auto-assign callback" do
post admin_users_path, params: {
user: {email_address: "restricted@example.com"},
auto_assign: "0"
}
assert_response :redirect
created = User.find_by(email_address: "restricted@example.com")
assert_not_includes created.groups, groups(:everyone)
end
test "create without auto_assign param auto-joins everyone" do
post admin_users_path, params: {
user: {email_address: "newbie@example.com"}
}
assert_response :redirect
created = User.find_by(email_address: "newbie@example.com")
assert_includes created.groups, groups(:everyone)
end
end
end

View File

@@ -11,6 +11,7 @@ module Api
domain_pattern: "webdav.example.com",
active: true
)
grant_everyone_access(@app)
@api_key = @user.api_keys.create!(name: "Test Key", application: @app)
@token = @api_key.plaintext_token
end
@@ -112,6 +113,42 @@ module Api
assert_equal "Application is inactive", json["error"]
end
test "bearer token returns 401 once user is removed from allowed groups" do
# App restricted to a specific group; user is a member when the key is made.
group = Group.create!(name: "webdav-users")
restricted_app = Application.create!(
name: "Restricted WebDAV",
slug: "restricted-webdav",
app_type: "forward_auth",
domain_pattern: "restricted.example.com",
active: true
)
restricted_app.allowed_groups << group
@user.groups << group
key = @user.api_keys.create!(name: "Restricted Key", application: restricted_app)
token = key.plaintext_token
# Sanity: access works while membership stands.
get "/api/verify", headers: {
"Authorization" => "Bearer #{token}",
"X-Forwarded-Host" => "restricted.example.com"
}
assert_response :ok
# Revoke group membership; the existing key must stop working.
@user.groups.destroy(group)
get "/api/verify", headers: {
"Authorization" => "Bearer #{token}",
"X-Forwarded-Host" => "restricted.example.com"
}
assert_response :unauthorized
json = JSON.parse(response.body)
assert_equal "Access denied: insufficient group membership", json["error"]
end
test "no bearer token falls through to cookie auth" do
# No auth header, no session -> should redirect (cookie flow)
get "/api/verify", headers: {

View File

@@ -7,8 +7,8 @@ module Api
@admin_user = users(:alice)
@inactive_user = User.create!(email_address: "inactive@example.com", password: "password", status: :disabled)
@group = groups(:admin_group)
@rule = Application.create!(name: "Test App", slug: "test-app", app_type: "forward_auth", domain_pattern: "test.example.com", active: true)
@inactive_rule = Application.create!(name: "Inactive App", slug: "inactive-app", app_type: "forward_auth", domain_pattern: "inactive.example.com", active: false)
@rule = grant_everyone_access(Application.create!(name: "Test App", slug: "test-app", app_type: "forward_auth", domain_pattern: "test.example.com", active: true))
@inactive_rule = grant_everyone_access(Application.create!(name: "Inactive App", slug: "inactive-app", app_type: "forward_auth", domain_pattern: "inactive.example.com", active: false))
end
# Authentication Tests
@@ -65,7 +65,7 @@ module Api
end
test "should return 403 when rule exists but user not in allowed groups" do
@rule.allowed_groups << @group
@rule.allowed_groups = [@group]
sign_in_as(@user) # User not in group
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
@@ -75,7 +75,7 @@ module Api
end
test "should return 200 when user is in allowed groups" do
@rule.allowed_groups << @group
@rule.allowed_groups = [@group]
@user.groups << @group
sign_in_as(@user)
@@ -86,7 +86,7 @@ module Api
# Domain Pattern Tests
test "should match wildcard domains correctly" do
Application.create!(name: "Wildcard App", slug: "wildcard-app", app_type: "forward_auth", domain_pattern: "*.example.com", active: true)
grant_everyone_access Application.create!(name: "Wildcard App", slug: "wildcard-app", app_type: "forward_auth", domain_pattern: "*.example.com", active: true)
sign_in_as(@user)
get "/api/verify", headers: {"X-Forwarded-Host" => "app.example.com"}
@@ -101,7 +101,7 @@ module Api
end
test "should match exact domains correctly" do
Application.create!(name: "Exact App", slug: "exact-app", app_type: "forward_auth", domain_pattern: "api.example.com", active: true)
grant_everyone_access Application.create!(name: "Exact App", slug: "exact-app", app_type: "forward_auth", domain_pattern: "api.example.com", active: true)
sign_in_as(@user)
get "/api/verify", headers: {"X-Forwarded-Host" => "api.example.com"}
@@ -126,7 +126,7 @@ module Api
end
test "should return custom headers when configured" do
Application.create!(
grant_everyone_access Application.create!(
name: "Custom App",
slug: "custom-app",
app_type: "forward_auth",
@@ -151,7 +151,7 @@ module Api
end
test "should return no headers when all headers disabled" do
Application.create!(
grant_everyone_access Application.create!(
name: "No Headers App",
slug: "no-headers-app",
app_type: "forward_auth",
@@ -182,11 +182,19 @@ module Api
assert_includes groups_header, "Editors"
end
test "should not include groups header when user has no groups" do
@user.groups.clear # Remove fixture groups
test "should not include groups header when user has no groups beyond the granting one and groups header empty" do
# Under default-deny the user must be in at least one group to access the app.
# This rewritten test verifies that when an app's headers_config disables the
# groups header, no x-remote-groups is sent regardless of memberships.
grant_everyone_access Application.create!(
name: "Headers Hidden", slug: "headers-hidden", app_type: "forward_auth",
domain_pattern: "hidden.example.com",
active: true,
headers_config: {groups: ""}
)
sign_in_as(@user)
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
get "/api/verify", headers: {"X-Forwarded-Host" => "hidden.example.com"}
assert_response 200
assert_nil response.headers["x-remote-groups"]
@@ -234,6 +242,20 @@ module Api
assert_equal "No authentication rule configured for this domain", response.headers["x-auth-reason"]
end
# Fail closed when no host can be determined: emitting identity headers without
# an application would bypass all per-domain group access control.
test "should fail closed and emit no identity headers when host is absent" do
sign_in_as(@user)
# Blank both host sources so forwarded_host is not present.
get "/api/verify", headers: {"X-Forwarded-Host" => "", "Host" => ""}
assert_response 403
assert_equal "No host header present", response.headers["x-auth-reason"]
assert_nil response.headers["X-Remote-User"]
assert_nil response.headers["X-Remote-Groups"]
end
# Security Tests
test "should handle very long domain names" do
long_domain = "a" * 250 + ".example.com"
@@ -537,7 +559,7 @@ module Api
end
test "should track failed attempts and eventually rate limit" do
cache = Rails.application.config.forward_auth_cache
Rails.application.config.forward_auth_cache
# Make 50 failed requests (no session = unauthorized)
50.times do
@@ -705,7 +727,7 @@ module Api
class FaTokenHostBindingTest < ActionDispatch::IntegrationTest
setup do
@user = users(:bob)
Application.create!(name: "Bound App", slug: "bound-app", app_type: "forward_auth", domain_pattern: "app.example.com", active: true)
grant_everyone_access Application.create!(name: "Bound App", slug: "bound-app", app_type: "forward_auth", domain_pattern: "app.example.com", active: true)
@original_cache = Rails.cache
Rails.cache = ActiveSupport::Cache::MemoryStore.new

View File

@@ -17,6 +17,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
@application.generate_new_client_secret!
@plain_client_secret = @application.client_secret
@application.save!
grant_everyone_access(@application)
end
def teardown
@@ -33,6 +34,25 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
# CRITICAL SECURITY TESTS
# ====================
test "consent endpoint rejects cross-site POST without a CSRF token" do
sign_in_as(@user)
# Forgery protection is disabled in the test env by default; enable it so the
# before_action actually runs, mirroring production behaviour.
original = ActionController::Base.allow_forgery_protection
ActionController::Base.allow_forgery_protection = true
begin
# No authenticity_token param: a forged cross-site submission. Because
# :consent is NOT in the verify_authenticity_token skip list, this must be
# rejected before the action can grant any OAuth scopes.
post "/oauth/authorize/consent", params: {approve: "true"}
assert_response :unprocessable_entity
ensure
ActionController::Base.allow_forgery_protection = original
end
end
test "prevents authorization code reuse - sequential attempts" do
# Create consent
OidcUserConsent.create!(

View File

@@ -10,6 +10,7 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
redirect_uris: ["http://localhost:4000/callback"].to_json,
active: true
)
grant_everyone_access(@application)
# Sign in the user using the test helper
sign_in_as(@user)

View File

@@ -213,6 +213,50 @@ class OidcRefreshTokenControllerTest < ActionDispatch::IntegrationTest
assert_equal family_id, new_refresh_token.token_family_id
end
test "reusing a revoked refresh token revokes every access token in the family" do
access_token = OidcAccessToken.create!(
application: @application,
user: @user,
scope: "openid profile email"
)
refresh_token = OidcRefreshToken.create!(
application: @application,
user: @user,
oidc_access_token: access_token,
scope: "openid profile email"
)
family_id = refresh_token.token_family_id
old_plaintext = refresh_token.token
# Rotate once: the old refresh token is revoked; a new access + refresh token
# are issued into the same family.
post "/oauth/token", params: {
grant_type: "refresh_token",
refresh_token: old_plaintext,
client_id: @application.client_id,
client_secret: @client_secret
}
assert_response :success
new_refresh = OidcRefreshToken.in_family(family_id).where.not(id: refresh_token.id).first
new_access_token = new_refresh.oidc_access_token
refute new_access_token.reload.revoked?, "rotated-in access token should start active"
# Reuse the OLD (now revoked) refresh token -> rotation-attack detection.
post "/oauth/token", params: {
grant_type: "refresh_token",
refresh_token: old_plaintext,
client_id: @application.client_id,
client_secret: @client_secret
}
assert_response :bad_request
# Both the original and the rotated-in access token must now be revoked, so a
# stolen access token from anywhere in the chain stops working at /userinfo.
assert access_token.reload.revoked?, "original access token should be revoked"
assert new_access_token.reload.revoked?, "rotated-in access token should be revoked"
end
test "userinfo endpoint works with hashed access token" do
access_token = OidcAccessToken.create!(
application: @application,

View File

@@ -19,16 +19,21 @@ class TotpSecurityTest < ActionDispatch::IntegrationTest
# First use of the code should succeed
post totp_verification_path, params: {code: valid_code}
assert_response :redirect
assert_redirected_to root_path
# Sign out
delete session_path
assert_response :redirect
# Note: In the current implementation, TOTP codes CAN be reused within the 60-second time window
# This is standard TOTP behavior. For enhanced security, you could implement used code tracking.
# This test documents the current behavior - codes work within their time window
# Replay the SAME code in a fresh sign-in attempt. Because verify_totp records
# the accepted timestep (ROTP `after:`), the code is now rejected even though
# it is still within its drift window — so we stay on the verification step.
post signin_path, params: {email_address: "totp_replay_test@example.com", password: "password123"}
assert_redirected_to totp_verification_path
post totp_verification_path, params: {code: valid_code}
assert_redirected_to totp_verification_path
assert_equal "Invalid verification code. Please try again.", flash[:alert]
user.sessions.delete_all
user.destroy

View File

@@ -11,7 +11,13 @@ two:
admin_group:
name: Administrators
description: System administrators with full access
admin: true
editor_group:
name: Editors
description: Content editors with limited access
everyone:
name: everyone
description: Auto-assigned to new users.
auto_assign: true

View File

@@ -1,9 +1,28 @@
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
# All users belong to "everyone" so existing tests that create group-less apps
# can be made compatible by attaching that group.
one_everyone:
user: one
group: everyone
two_everyone:
user: two
group: everyone
alice_everyone:
user: alice
group: everyone
bob_everyone:
user: bob
group: everyone
alice_admin_group:
user: alice
group: admin_group
two_admin_group:
user: two
group: admin_group
bob_editor_group:
user: bob
group: editor_group

View File

@@ -3,23 +3,19 @@
one:
email_address: one@example.com
password_digest: <%= password_digest %>
admin: false
status: 0 # active
two:
email_address: two@example.com
password_digest: <%= password_digest %>
admin: true
status: 0 # active
alice:
email_address: alice@example.com
password_digest: <%= password_digest %>
admin: true
status: 0 # active
bob:
email_address: bob@example.com
password_digest: <%= password_digest %>
admin: false
status: 0 # active

View File

@@ -0,0 +1,53 @@
require "test_helper"
class ApplicationHelperTest < ActionView::TestCase
test "monogram_initials picks capitals from camelCase" do
assert_equal "SL", monogram_initials("ShelfLife")
assert_equal "KR", monogram_initials("KavitaReader")
assert_equal "AB", monogram_initials("AudioBookShelf") # first two of 4 capitals
end
test "monogram_initials falls back to first two letters when fewer than two capitals" do
assert_equal "AU", monogram_initials("Audiobookshelf")
assert_equal "ME", monogram_initials("metube")
assert_equal "GI", monogram_initials("git")
end
test "monogram_initials handles single-character and unusual names" do
assert_equal "X", monogram_initials("X")
assert_equal "X1", monogram_initials("X1")
assert_equal "?", monogram_initials("")
assert_equal "?", monogram_initials(nil)
end
test "monogram_color is deterministic for the same name" do
a = monogram_color("ShelfLife")
b = monogram_color("ShelfLife")
assert_equal a, b
assert_match(/\A#[0-9a-f]{6}\z/i, a)
end
test "monogram_color differs for different names" do
# not a guarantee for all pairs, but should hold for at least one pair
assert_not_equal monogram_color("Kavita"), monogram_color("Navidrome")
end
test "app_icon_picture renders both icons with Tailwind dark: toggles when icon_dark is attached" do
app = applications(:kavita_app)
app.icon.attach(io: StringIO.new("light"), filename: "light.png", content_type: "image/png")
app.icon_dark.attach(io: StringIO.new("dark"), filename: "dark.png", content_type: "image/png")
html = app_icon_picture(app, class: "h-10 w-10 rounded-lg")
assert_match(/dark:hidden/, html)
assert_match(/hidden dark:block/, html)
end
test "app_icon_picture renders one img when no icon_dark is attached" do
app = applications(:kavita_app)
app.icon.attach(io: StringIO.new("light"), filename: "light.png", content_type: "image/png")
html = app_icon_picture(app, class: "h-10 w-10")
refute_match(/dark:hidden/, html)
refute_match(/hidden dark:block/, html)
end
end

View File

@@ -0,0 +1,76 @@
require "test_helper"
class CspTest < ActionDispatch::IntegrationTest
# In the test env content_security_policy_report_only is false, so the enforcing
# Content-Security-Policy header is emitted.
test "signin page sends a nonce-based CSP with no unsafe-inline" do
get signin_path
assert_response :success
csp = response.headers["Content-Security-Policy"]
assert csp.present?, "expected a Content-Security-Policy header"
script_src = directive(csp, "script-src")
style_src = directive(csp, "style-src")
assert_includes script_src, "'nonce-", "script-src must carry a nonce"
assert_includes style_src, "'nonce-", "style-src must carry a nonce"
refute_includes script_src, "'unsafe-inline'", "script-src must not allow unsafe-inline"
refute_includes style_src, "'unsafe-inline'", "style-src must not allow unsafe-inline"
end
test "the inline theme script carries the script-src nonce" do
get signin_path
assert_response :success
header_nonce = response.headers["Content-Security-Policy"][/script-src[^;]*'nonce-([^']+)'/, 1]
assert header_nonce.present?, "expected a nonce in the CSP header"
# The hand-written dark-mode <script> in the layout must use the same nonce,
# otherwise it would be blocked under the enforcing policy.
assert_match(/<script nonce="#{Regexp.escape(header_nonce)}">/, response.body,
"inline theme script must carry the matching CSP nonce")
end
test "signin page adds the OAuth redirect_uri host to form-action without 500ing" do
# A user must exist, otherwise /signin redirects to signup before the CSP
# branch runs.
User.create!(email_address: "csp_oauth@example.com", password: "password123")
app = Application.create!(
name: "CSP OAuth App",
slug: "csp-oauth-app",
app_type: "oidc",
redirect_uris: ["https://app.example.com/callback"].to_json,
active: true,
require_pkce: false
)
# An unauthenticated authorize request stores the full /oauth/authorize URL
# in the session and redirects to signin (oidc_controller.rb:202).
get "/oauth/authorize", params: {
client_id: app.client_id,
redirect_uri: app.parsed_redirect_uris.first,
response_type: "code",
scope: "openid"
}
assert_redirected_to signin_path
# Following to signin must reach allow_oauth_redirect_in_csp without raising.
# Regression: csp.form_action is a destructive getter, so reading it twice
# returned nil and `nil << host` raised NoMethodError -> 500.
follow_redirect!
assert_response :success
form_action = directive(response.headers["Content-Security-Policy"], "form-action")
assert_includes form_action, "'self'", "form-action must keep its default 'self'"
assert_includes form_action, "https://app.example.com",
"form-action must include the OAuth client's redirect_uri host"
end
private
def directive(csp, name)
csp.split(";").map(&:strip).find { |d| d.start_with?("#{name} ") } || ""
end
end

View File

@@ -12,7 +12,7 @@ class ForwardAuthAdvancedTest < ActionDispatch::IntegrationTest
# End-to-End Authentication Flow Tests
test "complete forward auth flow with default headers" do
# Create an application with default headers
Application.create!(name: "App", slug: "app-system-test", app_type: "forward_auth", domain_pattern: "app.example.com", active: true)
grant_everyone_access Application.create!(name: "App", slug: "app-system-test", app_type: "forward_auth", domain_pattern: "app.example.com", active: true)
# Step 1: Unauthenticated request to protected resource
get "/api/verify", headers: {
@@ -48,14 +48,14 @@ class ForwardAuthAdvancedTest < ActionDispatch::IntegrationTest
test "multiple domain access with single session" do
# Create applications for different domains
Application.create!(name: "App Domain", slug: "app-domain", app_type: "forward_auth", domain_pattern: "app.example.com", active: true)
Application.create!(
grant_everyone_access Application.create!(name: "App Domain", slug: "app-domain", app_type: "forward_auth", domain_pattern: "app.example.com", active: true)
grant_everyone_access Application.create!(
name: "Grafana", slug: "grafana-system-test", app_type: "forward_auth",
domain_pattern: "grafana.example.com",
active: true,
headers_config: {user: "X-WEBAUTH-USER", email: "X-WEBAUTH-EMAIL"}
)
Application.create!(
grant_everyone_access Application.create!(
name: "Metube", slug: "metube-system-test", app_type: "forward_auth",
domain_pattern: "metube.example.com",
active: true,
@@ -106,7 +106,7 @@ class ForwardAuthAdvancedTest < ActionDispatch::IntegrationTest
# Should have access (in allowed group)
get "/api/verify", headers: {"X-Forwarded-Host" => "admin.example.com"}
assert_response 200
assert_equal @group.name, response.headers["x-remote-groups"]
assert_includes response.headers["x-remote-groups"], @group.name
# Add user to second group
@user.groups << @group2
@@ -126,31 +126,27 @@ class ForwardAuthAdvancedTest < ActionDispatch::IntegrationTest
assert_response 403
end
test "bypass mode when no groups assigned to rule" do
# Create bypass application (no groups)
test "default deny when no groups assigned to rule" do
# An app with no allowed_groups now denies all users (was: bypass mode).
Application.create!(
name: "Public", slug: "public-system-test", app_type: "forward_auth",
domain_pattern: "public.example.com",
name: "No Groups", slug: "nogroups-system-test", app_type: "forward_auth",
domain_pattern: "nogroups.example.com",
active: true
)
# Create user with no groups
@user.groups.clear
# Sign in
post "/signin", params: {email_address: @user.email_address, password: "password"}
assert_response 303
# Should have access (bypass mode)
get "/api/verify", headers: {"X-Forwarded-Host" => "public.example.com"}
assert_response 200
assert_equal @user.email_address, response.headers["x-remote-user"]
get "/api/verify", headers: {"X-Forwarded-Host" => "nogroups.example.com"}
assert_response 403
end
# Security System Tests
test "session expiration and cleanup" do
# Create test application
Application.create!(
grant_everyone_access Application.create!(
name: "Test", slug: "test-system-test", app_type: "forward_auth",
domain_pattern: "test.example.com",
active: true
@@ -179,7 +175,7 @@ class ForwardAuthAdvancedTest < ActionDispatch::IntegrationTest
test "concurrent access with rate limiting considerations" do
# Create wildcard application
Application.create!(
grant_everyone_access Application.create!(
name: "Wildcard", slug: "wildcard-test", app_type: "forward_auth",
domain_pattern: "*.example.com",
active: true
@@ -246,7 +242,11 @@ class ForwardAuthAdvancedTest < ActionDispatch::IntegrationTest
active: true,
headers_config: app[:headers_config]
)
if app[:groups].any?
app[:groups].each { |group| rule.allowed_groups << group }
else
grant_everyone_access(rule)
end
rule
end
@@ -286,7 +286,7 @@ class ForwardAuthAdvancedTest < ActionDispatch::IntegrationTest
]
patterns.each_with_index do |pattern_config, idx|
Application.create!(
grant_everyone_access Application.create!(
name: "Pattern Test #{idx}", slug: "pattern-test-#{idx}", app_type: "forward_auth",
domain_pattern: pattern_config[:pattern],
active: true
@@ -310,7 +310,7 @@ class ForwardAuthAdvancedTest < ActionDispatch::IntegrationTest
# Performance System Tests
test "system performance under load" do
# Create test application with wildcard pattern
Application.create!(name: "Load Test", slug: "loadtest", app_type: "forward_auth", domain_pattern: "*.loadtest.example.com", active: true)
grant_everyone_access Application.create!(name: "Load Test", slug: "loadtest", app_type: "forward_auth", domain_pattern: "*.loadtest.example.com", active: true)
# Sign in
post "/signin", params: {email_address: @user.email_address, password: "password"}

View File

@@ -15,6 +15,7 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
domain_pattern: "test.example.com",
active: true
)
grant_everyone_access(@test_app)
end
# Basic Authentication Flow Tests
@@ -56,8 +57,8 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
# Domain and Rule Integration Tests
test "different domain patterns with same session" do
# Create test rules
Application.create!(name: "Wildcard App", slug: "wildcard-app", app_type: "forward_auth", domain_pattern: "*.example.com", active: true)
Application.create!(name: "Exact App", slug: "exact-app", app_type: "forward_auth", domain_pattern: "api.example.com", active: true)
grant_everyone_access Application.create!(name: "Wildcard App", slug: "wildcard-app", app_type: "forward_auth", domain_pattern: "*.example.com", active: true)
grant_everyone_access Application.create!(name: "Exact App", slug: "exact-app", app_type: "forward_auth", domain_pattern: "api.example.com", active: true)
# Sign in
post "/signin", params: {email_address: @user.email_address, password: "password"}
@@ -103,14 +104,14 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
# Header Configuration Integration Tests
test "different header configurations with same user" do
# Create applications with different configs
Application.create!(name: "Default App", slug: "default-app", app_type: "forward_auth", domain_pattern: "default.example.com", active: true)
Application.create!(
grant_everyone_access Application.create!(name: "Default App", slug: "default-app", app_type: "forward_auth", domain_pattern: "default.example.com", active: true)
grant_everyone_access Application.create!(
name: "Custom App", slug: "custom-app", app_type: "forward_auth",
domain_pattern: "custom.example.com",
active: true,
headers_config: {user: "X-WEBAUTH-USER", groups: "X-WEBAUTH-ROLES"}
)
Application.create!(
grant_everyone_access Application.create!(
name: "No Headers App", slug: "no-headers-app", app_type: "forward_auth",
domain_pattern: "noheaders.example.com",
active: true,
@@ -152,6 +153,10 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
# Redirect URL Integration Tests
test "unauthenticated request redirects to signin with parameters" do
# grafana.example.com must be a registered forward-auth app for its URL to be
# honoured as a redirect target (otherwise it would be an open-redirect vector).
grant_everyone_access Application.create!(name: "Grafana", slug: "grafana", app_type: "forward_auth", domain_pattern: "grafana.example.com", active: true)
# Test that unauthenticated requests redirect to signin with rd and rm parameters
get "/api/verify", headers: {
"X-Forwarded-Host" => "grafana.example.com"
@@ -171,7 +176,27 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
assert_includes location, "grafana.example.com"
end
test "spoofed X-Forwarded-Host is not reflected as a redirect target" do
# No forward-auth app exists for evil.com, and no valid rd is supplied. The
# attacker-controlled host must NOT be stored or reflected into the signin URL,
# and base_url must come from CLINCH_HOST (or the safe localhost default in
# test) rather than the request host.
get "/api/verify", headers: {
"X-Forwarded-Host" => "evil.com",
"X-Forwarded-Uri" => "/steal"
}
assert_response 302
assert_match %r{/signin}, response.location
refute_includes response.location, "evil.com"
refute_match(/evil\.com/, session[:return_to_after_authenticating].to_s)
end
test "return URL functionality after authentication" do
# app.example.com must be a registered forward-auth app for its URL to be
# honoured as a redirect target.
grant_everyone_access Application.create!(name: "App FA", slug: "app-fa", app_type: "forward_auth", domain_pattern: "app.example.com", active: true)
# Initial request should set return URL
get "/api/verify", headers: {
"X-Forwarded-Host" => "app.example.com",
@@ -196,7 +221,7 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
admin_user = users(:two)
# Create restricted rule
Application.create!(
grant_everyone_access Application.create!(
name: "Admin App", slug: "admin-app", app_type: "forward_auth",
domain_pattern: "admin.example.com",
active: true,

View File

@@ -1,6 +1,49 @@
require "test_helper"
class SessionSecurityTest < ActionDispatch::IntegrationTest
# ====================
# ACCOUNT DEACTIVATION TESTS
# ====================
test "TOTP verification rejects a user disabled mid-flow" do
user = User.create!(email_address: "midflow_totp@example.com", password: "password123")
user.enable_totp!
code = ROTP::TOTP.new(user.totp_secret).now
# Phase A: password step stashes the pending 2FA user
post signin_path, params: {email_address: "midflow_totp@example.com", password: "password123"}
assert_redirected_to totp_verification_path
# Admin disables the account while the user is on the 2FA screen
user.update!(status: :disabled)
# Phase B: completing TOTP must NOT create a session
post totp_verification_path, params: {code: code}
assert_redirected_to signin_path
assert_equal 0, user.reload.sessions.count
user.destroy
end
test "an existing session stops authenticating once the user is disabled" do
user = User.create!(email_address: "disabled_session@example.com", password: "password123")
sign_in_as(user)
get root_path
assert_response :success
# Disable bypassing the destroy callback to isolate the request-time lookup
# guard (find_session_by_cookie filtering on active users).
user.update_column(:status, User.statuses[:disabled])
get root_path
assert_response :redirect
assert_match %r{/signin}, response.location
user.sessions.delete_all
user.destroy
end
# ====================
# SESSION TIMEOUT TESTS
# ====================
@@ -199,7 +242,7 @@ class SessionSecurityTest < ActionDispatch::IntegrationTest
slug: "logout-test-app",
app_type: "oidc",
redirect_uris: ["http://localhost:4000/callback"].to_json,
backchannel_logout_uri: "http://localhost:4000/logout",
backchannel_logout_uri: "https://rp.example.com/backchannel-logout",
active: true
)

View File

@@ -0,0 +1,38 @@
require "test_helper"
class PrivateAddressCheckTest < ActiveSupport::TestCase
# internal_host? — DNS-free checks on IP literals and known hostnames
test "flags loopback, private, and link-local IP literals as internal" do
%w[
127.0.0.1
10.0.0.1
172.16.5.5
192.168.1.1
169.254.169.254
0.0.0.0
::1
].each do |host|
assert PrivateAddressCheck.internal_host?(host), "expected #{host} to be internal"
end
end
test "flags localhost-style hostnames as internal" do
assert PrivateAddressCheck.internal_host?("localhost")
assert PrivateAddressCheck.internal_host?("foo.localhost")
assert PrivateAddressCheck.internal_host?("metadata.google.internal")
assert PrivateAddressCheck.internal_host?("")
end
test "does not flag public IP literals as internal" do
refute PrivateAddressCheck.internal_host?("8.8.8.8")
refute PrivateAddressCheck.internal_host?("1.1.1.1")
end
# resolves_to_internal? on IP literals (no DNS needed) exercises the same
# address classification used after resolution.
test "resolves_to_internal? classifies IP literals" do
assert PrivateAddressCheck.resolves_to_internal?("169.254.169.254")
assert PrivateAddressCheck.resolves_to_internal?("127.0.0.1")
refute PrivateAddressCheck.resolves_to_internal?("8.8.8.8")
end
end

View File

@@ -54,6 +54,15 @@ class SecurityMailerTest < ActionMailer::TestCase
assert_bodies_contain email, "Old MacBook"
end
test "suspicious_passkey_used warns about a blocked clone sign-in" do
email = SecurityMailer.suspicious_passkey_used(@user, nickname: "Yubikey-5", **CONTEXT)
assert_equal [@user.email_address], email.to
assert_match(/blocked/i, email.subject)
assert_bodies_contain email, "Yubikey-5"
assert_bodies_match email, /clon/i
end
test "api_key_created includes the key name" do
email = SecurityMailer.api_key_created(@user, name: "CI bot", **CONTEXT)

View File

@@ -10,6 +10,7 @@ class ApiKeyTest < ActiveSupport::TestCase
domain_pattern: "webdav.example.com",
active: true
)
@app.allowed_groups << groups(:everyone)
end
test "generates clk_ prefixed token on create" do
@@ -78,9 +79,8 @@ class ApiKeyTest < ActiveSupport::TestCase
end
test "validates user must have access to application" do
group = groups(:admin_group)
@app.allowed_groups << group
# @user (bob) is not in admin_group
# Restrict the app to admin_group only — bob is not in admin_group.
@app.allowed_groups = [groups(:admin_group)]
key = @user.api_keys.build(name: "No Access", application: @app)
assert_not key.valid?
assert_includes key.errors[:user], "does not have access to this application"

View File

@@ -1,7 +1,84 @@
require "test_helper"
class ApplicationTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
test "sanitizes an SVG icon uploaded via UploadedFile (regression for FileNotFoundError)" do
app = applications(:kavita_app)
svg = %(<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script><path d="M0 0"/></svg>)
tempfile = Tempfile.new(["icon", ".svg"]).tap do |t|
t.write(svg)
t.rewind
end
uploaded = ActionDispatch::Http::UploadedFile.new(
tempfile: tempfile,
filename: "icon.svg",
type: "image/svg+xml"
)
# Previously raised ActiveStorage::FileNotFoundError because the
# before_validation callback called icon.download before the blob was
# uploaded to disk.
assert_nothing_raised do
app.update!(icon: uploaded)
end
cleaned = app.icon.download
refute_match(/<script/i, cleaned)
assert_match(/<path/, cleaned)
ensure
tempfile&.close
tempfile&.unlink
end
test "icon_dark is independently attachable and SVG-sanitized" do
app = applications(:kavita_app)
svg = %(<svg xmlns="http://www.w3.org/2000/svg"><script>boom()</script><circle cx="5" cy="5" r="3"/></svg>)
tempfile = Tempfile.new(["dark", ".svg"]).tap do |t|
t.write(svg)
t.rewind
end
uploaded = ActionDispatch::Http::UploadedFile.new(
tempfile: tempfile,
filename: "dark.svg",
type: "image/svg+xml"
)
assert_nothing_raised do
app.update!(icon_dark: uploaded)
end
assert app.icon_dark.attached?
cleaned = app.icon_dark.download
refute_match(/<script/i, cleaned)
assert_match(/<circle/, cleaned)
ensure
tempfile&.close
tempfile&.unlink
end
test "rejects backchannel_logout_uri pointing at internal addresses (SSRF guard)" do
app = applications(:kavita_app)
internal_uris = [
"http://127.0.0.1/logout",
"http://localhost/logout",
"https://169.254.169.254/latest/meta-data/",
"http://10.0.0.5/logout",
"http://192.168.1.10/logout"
]
internal_uris.each do |uri|
app.backchannel_logout_uri = uri
refute app.valid?, "expected #{uri} to be rejected"
assert_includes app.errors[:backchannel_logout_uri].join, "private, loopback, or link-local"
end
end
test "allows backchannel_logout_uri pointing at a public host" do
app = applications(:kavita_app)
app.backchannel_logout_uri = "https://relying-party.example.com/backchannel-logout"
assert app.valid?, app.errors.full_messages.to_sentence
end
end

View File

@@ -1,6 +1,27 @@
require "test_helper"
class UserTest < ActiveSupport::TestCase
test "disabling a user destroys their active sessions" do
user = User.create!(email_address: "disable_sessions@example.com", password: "password123")
user.sessions.create!
user.sessions.create!
assert_equal 2, user.sessions.count
user.update!(status: :disabled)
assert_equal 0, user.reload.sessions.count
end
test "reactivating or other updates do not destroy sessions" do
user = User.create!(email_address: "keep_sessions@example.com", password: "password123")
user.sessions.create!
# An update that does not change status must leave sessions intact.
user.update!(username: "keepsessions")
assert_equal 1, user.reload.sessions.count
end
test "downcases and strips email_address" do
user = User.new(email_address: " DOWNCASED@EXAMPLE.COM ")
assert_equal("downcased@example.com", user.email_address)
@@ -135,23 +156,36 @@ class UserTest < ActiveSupport::TestCase
assert_equal user, found_user
end
test "admin scope" do
admin_user = User.create!(
email_address: "admin@example.com",
password: "password123",
admin: true
)
regular_user = User.create!(
email_address: "user@example.com",
password: "password123",
admin: false
)
test "admin scope returns users in admin groups" do
admin_group = groups(:admin_group)
admin_user = User.create!(email_address: "admin@example.com", password: "password123")
admin_user.groups << admin_group
regular_user = User.create!(email_address: "user@example.com", password: "password123")
admins = User.admins
assert_includes admins, admin_user
assert_not_includes admins, regular_user
end
test "admin? reflects membership in any admin: true group" do
user = User.create!(email_address: "promoted@example.com", password: "password123")
assert_not user.admin?
user.groups << groups(:admin_group)
assert user.reload.admin?
end
test "after_create auto-joins all auto_assign groups" do
user = User.create!(email_address: "newbie@example.com", password: "password123")
assert_includes user.groups, groups(:everyone)
end
test "skip_auto_assign disables the after_create callback" do
user = User.new(email_address: "skipper@example.com", password: "password123")
user.skip_auto_assign = true
user.save!
assert_not_includes user.groups, groups(:everyone)
end
test "validates email address format" do
user = User.new(email_address: "invalid-email", password: "password123")
assert_not user.valid?

View File

@@ -0,0 +1,29 @@
require "test_helper"
class WebauthnCredentialTest < ActiveSupport::TestCase
# suspicious_sign_count?(new_sign_count) — clone detection per WebAuthn §6.1.1.
# Build an in-memory credential with a given stored sign_count; no persistence
# needed since the method only reads self.sign_count.
def credential(stored:)
WebauthnCredential.new(sign_count: stored)
end
test "does not flag when the authenticator reports no counter (synced passkeys)" do
# Both 0 -> authenticator doesn't implement a counter; must NOT be suspicious.
refute credential(stored: 0).suspicious_sign_count?(0)
# Stored 0, first real use.
refute credential(stored: 0).suspicious_sign_count?(5)
# Stored non-zero but authenticator now reports 0 -> no counter, not a clone.
refute credential(stored: 5).suspicious_sign_count?(0)
end
test "does not flag a normal increasing counter" do
refute credential(stored: 5).suspicious_sign_count?(6)
refute credential(stored: 1).suspicious_sign_count?(1000)
end
test "flags a non-advancing counter as a possible clone" do
assert credential(stored: 5).suspicious_sign_count?(5), "equal count is suspicious"
assert credential(stored: 5).suspicious_sign_count?(3), "decreasing count is suspicious"
end
end

View File

@@ -95,7 +95,8 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
end
test "admin claim should not be included in token" do
@user.update!(admin: true)
# alice is already in admin_group via fixtures, so admin? is true here
assert @user.admin?
token = @service.generate_id_token(@user, @application)

View File

@@ -12,6 +12,19 @@ module SessionTestHelper
Current.session&.destroy!
cookies.delete("session_id")
end
# Attach the auto-assign "everyone" group to the given app so existing tests
# written under the old "empty allowed_groups = public" rule keep working.
# New tests should attach groups explicitly to model real access intent.
def grant_everyone_access(app)
everyone = begin
groups(:everyone)
rescue
Group.find_by(auto_assign: true)
end
app.allowed_groups << everyone unless app.allowed_groups.include?(everyone)
app
end
end
ActiveSupport.on_load(:action_dispatch_integration_test) do