57 Commits

Author SHA1 Message Date
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
Dan Milne
bdb10d86fb Show OIDC env vars on application show page under a toggle
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
Previously the copy-pasteable env-var block only appeared right after
creating an app or regenerating credentials. Operators had no easy way
back to it, so they had to reconstruct OIDC_DISCOVERY_URL etc. from
memory.

Adds a collapsed <details> disclosure inside the OIDC Configuration
card with the same env vars (placeholder for the secret, which can't
be re-shown). Extracts the env-line construction into an
oidc_env_lines helper so the flash panel and the persistent display
share one source of truth.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 21:19:14 +10:00
Dan Milne
37e6e2cc19 Show copy-pasteable OIDC env vars after creating an app
Surfaces OIDC_CLIENT_ID/SECRET, discovery URL, provider name, and PKCE
flag in a single textarea on the credentials flash so the client config
can be dropped straight into a consuming app's .env file.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:30:30 +10:00
Dan Milne
9648b64043 Bump version to 0.10.1
Patch release covering the Ruby 4.0.3, Rails 8.1.3, and transitive
gem updates landed since 0.10.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 00:09:27 +10:00
Dan Milne
a5eba9a5cd Update transitive gems
Routine bundle update on the Ruby 4.0.3 install. No app code changes;
test suite green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 00:09:20 +10:00
Dan Milne
afa90303c8 Bump Rails from 8.1.2 to 8.1.3
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 00:06:22 +10:00
Dan Milne
df5dbfc46c Bump Ruby from 4.0.1 to 4.0.3
Patch release within 4.0.x — security and bug fixes only, no source
changes required. Updated both .ruby-version and the Dockerfile ARG
they're explicitly told to keep in sync.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 00:06:22 +10:00
Dan Milne
2768104c1e Bump version to 0.10.0
Substantial scope since 0.9.0: API keys for forward auth, SecurityMailer
alerts on 8 account-security events, dark mode, Remember-me with proper
browser-session cookie semantics, SvgScrubber for icon XSS, OIDC
auth-code replay revocation, forward-auth caching + rate limiting, and
fixes for broken invitation / password-reset emails.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 00:02:40 +10:00
Dan Milne
2e427a0520 Add SvgScrubber to strip XSS payloads from uploaded app icons
Application#sanitize_svg_icon already runs a Loofah scrubber on every
icon upload, but the scrubber class itself was never tracked. Land it
along with tests covering the four shapes that matter:

- <script> elements stripped entirely
- on* event handlers (onload, onclick, …) removed but the carrying
  element preserved
- attribute values pointing at javascript:/data: URIs rejected
- benign icons round-trip unchanged

Writing the benign-icon test caught a real bug: the attribute allowlist
holds canonical SVG case (viewBox, preserveAspectRatio, gradientUnits,
…) but safe_attribute? downcases the incoming name before comparing,
so legitimate icons were silently losing those attributes on upload.
Fix by comparing against a precomputed lowercase lookup set; the
constant stays readable as canonical SVG case for documentation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 23:57:22 +10:00
Dan Milne
556656d090 Drop Remember-me cookie's Expires when the box is unchecked
Without Remember-me the session cookie was still being written via
`cookies.signed.permanent`, so it survived browser restart on shared
devices — surprising for a user who explicitly opted out of Remember-me.
Issue a browser-session cookie (no Expires) when remember_me is off;
the server-side Session#expires_at still bounds the 24h / 30d window.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 23:54:09 +10:00
Dan Milne
cc93f72f0a Notify users out-of-band when security settings change
Previously only TOTP-enabled triggered an email. Every other
security-relevant change — password change, TOTP disable, passkey
add/remove, API key create/revoke, email address change, backup-code
regeneration — happened silently, so an attacker on a stolen session
could quietly drop 2FA or hijack the email with no signal to the
account holder.

Add SecurityMailer with one method per event. Each email carries the
request IP, user-agent, and timestamp so the user can spot unfamiliar
activity. Email-address changes notify both the old and new addresses
with directional language; the old-address copy explicitly warns that
whoever made the change can now receive password reset emails.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 23:52:12 +10:00
Dan Milne
09e9b32e46 Run SolidQueue supervisor inside Puma in production
Production switched the queue adapter back to :solid_queue but the Puma
plugin had been removed, so jobs (e.g. invitation resend) enqueued fine
but never ran. Clinch ships as a single container, so always start the
supervisor in production rather than gating on SOLID_QUEUE_IN_PUMA.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 23:51:37 +10:00
Dan Milne
7d352654fd Fix broken password reset email templates
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
Both templates called `@user.password_reset_token` and
`@user.password_reset_token_expires_in`, which don't exist —
`generates_token_for` only adds class-level helpers, not instance
accessors. Every password reset email was failing at render time.
Use `generate_token_for(:password_reset)` and a literal expiry string
matching the 1-hour TTL on the token.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 23:40:43 +10:00
Dan Milne
e39721c7e6 Fix broken invitation email text template
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 text part used non-existent helpers (`invite_url`,
`@user.invitation_login_token`) and Ruby string interpolation in an ERB
file, so multipart delivery failed at render time and no invite mail
went out. Mirror the HTML template instead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 23:39:29 +10:00
Dan Milne
5178cf3d81 Drop redundant MemoryStore internals peek from fa_token creation test
The refute_match on response.location already proves create_forward_auth_token
did nothing: the cache.write and the URL rewrite are back-to-back with no
branch between them, so the URL lacking fa_token= implies no cache entry
was written. The instance_variable_get(:@data) inspection was both redundant
and coupled to MemoryStore's private layout.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-04-20 20:28:28 +10:00
Dan Milne
2d5650e620 Bind forward-auth fa_token to its destination host
An observed fa_token (via Referer leaks, access logs, JS monitors)
could previously be redeemed against a different reverse-proxied app
within the 60s TTL. The token now stores the destination host at
creation and the verifier rejects mismatches without burning the cache
entry, so legitimate destinations can still redeem.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-04-20 19:04:53 +10:00
Dan Milne
7f0d3d3900 Tighten TOTP enrollment comments to explain the threat, not the change
Replace the changelog-flavored "view no longer round-trips" line with a
one-liner naming the actual threat (session-holder substituting a secret
they control). Drop the narration comment above session.delete +
deliver_later -- the identifiers already say what the two lines do.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-04-20 18:58:39 +10:00
Dan Milne
b876e02c3a Hold TOTP enrollment secret server-side and email user on activation
TOTP enrollment previously round-tripped the generated secret through a
hidden form field and saved whatever the client submitted, letting an
attacker with session access enroll a 2FA device they control by posting
their own secret plus a matching code. Stash the secret in the session
at GET /totp/new, read it only from the session at POST /totp, and drop
the hidden field from the view. Notify the user by email on successful
enrollment so unauthorized activations are visible even if a new vector
appears later.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-04-20 18:17:50 +10:00
Dan Milne
93d8381214 Nullify token to auth-code FK on delete so cleanup job can purge codes
The FK added in b7fa499 defaulted to ON DELETE RESTRICT, which means
OidcTokenCleanupJob#perform would fail when deleting auth codes older
than 7 days if any refresh token (whose expiry is days-to-weeks) still
referenced them. Switch both token FKs to ON DELETE SET NULL so token
rows survive the code deletion with a NULL FK, preserving the audit
trail the cleanup job deliberately keeps.

Add a regression test covering the exact scenario: a 10-day-old auth
code with a token still pointing at it -> cleanup deletes the code,
token survives, token FK is nulled.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-04-20 18:12:03 +10:00
Dan Milne
2068675173 Collapse auth-code replay revocation to two update_all queries
The previous implementation iterated find_each(&:revoke!) on both the
access-token and refresh-token associations. OidcAccessToken#revoke!
also cascades to its refresh tokens, so a chain of N access tokens with
their refresh tokens produced ~3N UPDATEs (outer loop + cascade +
outer refresh loop double-writing) all while holding a pessimistic
lock on the auth_code row. Replace with scoped update_all on each
association -- 2 UPDATEs total, no behavior change.

Also hoist the repeated refresh_token_record.oidc_authorization_code
lookup in the rotation path to a named local and drop the duplicated
inline comment.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-04-20 18:11:54 +10:00
Dan Milne
b7fa49953c Revoke full token chain on OIDC authorization-code replay
The replay handler previously used a created_at time-range filter to
target access tokens and called update_all(expires_at:), which left
revoked_at nil, skipped refresh tokens entirely, and could miss or
falsely catch tokens from concurrent flows. Add an oidc_authorization_code
FK on both token tables, carry it through refresh-token rotation, and
use the association to revoke every descendant via revoke! (which sets
revoked_at and cascades access -> refresh).

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-04-20 17:39:08 +10:00
Dan Milne
b7dd3c02e7 Extract client_id and redirect_uri validation into before_actions
The authorize action opened with ~55 lines of parameter validation that
ran before any business logic. Move the two RFC 6749 §4.1.2.1 checks
(client_id lookup, redirect_uri registration) into set_application and
validate_redirect_uri before_actions. The action body now starts at the
point where errors may legitimately redirect back to the client.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-04-20 17:29:36 +10:00
Dan Milne
17a464fd15 Fix OIDC claims validation against undefined scopes variable
The authorize action called validate_claims_against_scopes with
requested_scopes before that local was assigned (assignment was ~100
lines later), raising NameError whenever a client passed a claims=
parameter. Move the scope normalization above the claims validation.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-04-20 17:26:46 +10:00
Dan Milne
9197524c88 Add remember me checkbox, center and narrow sign-in form
- Add "Remember me for 30 days" checkbox (30-day vs 24-hour session expiry)
- Center heading and constrain form width to max-w-md
- Preserve remember_me preference through TOTP and WebAuthn auth flows

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 11:22:51 +10:00
Dan Milne
2235924f37 Harden OIDC, add SVG sanitization, improve form UX and security defaults
Remove PKCE plain method support (S256 only), enforce openid scope requirement,
filter to supported scopes, strip reserved claims from custom claims as
defense-in-depth, sanitize SVG icons with Loofah, add global input padding,
switch session cookies to SameSite=Lax, use Session.active scope, and remove
unsafe-eval from CSP.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 21:06:51 +10:00
128 changed files with 3433 additions and 732 deletions

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

@@ -0,0 +1,56 @@
name: Build and publish image
on:
push:
branches: [ main ]
tags: [ 'v*' ]
# Only one build per ref at a time; cancel superseded main builds.
concurrency:
group: build-${{ github.ref }}
cancel-in-progress: ${{ github.ref == 'refs/heads/main' }}
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write # Required to push to GHCR
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: Extract image metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=edge,branch=main
type=sha,prefix=sha-,format=short,enable={{is_default_branch}}
type=semver,pattern=v{{version}}
type=semver,pattern=v{{major}}.{{minor}}
flavor: |
latest=auto
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

View File

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

View File

@@ -1,7 +1,7 @@
source "https://rubygems.org" source "https://rubygems.org"
# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main" # Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main"
gem "rails", "~> 8.1.2" gem "rails", "~> 8.1.3"
# The modern asset pipeline for Rails [https://github.com/rails/propshaft] # The modern asset pipeline for Rails [https://github.com/rails/propshaft]
gem "propshaft" gem "propshaft"
# Use sqlite3 as the database for Active Record # Use sqlite3 as the database for Active Record

View File

@@ -1,31 +1,31 @@
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
action_text-trix (2.1.16) action_text-trix (2.1.19)
railties railties
actioncable (8.1.2) actioncable (8.1.3)
actionpack (= 8.1.2) actionpack (= 8.1.3)
activesupport (= 8.1.2) activesupport (= 8.1.3)
nio4r (~> 2.0) nio4r (~> 2.0)
websocket-driver (>= 0.6.1) websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6) zeitwerk (~> 2.6)
actionmailbox (8.1.2) actionmailbox (8.1.3)
actionpack (= 8.1.2) actionpack (= 8.1.3)
activejob (= 8.1.2) activejob (= 8.1.3)
activerecord (= 8.1.2) activerecord (= 8.1.3)
activestorage (= 8.1.2) activestorage (= 8.1.3)
activesupport (= 8.1.2) activesupport (= 8.1.3)
mail (>= 2.8.0) mail (>= 2.8.0)
actionmailer (8.1.2) actionmailer (8.1.3)
actionpack (= 8.1.2) actionpack (= 8.1.3)
actionview (= 8.1.2) actionview (= 8.1.3)
activejob (= 8.1.2) activejob (= 8.1.3)
activesupport (= 8.1.2) activesupport (= 8.1.3)
mail (>= 2.8.0) mail (>= 2.8.0)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
actionpack (8.1.2) actionpack (8.1.3)
actionview (= 8.1.2) actionview (= 8.1.3)
activesupport (= 8.1.2) activesupport (= 8.1.3)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
rack (>= 2.2.4) rack (>= 2.2.4)
rack-session (>= 1.0.1) rack-session (>= 1.0.1)
@@ -33,36 +33,36 @@ GEM
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6) rails-html-sanitizer (~> 1.6)
useragent (~> 0.16) useragent (~> 0.16)
actiontext (8.1.2) actiontext (8.1.3)
action_text-trix (~> 2.1.15) action_text-trix (~> 2.1.15)
actionpack (= 8.1.2) actionpack (= 8.1.3)
activerecord (= 8.1.2) activerecord (= 8.1.3)
activestorage (= 8.1.2) activestorage (= 8.1.3)
activesupport (= 8.1.2) activesupport (= 8.1.3)
globalid (>= 0.6.0) globalid (>= 0.6.0)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
actionview (8.1.2) actionview (8.1.3)
activesupport (= 8.1.2) activesupport (= 8.1.3)
builder (~> 3.1) builder (~> 3.1)
erubi (~> 1.11) erubi (~> 1.11)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6) rails-html-sanitizer (~> 1.6)
activejob (8.1.2) activejob (8.1.3)
activesupport (= 8.1.2) activesupport (= 8.1.3)
globalid (>= 0.3.6) globalid (>= 0.3.6)
activemodel (8.1.2) activemodel (8.1.3)
activesupport (= 8.1.2) activesupport (= 8.1.3)
activerecord (8.1.2) activerecord (8.1.3)
activemodel (= 8.1.2) activemodel (= 8.1.3)
activesupport (= 8.1.2) activesupport (= 8.1.3)
timeout (>= 0.4.0) timeout (>= 0.4.0)
activestorage (8.1.2) activestorage (8.1.3)
actionpack (= 8.1.2) actionpack (= 8.1.3)
activejob (= 8.1.2) activejob (= 8.1.3)
activerecord (= 8.1.2) activerecord (= 8.1.3)
activesupport (= 8.1.2) activesupport (= 8.1.3)
marcel (~> 1.0) marcel (~> 1.0)
activesupport (8.1.2) activesupport (8.1.3)
base64 base64
bigdecimal bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1) concurrent-ruby (~> 1.0, >= 1.3.1)
@@ -75,19 +75,19 @@ GEM
securerandom (>= 0.3) securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5) tzinfo (~> 2.0, >= 2.0.5)
uri (>= 0.13.1) uri (>= 0.13.1)
addressable (2.8.8) addressable (2.9.0)
public_suffix (>= 2.0.2, < 8.0) public_suffix (>= 2.0.2, < 8.0)
android_key_attestation (0.3.0) android_key_attestation (0.3.0)
ast (2.4.3) ast (2.4.3)
base64 (0.3.0) base64 (0.3.0)
bcrypt (3.1.21) bcrypt (3.1.22)
bcrypt_pbkdf (1.1.2) bcrypt_pbkdf (1.1.2)
bigdecimal (4.0.1) bigdecimal (4.1.2)
bindata (2.5.1) bindata (2.5.1)
bindex (0.8.1) bindex (0.8.1)
bootsnap (1.20.1) bootsnap (1.24.6)
msgpack (~> 1.2) msgpack (~> 1.2)
brakeman (7.1.2) brakeman (8.0.5)
racc racc
builder (3.3.0) builder (3.3.0)
bundler-audit (0.9.3) bundler-audit (0.9.3)
@@ -102,11 +102,11 @@ GEM
rack-test (>= 0.6.3) rack-test (>= 0.6.3)
regexp_parser (>= 1.5, < 3.0) regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2) xpath (~> 3.2)
cbor (0.5.10.1) cbor (0.5.10.3)
childprocess (5.1.0) childprocess (5.1.0)
logger (~> 1.5) logger (~> 1.5)
chunky_png (1.4.0) chunky_png (1.4.0)
concurrent-ruby (1.3.6) concurrent-ruby (1.3.7)
connection_pool (3.0.2) connection_pool (3.0.2)
cose (1.3.1) cose (1.3.1)
cbor (~> 0.5.9) cbor (~> 0.5.9)
@@ -120,44 +120,44 @@ GEM
dotenv (3.2.0) dotenv (3.2.0)
drb (2.2.3) drb (2.2.3)
ed25519 (1.4.0) ed25519 (1.4.0)
erb (6.0.2) erb (6.0.4)
erubi (1.13.1) erubi (1.13.1)
et-orbi (1.4.0) et-orbi (1.4.0)
tzinfo tzinfo
ffi (1.17.3-aarch64-linux-gnu) ffi (1.17.4-aarch64-linux-gnu)
ffi (1.17.3-aarch64-linux-musl) ffi (1.17.4-aarch64-linux-musl)
ffi (1.17.3-arm-linux-gnu) ffi (1.17.4-arm-linux-gnu)
ffi (1.17.3-arm-linux-musl) ffi (1.17.4-arm-linux-musl)
ffi (1.17.3-arm64-darwin) ffi (1.17.4-arm64-darwin)
ffi (1.17.3-x86_64-linux-gnu) ffi (1.17.4-x86_64-linux-gnu)
ffi (1.17.3-x86_64-linux-musl) ffi (1.17.4-x86_64-linux-musl)
fugit (1.12.1) fugit (1.12.2)
et-orbi (~> 1.4) et-orbi (~> 1.4)
raabro (~> 1.4) raabro (~> 1.4)
globalid (1.3.0) globalid (1.3.0)
activesupport (>= 6.1) activesupport (>= 6.1)
i18n (1.14.8) i18n (1.15.2)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
image_processing (1.14.0) image_processing (1.14.0)
mini_magick (>= 4.9.5, < 6) mini_magick (>= 4.9.5, < 6)
ruby-vips (>= 2.0.17, < 3) ruby-vips (>= 2.0.17, < 3)
importmap-rails (2.2.2) importmap-rails (2.2.3)
actionpack (>= 6.0.0) actionpack (>= 6.0.0)
activesupport (>= 6.0.0) activesupport (>= 6.0.0)
railties (>= 6.0.0) railties (>= 6.0.0)
io-console (0.8.2) io-console (0.8.2)
irb (1.17.0) irb (1.18.0)
pp (>= 0.6.0) pp (>= 0.6.0)
prism (>= 1.3.0) prism (>= 1.3.0)
rdoc (>= 4.0.0) rdoc (>= 4.0.0)
reline (>= 0.4.2) reline (>= 0.4.2)
jbuilder (2.14.1) jbuilder (2.15.1)
actionview (>= 7.0.0) actionview (>= 7.0.0)
activesupport (>= 7.0.0) activesupport (>= 7.0.0)
json (2.19.0) json (2.19.9)
jwt (3.1.2) jwt (3.2.0)
base64 base64
kamal (2.10.1) kamal (2.12.0)
activesupport (>= 7.0) activesupport (>= 7.0)
base64 (~> 0.2) base64 (~> 0.2)
bcrypt_pbkdf (~> 1.0) bcrypt_pbkdf (~> 1.0)
@@ -177,7 +177,7 @@ GEM
launchy (>= 2.2, < 4) launchy (>= 2.2, < 4)
lint_roller (1.1.0) lint_roller (1.1.0)
logger (1.7.0) logger (1.7.0)
loofah (2.25.0) loofah (2.25.1)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.12.0) nokogiri (>= 1.12.0)
mail (2.9.0) mail (2.9.0)
@@ -186,14 +186,14 @@ GEM
net-imap net-imap
net-pop net-pop
net-smtp net-smtp
marcel (1.1.0) marcel (1.2.1)
matrix (0.4.3) matrix (0.4.3)
mini_magick (5.3.1) mini_magick (5.3.1)
logger logger
mini_mime (1.1.5) mini_mime (1.1.5)
minitest (5.27.0) minitest (5.27.0)
msgpack (1.8.0) msgpack (1.8.3)
net-imap (0.6.3) net-imap (0.6.4.1)
date date
net-protocol net-protocol
net-pop (0.1.2) net-pop (0.1.2)
@@ -206,68 +206,68 @@ GEM
net-ssh (>= 5.0.0, < 8.0.0) net-ssh (>= 5.0.0, < 8.0.0)
net-smtp (0.5.1) net-smtp (0.5.1)
net-protocol net-protocol
net-ssh (7.3.0) net-ssh (7.3.2)
nio4r (2.7.5) nio4r (2.7.5)
nokogiri (1.19.1-aarch64-linux-gnu) nokogiri (1.19.4-aarch64-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.19.1-aarch64-linux-musl) nokogiri (1.19.4-aarch64-linux-musl)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.19.1-arm-linux-gnu) nokogiri (1.19.4-arm-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.19.1-arm-linux-musl) nokogiri (1.19.4-arm-linux-musl)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.19.1-arm64-darwin) nokogiri (1.19.4-arm64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.19.1-x86_64-linux-gnu) nokogiri (1.19.4-x86_64-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.19.1-x86_64-linux-musl) nokogiri (1.19.4-x86_64-linux-musl)
racc (~> 1.4) racc (~> 1.4)
openssl (4.0.0) openssl (4.0.2)
openssl-signature_algorithm (1.3.0) openssl-signature_algorithm (1.3.0)
openssl (> 2.0) openssl (> 2.0)
ostruct (0.6.3) ostruct (0.6.3)
parallel (1.27.0) parallel (2.1.0)
parser (3.3.10.0) parser (3.3.11.1)
ast (~> 2.4.1) ast (~> 2.4.1)
racc racc
pp (0.6.3) pp (0.6.3)
prettyprint prettyprint
prettyprint (0.2.0) prettyprint (0.2.0)
prism (1.7.0) prism (1.9.0)
propshaft (1.3.1) propshaft (1.3.2)
actionpack (>= 7.0.0) actionpack (>= 7.0.0)
activesupport (>= 7.0.0) activesupport (>= 7.0.0)
rack rack
psych (5.3.1) psych (5.4.0)
date date
stringio stringio
public_suffix (7.0.0) public_suffix (7.0.5)
puma (7.1.0) puma (8.0.2)
nio4r (~> 2.0) nio4r (~> 2.0)
raabro (1.4.0) raabro (1.4.0)
racc (1.8.1) racc (1.8.1)
rack (3.2.5) rack (3.2.6)
rack-session (2.1.1) rack-session (2.1.2)
base64 (>= 0.1.0) base64 (>= 0.1.0)
rack (>= 3.0.0) rack (>= 3.0.0)
rack-test (2.2.0) rack-test (2.2.0)
rack (>= 1.3) rack (>= 1.3)
rackup (2.3.1) rackup (2.3.1)
rack (>= 3) rack (>= 3)
rails (8.1.2) rails (8.1.3)
actioncable (= 8.1.2) actioncable (= 8.1.3)
actionmailbox (= 8.1.2) actionmailbox (= 8.1.3)
actionmailer (= 8.1.2) actionmailer (= 8.1.3)
actionpack (= 8.1.2) actionpack (= 8.1.3)
actiontext (= 8.1.2) actiontext (= 8.1.3)
actionview (= 8.1.2) actionview (= 8.1.3)
activejob (= 8.1.2) activejob (= 8.1.3)
activemodel (= 8.1.2) activemodel (= 8.1.3)
activerecord (= 8.1.2) activerecord (= 8.1.3)
activestorage (= 8.1.2) activestorage (= 8.1.3)
activesupport (= 8.1.2) activesupport (= 8.1.3)
bundler (>= 1.15.0) bundler (>= 1.15.0)
railties (= 8.1.2) railties (= 8.1.3)
rails-dom-testing (2.3.0) rails-dom-testing (2.3.0)
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
minitest minitest
@@ -275,9 +275,9 @@ GEM
rails-html-sanitizer (1.7.0) rails-html-sanitizer (1.7.0)
loofah (~> 2.25) loofah (~> 2.25)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
railties (8.1.2) railties (8.1.3)
actionpack (= 8.1.2) actionpack (= 8.1.3)
activesupport (= 8.1.2) activesupport (= 8.1.3)
irb (~> 1.13) irb (~> 1.13)
rackup (>= 1.0.0) rackup (>= 1.0.0)
rake (>= 12.2) rake (>= 12.2)
@@ -285,32 +285,32 @@ GEM
tsort (>= 0.2) tsort (>= 0.2)
zeitwerk (~> 2.6) zeitwerk (~> 2.6)
rainbow (3.1.1) rainbow (3.1.1)
rake (13.3.1) rake (13.4.2)
rdoc (7.2.0) rdoc (7.2.0)
erb erb
psych (>= 4.0.0) psych (>= 4.0.0)
tsort tsort
regexp_parser (2.11.3) regexp_parser (2.12.0)
reline (0.6.3) reline (0.6.3)
io-console (~> 0.5) io-console (~> 0.5)
rexml (3.4.4) rexml (3.4.4)
rotp (6.3.0) rotp (6.3.0)
rqrcode (3.1.1) rqrcode (3.2.0)
chunky_png (~> 1.0) chunky_png (~> 1.0)
rqrcode_core (~> 2.0) rqrcode_core (~> 2.0)
rqrcode_core (2.0.1) rqrcode_core (2.1.0)
rubocop (1.81.7) rubocop (1.87.0)
json (~> 2.3) json (~> 2.3)
language_server-protocol (~> 3.17.0.2) language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0) lint_roller (~> 1.1.0)
parallel (~> 1.10) parallel (>= 1.10)
parser (>= 3.3.0.2) parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0) rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0) regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.47.1, < 2.0) rubocop-ast (>= 1.49.0, < 2.0)
ruby-progressbar (~> 1.7) ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0) unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.49.0) rubocop-ast (1.49.1)
parser (>= 3.3.7.2) parser (>= 3.3.7.2)
prism (~> 1.7) prism (~> 1.7)
rubocop-performance (1.26.1) rubocop-performance (1.26.1)
@@ -321,29 +321,30 @@ GEM
ruby-vips (2.3.0) ruby-vips (2.3.0)
ffi (~> 1.12) ffi (~> 1.12)
logger logger
rubyzip (3.2.2) rubyzip (3.4.0)
safety_net_attestation (0.5.0) safety_net_attestation (0.5.0)
jwt (>= 2.0, < 4.0) jwt (>= 2.0, < 4.0)
securerandom (0.4.1) securerandom (0.4.1)
selenium-webdriver (4.39.0) selenium-webdriver (4.45.0)
base64 (~> 0.2) base64 (~> 0.2)
logger (~> 1.4) logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5) rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 4.0) rubyzip (>= 1.2.2, < 4.0)
websocket (~> 1.0) websocket (~> 1.0)
sentry-rails (6.2.0) sentry-rails (6.6.2)
railties (>= 5.2.0) railties (>= 5.2.0)
sentry-ruby (~> 6.2.0) sentry-ruby (~> 6.6.2)
sentry-ruby (6.2.0) sentry-ruby (6.6.2)
bigdecimal bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2) concurrent-ruby (~> 1.0, >= 1.0.2)
logger
simplecov (0.22.0) simplecov (0.22.0)
docile (~> 1.1) docile (~> 1.1)
simplecov-html (~> 0.11) simplecov-html (~> 0.11)
simplecov_json_formatter (~> 0.1) simplecov_json_formatter (~> 0.1)
simplecov-html (0.13.2) simplecov-html (0.13.2)
simplecov_json_formatter (0.1.4) simplecov_json_formatter (0.1.4)
solid_cable (3.0.12) solid_cable (4.0.0)
actioncable (>= 7.2) actioncable (>= 7.2)
activejob (>= 7.2) activejob (>= 7.2)
activerecord (>= 7.2) activerecord (>= 7.2)
@@ -352,20 +353,20 @@ GEM
activejob (>= 7.2) activejob (>= 7.2)
activerecord (>= 7.2) activerecord (>= 7.2)
railties (>= 7.2) railties (>= 7.2)
solid_queue (1.2.4) solid_queue (1.4.0)
activejob (>= 7.1) activejob (>= 7.1)
activerecord (>= 7.1) activerecord (>= 7.1)
concurrent-ruby (>= 1.3.1) concurrent-ruby (>= 1.3.1)
fugit (~> 1.11) fugit (~> 1.11)
railties (>= 7.1) railties (>= 7.1)
thor (>= 1.3.1) thor (>= 1.3.1)
sqlite3 (2.9.0-aarch64-linux-gnu) sqlite3 (2.9.5-aarch64-linux-gnu)
sqlite3 (2.9.0-aarch64-linux-musl) sqlite3 (2.9.5-aarch64-linux-musl)
sqlite3 (2.9.0-arm-linux-gnu) sqlite3 (2.9.5-arm-linux-gnu)
sqlite3 (2.9.0-arm-linux-musl) sqlite3 (2.9.5-arm-linux-musl)
sqlite3 (2.9.0-arm64-darwin) sqlite3 (2.9.5-arm64-darwin)
sqlite3 (2.9.0-x86_64-linux-gnu) sqlite3 (2.9.5-x86_64-linux-gnu)
sqlite3 (2.9.0-x86_64-linux-musl) sqlite3 (2.9.5-x86_64-linux-musl)
sshkit (1.25.0) sshkit (1.25.0)
base64 base64
logger logger
@@ -373,10 +374,10 @@ GEM
net-sftp (>= 2.1.2) net-sftp (>= 2.1.2)
net-ssh (>= 2.8.0) net-ssh (>= 2.8.0)
ostruct ostruct
standard (1.52.0) standard (1.55.0)
language_server-protocol (~> 3.17.0.2) language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.0) lint_roller (~> 1.0)
rubocop (~> 1.81.7) rubocop (~> 1.87.0)
standard-custom (~> 1.0.0) standard-custom (~> 1.0.0)
standard-performance (~> 1.8) standard-performance (~> 1.8)
standard-custom (1.0.2) standard-custom (1.0.2)
@@ -388,27 +389,27 @@ GEM
stimulus-rails (1.3.4) stimulus-rails (1.3.4)
railties (>= 6.0.0) railties (>= 6.0.0)
stringio (3.2.0) stringio (3.2.0)
tailwindcss-rails (4.4.0) tailwindcss-rails (4.6.0)
railties (>= 7.0.0) railties (>= 7.0.0)
tailwindcss-ruby (~> 4.0) tailwindcss-ruby (~> 4.0)
tailwindcss-ruby (4.1.18) tailwindcss-ruby (4.3.1)
tailwindcss-ruby (4.1.18-aarch64-linux-gnu) tailwindcss-ruby (4.3.1-aarch64-linux-gnu)
tailwindcss-ruby (4.1.18-aarch64-linux-musl) tailwindcss-ruby (4.3.1-aarch64-linux-musl)
tailwindcss-ruby (4.1.18-arm64-darwin) tailwindcss-ruby (4.3.1-arm64-darwin)
tailwindcss-ruby (4.1.18-x86_64-linux-gnu) tailwindcss-ruby (4.3.1-x86_64-linux-gnu)
tailwindcss-ruby (4.1.18-x86_64-linux-musl) tailwindcss-ruby (4.3.1-x86_64-linux-musl)
thor (1.5.0) thor (1.5.0)
thruster (0.1.17) thruster (0.1.21)
thruster (0.1.17-aarch64-linux) thruster (0.1.21-aarch64-linux)
thruster (0.1.17-arm64-darwin) thruster (0.1.21-arm64-darwin)
thruster (0.1.17-x86_64-linux) thruster (0.1.21-x86_64-linux)
timeout (0.6.0) timeout (0.6.1)
tpm-key_attestation (0.14.1) tpm-key_attestation (0.14.1)
bindata (~> 2.4) bindata (~> 2.4)
openssl (> 2.0) openssl (> 2.0)
openssl-signature_algorithm (~> 1.0) openssl-signature_algorithm (~> 1.0)
tsort (0.2.0) tsort (0.2.0)
turbo-rails (2.0.20) turbo-rails (2.0.23)
actionpack (>= 7.1.0) actionpack (>= 7.1.0)
railties (>= 7.1.0) railties (>= 7.1.0)
tzinfo (2.0.6) tzinfo (2.0.6)
@@ -418,11 +419,10 @@ GEM
unicode-emoji (4.2.0) unicode-emoji (4.2.0)
uri (1.1.1) uri (1.1.1)
useragent (0.16.11) useragent (0.16.11)
web-console (4.2.1) web-console (4.3.0)
actionview (>= 6.0.0) actionview (>= 8.0.0)
activemodel (>= 6.0.0)
bindex (>= 0.4.0) bindex (>= 0.4.0)
railties (>= 6.0.0) railties (>= 8.0.0)
webauthn (3.4.3) webauthn (3.4.3)
android_key_attestation (~> 0.3.0) android_key_attestation (~> 0.3.0)
bindata (~> 2.4) bindata (~> 2.4)
@@ -432,13 +432,13 @@ GEM
safety_net_attestation (~> 0.5.0) safety_net_attestation (~> 0.5.0)
tpm-key_attestation (~> 0.14.0) tpm-key_attestation (~> 0.14.0)
websocket (1.2.11) websocket (1.2.11)
websocket-driver (0.8.0) websocket-driver (0.8.1)
base64 base64
websocket-extensions (>= 0.1.0) websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5) websocket-extensions (0.1.5)
xpath (3.2.0) xpath (3.2.0)
nokogiri (~> 1.8) nokogiri (~> 1.8)
zeitwerk (2.7.5) zeitwerk (2.8.2)
PLATFORMS PLATFORMS
aarch64-linux aarch64-linux
@@ -469,7 +469,7 @@ DEPENDENCIES
propshaft propshaft
public_suffix (~> 7.0) public_suffix (~> 7.0)
puma (>= 5.0) puma (>= 5.0)
rails (~> 8.1.2) rails (~> 8.1.3)
rotp (~> 6.3) rotp (~> 6.3)
rqrcode (~> 3.1) rqrcode (~> 3.1)
selenium-webdriver selenium-webdriver
@@ -490,4 +490,4 @@ DEPENDENCIES
webauthn (~> 3.0) webauthn (~> 3.0)
BUNDLED WITH BUNDLED WITH
4.0.3 4.0.6

110
SECURITY_REVIEW_TODO.md Normal file
View File

@@ -0,0 +1,110 @@
# Security Review — Tracking
Status of findings from the multi-surface security review (OIDC/OAuth2, ForwardAuth,
WebAuthn/TOTP, sessions, admin/config). Work landed on branch
`security/forward-auth-and-consent-csrf`.
## ✅ Done (branch `security/forward-auth-and-consent-csrf`)
All HIGH findings are closed. Each fix has tests; suite is green.
| Commit | Fix | Sev |
|--------|-----|-----|
| `703d24e` | ForwardAuth fail-open when no host header; consent endpoint CSRF | HIGH ×2 |
| `8a095e4` | Bearer API-key skipped group check at use-time | HIGH |
| `96a657e` | Open redirect via unvalidated `X-Forwarded-Host` in login redirect | HIGH |
| `84ed462` | `CLINCH_HOST` made mandatory in deployed envs; dropped request-host fallback | MEDIUM |
| `f38ac2e` | TOTP code replay within drift window (+ latent plaintext backup-code bug) | HIGH |
| `406a79d` | SSRF via `backchannel_logout_uri` (metadata/loopback/RFC1918) | HIGH |
| `57d7d1f` | Host-auth regex unanchored (`evil-example.com` matched) | HIGH |
| `89bd5f1` | Disabled user could complete 2FA mid-flow / keep session; enforce active status | HIGH |
| `cd862c7` | TOTP/backup/OAuth/PKCE `code` params not filtered from logs | MEDIUM |
| `2426687` | `revoke_family!` didn't revoke access tokens on refresh-token reuse | HIGH |
| `44892e3` | WebAuthn clone detection logged but didn't block; false-positive on synced passkeys | HIGH |
| `d49e7ce` | CSP `unsafe-inline` removed (script-src + style-src → nonces) | HIGH |
**Verified false positive (no change):** PKCE *is* required by default —
`require_pkce` column defaults to `true` (`db/schema.rb`), token endpoint enforces
it, admin UI exposes the opt-out. Operational check: confirm no legacy confidential
apps sit on `require_pkce = false`.
**Follow-up before relying on CSP change:** do one manual browser pass (DevTools
console) on `/signin`, OAuth consent, a Turbo navigation, dark-mode toggle, and a
WebAuthn sign-in — expect zero CSP violations. Dev is report-only so violations
surface as warnings without breaking. Fallback if style-src surprises: keep
`style-src 'unsafe-inline'`, ship script-src only.
## ☐ Remaining — MEDIUM
- [ ] **`id_token_hint` ignored at OIDC logout** — any client can redirect logout to
any other registered client's post-logout URI. Validate the hint's `aud` and
scope the redirect to that app. `app/controllers/oidc_controller.rb` (logout).
- [ ] **`offline_access` doesn't gate refresh-token issuance** — refresh tokens are
minted unconditionally; gate on the granted scope.
`app/controllers/oidc_controller.rb` (authorization_code grant, ~line 564).
- [ ] **CSP-report endpoint hardening** — unauthenticated, no rate limit / body-size
cap, logs raw CRLF (log injection). Sanitize values, cap size, rate-limit.
`app/controllers/api/csp_controller.rb`.
- [ ] **Port not stripped from `X-Forwarded-Host`** in main verify + bearer paths →
403 outages on non-standard ports (also a correctness bug). Reuse the
port-stripping done in `check_forward_auth_token`.
`app/controllers/api/forward_auth_controller.rb`.
- [ ] **WebAuthn `acr:"2"` without enforced user verification** — `user_verification:
"preferred"` lets a PIN-less key authenticate yet reports verified 2FA. Use
`"required"`, or downgrade `acr` to `"1"` when the UV flag is absent.
`app/controllers/sessions_controller.rb` (webauthn_challenge/verify),
`app/controllers/webauthn_controller.rb`.
- [ ] **`RESERVED_CLAIMS` incomplete** — missing `at_hash`/`auth_time`/`acr`; and
`ApplicationUserClaims` has no reserved-name validation (User/Group do). Could
let a custom claim overwrite a security claim. `app/services/oidc_jwt_service.rb`,
`app/models/application_user_claim.rb`.
- [ ] **`reset_session` not called on login** — defensive best practice for an IdP;
clears pre-auth session state. `app/controllers/concerns/authentication.rb`
(`start_new_session_for`).
- [x] **Hardcoded private IP `192.168.2.246`** in `config/environments/production.rb`
— removed; it was redundant with the `192.168.0.0/16` regex already in the
`CLINCH_ALLOW_INTERNAL_IPS` block.
- [ ] **CSP `form-action` widened by unvalidated `redirect_uri`** before auth — only
add to `form-action` if the client_id+redirect_uri is a registered pair.
`app/controllers/concerns/authentication.rb` (`allow_oauth_redirect_in_csp`).
- [ ] **SVG `style` attribute permits `url()`/`expression()`** — mitigated today by
`Content-Disposition: attachment`, but fragile. Sanitize CSS values or drop
`style` from the allowlist. `app/models/svg_scrubber.rb`.
- [ ] **WebAuthn error messages leak internals** — return generic errors to client,
log detail server-side. `app/controllers/sessions_controller.rb`,
`app/controllers/webauthn_controller.rb`.
- [ ] **Account enumeration via webauthn challenge** — distinguishes "user not found"
vs "no passkey". Return a uniform message. `app/controllers/sessions_controller.rb`
(`webauthn_challenge`).
- [ ] **`token_family_id` only 31 bits** (`SecureRandom.random_number(2**31)`) —
birthday collision ~46k; use a UUID/string. `app/models/oidc_refresh_token.rb`.
- [ ] **Session cookie uses sequential integer DB id** — HMAC-signed so not forgeable,
but consider a random `token` column (Rails 8 generator default).
`app/models/session.rb`, `app/controllers/concerns/authentication.rb`.
- [ ] **Login rate-limit is IP-only** — no account lockout (distributed brute force /
credential stuffing). Add failed-count + `locked_until` on users.
- [ ] **Backup-code rate limit not reset on success** and is cache-based (resets on
cache flush). Reset on success; consider DB-backed counter. `app/models/user.rb`.
## ☐ Remaining — LOW / INFO
- [ ] Public clients can't revoke their own tokens (revoke endpoint requires secret).
- [ ] Basic-auth client creds not URL-decoded per RFC 6749 §2.3.1.
- [ ] `token_hmac` columns nullable at DB level despite model `presence: true`.
- [ ] Group names allow commas → injection into `X-Remote-Groups` (false memberships
downstream). Add a format validator. `app/models/group.rb`.
- [ ] `fa_token` leaks in redirect URL / Referer / history (60s TTL, host-bound).
- [ ] Admin `domain_pattern` allows ReDoS — add a format validator.
`app/models/application.rb`.
- [ ] Forced-TOTP-setup login path can redirect-loop (`totp_required` + no TOTP).
- [ ] `complete_setup` creates an unprompted session for any authenticated user.
- [ ] Password min length only 8 — consider 12 + a max (bcrypt 72-byte truncation).
- [ ] `support_unencrypted_data: true` left enabled (TOTP secret encryption migration).
`config/initializers/active_record_encryption.rb`.
- [ ] All crypto keys derived from a single `SECRET_KEY_BASE` root — document setting
independent `ACTIVE_RECORD_ENCRYPTION_*` keys in production.
- [ ] Log injection via user `email_address` in ForwardAuth logs (strip CRLF / use
structured logging). `app/controllers/api/forward_auth_controller.rb`.
- [ ] WebAuthn RP ID is the registrable domain (cross-subdomain credential roaming) —
set `CLINCH_RP_ID` to the exact host unless roaming is intended.
`config/initializers/webauthn.rb`.

View File

@@ -3,6 +3,12 @@
@custom-variant dark (&:where(.dark, .dark *)); @custom-variant dark (&:where(.dark, .dark *));
@layer base { @layer base {
input:where([type="text"], [type="email"], [type="password"], [type="number"], [type="url"], [type="tel"], [type="search"]),
textarea,
select {
padding: 0.5rem 0.75rem;
}
.dark input:where([type="text"], [type="email"], [type="password"], [type="number"], [type="url"], [type="tel"], [type="search"]), .dark input:where([type="text"], [type="email"], [type="password"], [type="number"], [type="url"], [type="tel"], [type="search"]),
.dark textarea, .dark textarea,
.dark select { .dark select {

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] before_action :set_application, only: [:show, :edit, :update, :destroy, :regenerate_credentials]
def index 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 end
def show def show
@allowed_groups = @application.allowed_groups @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 end
def new def new
@@ -104,7 +124,7 @@ module Admin
permitted = params.require(:application).permit( permitted = params.require(:application).permit(
:name, :slug, :app_type, :active, :redirect_uris, :description, :metadata, :name, :slug, :app_type, :active, :redirect_uris, :description, :metadata,
:domain_pattern, :landing_url, :access_token_ttl, :refresh_token_ttl, :id_token_ttl, :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 # Handle headers_config - it comes as a JSON string from the text area

View File

@@ -15,6 +15,7 @@ module Admin
def new def new
@group = Group.new @group = Group.new
@available_users = User.order(:email_address) @available_users = User.order(:email_address)
@available_applications = Application.order(:name)
end end
def create def create
@@ -28,6 +29,7 @@ module Admin
@group = Group.new @group = Group.new
@group.errors.add(:custom_claims, "must be valid JSON") @group.errors.add(:custom_claims, "must be valid JSON")
@available_users = User.order(:email_address) @available_users = User.order(:email_address)
@available_applications = Application.order(:name)
render :new, status: :unprocessable_entity render :new, status: :unprocessable_entity
return return
end end
@@ -45,15 +47,23 @@ module Admin
@group.users = User.where(id: user_ids) @group.users = User.where(id: user_ids)
end 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." redirect_to admin_group_path(@group), notice: "Group created successfully."
else else
@available_users = User.order(:email_address) @available_users = User.order(:email_address)
@available_applications = Application.order(:name)
render :new, status: :unprocessable_entity render :new, status: :unprocessable_entity
end end
end end
def edit def edit
@available_users = User.order(:email_address) @available_users = User.order(:email_address)
@available_applications = Application.order(:name)
end end
def update def update
@@ -66,6 +76,7 @@ module Admin
rescue JSON::ParserError rescue JSON::ParserError
@group.errors.add(:custom_claims, "must be valid JSON") @group.errors.add(:custom_claims, "must be valid JSON")
@available_users = User.order(:email_address) @available_users = User.order(:email_address)
@available_applications = Application.order(:name)
render :edit, status: :unprocessable_entity render :edit, status: :unprocessable_entity
return return
end end
@@ -83,9 +94,18 @@ module Admin
@group.users = [] @group.users = []
end 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." redirect_to admin_group_path(@group), notice: "Group updated successfully."
else else
@available_users = User.order(:email_address) @available_users = User.order(:email_address)
@available_applications = Application.order(:name)
render :edit, status: :unprocessable_entity render :edit, status: :unprocessable_entity
end end
end end
@@ -102,7 +122,7 @@ module Admin
end end
def group_params 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 end
end end

View File

@@ -7,27 +7,38 @@ module Admin
end end
def show def show
@accessible_applications = Application.where(active: true)
.joins(:allowed_groups)
.where(groups: {id: @user.groups})
.distinct
.includes(:allowed_groups)
.order(:name)
end end
def new def new
@user = User.new @user = User.new
@available_groups = Group.order(:name)
end end
def create def create
@user = User.new(user_params) @user = User.new(user_params)
@user.password = SecureRandom.alphanumeric(16) if user_params[:password].blank? @user.password = SecureRandom.alphanumeric(16) if user_params[:password].blank?
@user.status = :pending_invitation @user.status = :pending_invitation
@user.skip_auto_assign = true if params[:auto_assign] == "0"
if @user.save if @user.save
assign_groups_from_params(@user)
InvitationsMailer.invite_user(@user).deliver_later InvitationsMailer.invite_user(@user).deliver_later
redirect_to admin_users_path, notice: "User created successfully. Invitation email sent to #{@user.email_address}." redirect_to admin_users_path, notice: "User created successfully. Invitation email sent to #{@user.email_address}."
else else
@available_groups = Group.order(:name)
render :new, status: :unprocessable_entity render :new, status: :unprocessable_entity
end end
end end
def edit def edit
@applications = Application.active.order(:name) @applications = Application.active.order(:name)
@available_groups = Group.order(:name)
end end
def update def update
@@ -43,6 +54,7 @@ module Admin
rescue JSON::ParserError rescue JSON::ParserError
@user.errors.add(:custom_claims, "must be valid JSON") @user.errors.add(:custom_claims, "must be valid JSON")
@applications = Application.active.order(:name) @applications = Application.active.order(:name)
@available_groups = Group.order(:name)
render :edit, status: :unprocessable_entity render :edit, status: :unprocessable_entity
return return
end end
@@ -52,9 +64,16 @@ module Admin
end end
if @user.update(update_params) 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." redirect_to admin_users_path, notice: "User updated successfully."
else else
@applications = Application.active.order(:name) @applications = Application.active.order(:name)
@available_groups = Group.order(:name)
render :edit, status: :unprocessable_entity render :edit, status: :unprocessable_entity
end end
end end
@@ -122,15 +141,28 @@ module Admin
end end
def user_params def user_params
# Base attributes that all admins can modify params.require(:user).permit(:email_address, :username, :name, :password, :status, :totp_required, :custom_claims)
base_params = params.require(:user).permit(: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
base_params[:admin] = params[:user][:admin] if params[:user][:admin].present?
end end
base_params # 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 end
end end

View File

@@ -64,26 +64,16 @@ module Api
return render_forbidden("No authentication rule configured for this domain") return render_forbidden("No authentication rule configured for this domain")
end end
else else
Rails.logger.info "ForwardAuth: User #{user.email_address} authenticated (no domain specified)" # Fail closed: with no host we cannot resolve an application or evaluate its
end # group policy. Emitting identity headers here would bypass all per-domain
# access control, so reject instead.
headers = if app Rails.logger.info "ForwardAuth: Access denied - no host header present"
app.headers_for_user(user) return render_forbidden("No host header present")
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
end 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 } headers.each { |key, value| response.headers[key] = value }
Rails.logger.debug "ForwardAuth: Headers sent: #{headers.keys.join(", ")}" if headers.any? 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") return render_bearer_error("Application is inactive")
end 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! api_key.touch_last_used!
headers = app.headers_for_user(user) headers = app.headers_for_user(user)
@@ -163,16 +161,25 @@ module Api
def check_forward_auth_token def check_forward_auth_token
token = params[:fa_token] token = params[:fa_token]
return nil unless token.present? return nil if token.blank?
session_id = Rails.cache.read("forward_auth_token:#{token}") cached = Rails.cache.read("forward_auth_token:#{token}")
return nil unless session_id return nil unless cached.is_a?(Hash)
session = Session.find_by(id: session_id) # The token is bound to the host that created it. If the request is
# arriving at a different host, refuse — and do NOT burn the cache
# entry, so that the legitimate destination can still redeem within
# the 60s TTL.
request_host = (request.headers["X-Forwarded-Host"] || request.headers["Host"])
.to_s.sub(/:\d+\z/, "").downcase
return nil if request_host.blank?
return nil unless cached[:host] == request_host
session = Session.find_by(id: cached[:session_id])
return nil unless session && !session.expired? return nil unless session && !session.expired?
Rails.cache.delete("forward_auth_token:#{token}") Rails.cache.delete("forward_auth_token:#{token}")
session_id cached[:session_id]
end end
def extract_session_id def extract_session_id
@@ -189,11 +196,14 @@ module Api
original_host = request.headers["X-Forwarded-Host"] original_host = request.headers["X-Forwarded-Host"]
original_uri = request.headers["X-Forwarded-Uri"] || request.headers["X-Forwarded-Path"] || "/" original_uri = request.headers["X-Forwarded-Uri"] || request.headers["X-Forwarded-Path"] || "/"
original_url = if original_host # X-Forwarded-Host is attacker-influenceable, so only honour the forwarded
"https://#{original_host}#{original_uri}" # URL as a post-login redirect target if it resolves to a known, active
else # forward-auth application. Otherwise this is an open redirect: a spoofed
redirect_url || base_url # host would be stored and reflected into the signin `rd`, then followed
end # (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 session[:return_to_after_authenticating] = original_url
@@ -233,18 +243,13 @@ module Api
def determine_base_url(redirect_url) def determine_base_url(redirect_url)
return redirect_url if redirect_url.present? return redirect_url if redirect_url.present?
if ENV["CLINCH_HOST"].present? # CLINCH_HOST is the IdP's canonical origin and is mandatory in deployed
host = ENV["CLINCH_HOST"] # environments (enforced at boot in config/initializers/clinch_host.rb).
host.match?(/^https?:\/\//) ? host : "https://#{host}" # We never fall back to the request host: a spoofed X-Forwarded-Host would
else # otherwise redirect the login flow to an attacker-controlled origin. The
request_host = request.host || request.headers["X-Forwarded-Host"] # localhost default only applies to local dev/test.
if request_host.present? host = ENV["CLINCH_HOST"].presence || "http://localhost:3000"
Rails.logger.warn "ForwardAuth: CLINCH_HOST not set, using request host: #{request_host}" host.match?(%r{\Ahttps?://}) ? host : "https://#{host}"
"https://#{request_host}"
else
raise StandardError, "ForwardAuth: CLINCH_HOST environment variable not set and no request host available."
end
end
end end
end end
end end

View File

@@ -14,6 +14,7 @@ class ApiKeysController < ApplicationController
@api_key = Current.session.user.api_keys.build(api_key_params) @api_key = Current.session.user.api_keys.build(api_key_params)
if @api_key.save if @api_key.save
SecurityMailer.api_key_created(Current.session.user, name: @api_key.name, **security_event_context).deliver_later
flash[:api_key_token] = @api_key.plaintext_token flash[:api_key_token] = @api_key.plaintext_token
redirect_to api_key_path(@api_key) redirect_to api_key_path(@api_key)
else else
@@ -31,6 +32,7 @@ class ApiKeysController < ApplicationController
def destroy def destroy
@api_key.revoke! @api_key.revoke!
SecurityMailer.api_key_revoked(@api_key.user, name: @api_key.name, **security_event_context).deliver_later
redirect_to api_keys_path, notice: "API key revoked." redirect_to api_keys_path, notice: "API key revoked."
end end

View File

@@ -14,6 +14,10 @@ class ApplicationController < ActionController::Base
private private
def security_event_context
{ip: request.remote_ip, user_agent: request.user_agent, occurred_at: Time.current}
end
# Remove a query parameter from a URL using proper URI parsing # Remove a query parameter from a URL using proper URI parsing
# More robust than regex - handles URL encoding, edge cases, etc. # More robust than regex - handles URL encoding, edge cases, etc.
# #

View File

@@ -31,7 +31,7 @@ module Authentication
end end
def find_session_by_cookie def find_session_by_cookie
Session.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 end
def request_authentication def request_authentication
@@ -43,9 +43,40 @@ module Authentication
session.delete(:return_to_after_authenticating) || root_url session.delete(:return_to_after_authenticating) || root_url
end end
def start_new_session_for(user, acr: "1") # 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.update!(last_sign_in_at: Time.current)
user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip, acr: acr).tap do |session| user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip, acr: acr, remember_me: remember_me).tap do |session|
Current.session = session Current.session = session
# Extract root domain for cross-subdomain cookies (required for forward auth) # Extract root domain for cross-subdomain cookies (required for forward auth)
@@ -58,8 +89,8 @@ module Authentication
{ {
value: session.id, value: session.id,
httponly: true, httponly: true,
same_site: :none, # Allow cross-site cookies for OIDC testing same_site: :lax,
secure: true # Required for SameSite=None secure: true
} }
else else
{ {
@@ -73,7 +104,15 @@ module Authentication
# Set domain for cross-subdomain authentication if we can extract it # Set domain for cross-subdomain authentication if we can extract it
cookie_options[:domain] = domain if domain.present? cookie_options[:domain] = domain if domain.present?
# When "Remember me" is off, issue a browser-session cookie (no Expires)
# so closing the browser signs the user out — especially important on
# shared devices. The server Session#expires_at still enforces the
# 24h / 30d window regardless.
if remember_me
cookies.signed.permanent[:session_id] = cookie_options cookies.signed.permanent[:session_id] = cookie_options
else
cookies.signed[:session_id] = cookie_options
end
# Create a one-time token for immediate forward auth after authentication # Create a one-time token for immediate forward auth after authentication
# This solves the race condition where browser hasn't processed cookie yet # This solves the race condition where browser hasn't processed cookie yet
@@ -130,35 +169,35 @@ module Authentication
end end
# Create a one-time token for forward auth to handle the race condition # Create a one-time token for forward auth to handle the race condition
# where the browser hasn't processed the session cookie yet # where the browser hasn't processed the session cookie yet.
#
# The token is bound to the destination host so that anyone who observes
# the token (Referer leaks, access logs, JS monitors) cannot redeem it for
# a different application within the 60-second TTL.
def create_forward_auth_token(session_obj) def create_forward_auth_token(session_obj)
# Generate a secure random token controller_session = session
token = SecureRandom.urlsafe_base64(32) return unless controller_session[:return_to_after_authenticating].present?
# Store it with an expiry of 60 seconds uri = URI.parse(controller_session[:return_to_after_authenticating])
# OAuth flow handles its own session propagation — no fa_token needed.
return if uri.path&.start_with?("/oauth/")
# Path-only URLs are same-origin on Clinch; the cookie race doesn't apply
# and we have no destination host to bind against.
bound_host = uri.hostname&.downcase
return if bound_host.blank?
token = SecureRandom.urlsafe_base64(32)
Rails.cache.write( Rails.cache.write(
"forward_auth_token:#{token}", "forward_auth_token:#{token}",
session_obj.id, {session_id: session_obj.id, host: bound_host},
expires_in: 60.seconds expires_in: 60.seconds
) )
# Set the token as a query parameter on the redirect URL
# We need to store this in the controller's session
controller_session = session
if controller_session[:return_to_after_authenticating].present?
original_url = controller_session[:return_to_after_authenticating]
uri = URI.parse(original_url)
# Skip adding fa_token for OAuth URLs (OAuth flow should not have forward auth tokens)
unless uri.path&.start_with?("/oauth/")
# Add token as query parameter
query_params = URI.decode_www_form(uri.query || "").to_h query_params = URI.decode_www_form(uri.query || "").to_h
query_params["fa_token"] = token query_params["fa_token"] = token
uri.query = URI.encode_www_form(query_params) uri.query = URI.encode_www_form(query_params)
# Update the session with the tokenized URL
controller_session[:return_to_after_authenticating] = uri.to_s controller_session[:return_to_after_authenticating] = uri.to_s
end end
end end
end
end

View File

@@ -1,8 +1,19 @@
class OidcController < ApplicationController class OidcController < ApplicationController
SUPPORTED_SCOPES = %w[openid profile email groups offline_access].freeze
# Discovery and JWKS endpoints are public # Discovery and JWKS endpoints are public
# authorize is also unauthenticated to handle prompt=none and prompt=login specially # authorize is also unauthenticated to handle prompt=none and prompt=login specially
allow_unauthenticated_access only: [:discovery, :jwks, :token, :revoke, :userinfo, :logout, :authorize] allow_unauthenticated_access only: [:discovery, :jwks, :token, :revoke, :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.
before_action :set_application, only: :authorize
before_action :validate_redirect_uri, only: :authorize
# Rate limiting to prevent brute force and abuse # Rate limiting to prevent brute force and abuse
rate_limit to: 60, within: 1.minute, only: [:token, :revoke], with: -> { rate_limit to: 60, within: 1.minute, only: [:token, :revoke], with: -> {
@@ -29,7 +40,7 @@ class OidcController < ApplicationController
grant_types_supported: ["authorization_code", "refresh_token"], grant_types_supported: ["authorization_code", "refresh_token"],
subject_types_supported: ["pairwise"], subject_types_supported: ["pairwise"],
id_token_signing_alg_values_supported: ["RS256"], id_token_signing_alg_values_supported: ["RS256"],
scopes_supported: ["openid", "profile", "email", "groups", "offline_access"], scopes_supported: SUPPORTED_SCOPES,
token_endpoint_auth_methods_supported: ["client_secret_post", "client_secret_basic"], token_endpoint_auth_methods_supported: ["client_secret_post", "client_secret_basic"],
claims_supported: [ claims_supported: [
"sub", # Always included "sub", # Always included
@@ -42,7 +53,7 @@ class OidcController < ApplicationController
# Note: Custom claims are also supported but not listed here # Note: Custom claims are also supported but not listed here
# ID-token-only claims (auth_time, acr, azp, at_hash, nonce) are not listed # ID-token-only claims (auth_time, acr, azp, at_hash, nonce) are not listed
], ],
code_challenge_methods_supported: ["plain", "S256"], code_challenge_methods_supported: ["S256"],
backchannel_logout_supported: true, backchannel_logout_supported: true,
backchannel_logout_session_supported: true, backchannel_logout_session_supported: true,
request_parameter_supported: false, request_parameter_supported: false,
@@ -59,7 +70,8 @@ class OidcController < ApplicationController
# GET /oauth/authorize # GET /oauth/authorize
def authorize def authorize
# Get parameters (ignore forward auth tokens and other unknown params) # @application and a validated redirect_uri are guaranteed by the before_actions.
# Read the remaining parameters (ignore forward auth tokens and other unknown params).
client_id = params[:client_id] client_id = params[:client_id]
redirect_uri = params[:redirect_uri] redirect_uri = params[:redirect_uri]
state = params[:state] state = params[:state]
@@ -67,57 +79,10 @@ class OidcController < ApplicationController
scope = params[:scope] || "openid" scope = params[:scope] || "openid"
response_type = params[:response_type] response_type = params[:response_type]
code_challenge = params[:code_challenge] code_challenge = params[:code_challenge]
code_challenge_method = params[:code_challenge_method] || "plain" code_challenge_method = params[:code_challenge_method] || "S256"
# Validate client_id first (required before we can look up the application)
# OAuth2 RFC 6749 Section 4.1.2.1: If client_id is missing/invalid, show error page (don't redirect)
unless client_id.present?
render plain: "Invalid request: client_id is required", status: :bad_request
return
end
# Find the application by client_id
@application = Application.find_by(client_id: client_id, app_type: "oidc")
unless @application
# Log all OIDC applications for debugging
all_oidc_apps = Application.where(app_type: "oidc")
Rails.logger.error "OAuth: Invalid request - application not found for client_id: #{client_id}"
Rails.logger.error "OAuth: Available OIDC applications: #{all_oidc_apps.pluck(:id, :client_id, :name)}"
error_msg = if Rails.env.development?
"Invalid request: Application not found for client_id '#{client_id}'. Available OIDC applications: #{all_oidc_apps.pluck(:name, :client_id).map { |name, id| "#{name} (#{id})" }.join(", ")}"
else
"Invalid request: Application not found"
end
render plain: error_msg, status: :bad_request
return
end
# Validate redirect_uri presence and format
# OAuth2 RFC 6749 Section 4.1.2.1: If redirect_uri is missing/invalid, show error page (don't redirect)
unless redirect_uri.present?
render plain: "Invalid request: redirect_uri is required", status: :bad_request
return
end
# Validate redirect URI matches one of the registered URIs
unless @application.parsed_redirect_uris.include?(redirect_uri)
Rails.logger.error "OAuth: Invalid request - redirect URI mismatch. Expected: #{@application.parsed_redirect_uris}, Got: #{redirect_uri}"
# For development, show detailed error
error_msg = if Rails.env.development?
"Invalid request: Redirect URI mismatch. Application is configured for: #{@application.parsed_redirect_uris.join(", ")}, but received: #{redirect_uri}"
else
"Invalid request: Redirect URI not registered for this application"
end
render plain: error_msg, status: :bad_request
return
end
# ============================================================================ # ============================================================================
# At this point we have a valid client_id and redirect_uri # client_id and redirect_uri are already validated (see before_actions).
# All subsequent errors should redirect back to the client with error parameters # All subsequent errors should redirect back to the client with error parameters
# per OAuth2 RFC 6749 Section 4.1.2.1 # per OAuth2 RFC 6749 Section 4.1.2.1
# ============================================================================ # ============================================================================
@@ -146,10 +111,10 @@ class OidcController < ApplicationController
# Validate PKCE parameters if present (now we can safely redirect with error) # Validate PKCE parameters if present (now we can safely redirect with error)
if code_challenge.present? if code_challenge.present?
unless %w[plain S256].include?(code_challenge_method) unless code_challenge_method == "S256"
Rails.logger.error "OAuth: Invalid code_challenge_method: #{code_challenge_method}" Rails.logger.error "OAuth: Invalid code_challenge_method: #{code_challenge_method}"
error_uri = "#{redirect_uri}?error=invalid_request" error_uri = "#{redirect_uri}?error=invalid_request"
error_uri += "&error_description=#{CGI.escape("Invalid code_challenge_method: must be 'plain' or 'S256'")}" error_uri += "&error_description=#{CGI.escape("Invalid code_challenge_method: only 'S256' is supported")}"
error_uri += "&state=#{CGI.escape(state)}" if state.present? error_uri += "&state=#{CGI.escape(state)}" if state.present?
redirect_to error_uri, allow_other_host: true redirect_to error_uri, allow_other_host: true
return return
@@ -166,6 +131,12 @@ class OidcController < ApplicationController
end end
end end
# Normalize requested scopes to the set we support. Needed here so claims
# validation below can check claim→scope coverage against what will actually
# be granted.
requested_scopes = scope.split(" ") & SUPPORTED_SCOPES
scope = requested_scopes.join(" ")
# Parse claims parameter (JSON string) for OIDC claims request # Parse claims parameter (JSON string) for OIDC claims request
# Per OIDC Core §5.5: The claims parameter is a JSON object that requests # Per OIDC Core §5.5: The claims parameter is a JSON object that requests
# specific claims to be returned in the id_token and/or userinfo # specific claims to be returned in the id_token and/or userinfo
@@ -289,7 +260,12 @@ class OidcController < ApplicationController
return return
end end
requested_scopes = scope.split(" ") unless requested_scopes.include?("openid")
error_uri = "#{redirect_uri}?error=invalid_scope&error_description=#{CGI.escape("The 'openid' scope is required")}"
error_uri += "&state=#{CGI.escape(state)}" if state.present?
redirect_to error_uri, allow_other_host: true
return
end
# Check if application is configured to skip consent # Check if application is configured to skip consent
# If so, automatically create consent and proceed without showing consent screen # If so, automatically create consent and proceed without showing consent screen
@@ -420,8 +396,7 @@ class OidcController < ApplicationController
user = Current.session.user user = Current.session.user
# Record user consent requested_scopes = oauth_params["scope"].split(" ") & SUPPORTED_SCOPES
requested_scopes = oauth_params["scope"].split(" ")
parsed_claims = begin parsed_claims = begin
JSON.parse(oauth_params["claims_requests"]) JSON.parse(oauth_params["claims_requests"])
rescue rescue
@@ -539,15 +514,12 @@ class OidcController < ApplicationController
# Check if code has already been used (CRITICAL: check AFTER locking) # Check if code has already been used (CRITICAL: check AFTER locking)
if auth_code.used? if auth_code.used?
# Per OAuth 2.0 spec, if an auth code is reused, revoke all tokens issued from it # Per OAuth 2.0 spec, if an auth code is reused, revoke every token
# descended from it (both generations across any rotations).
Rails.logger.warn "OAuth Security: Authorization code reuse detected for code #{auth_code.id}" Rails.logger.warn "OAuth Security: Authorization code reuse detected for code #{auth_code.id}"
now = Time.current
# Revoke all access tokens issued from this authorization code auth_code.oidc_access_tokens.where(revoked_at: nil).update_all(revoked_at: now)
OidcAccessToken.where( auth_code.oidc_refresh_tokens.where(revoked_at: nil).update_all(revoked_at: now)
application: application,
user: auth_code.user,
created_at: auth_code.created_at..Time.current
).update_all(expires_at: Time.current)
render json: { render json: {
error: "invalid_grant", error: "invalid_grant",
@@ -588,7 +560,8 @@ class OidcController < ApplicationController
access_token_record = OidcAccessToken.create!( access_token_record = OidcAccessToken.create!(
application: application, application: application,
user: user, user: user,
scope: auth_code.scope scope: auth_code.scope,
oidc_authorization_code: auth_code
) )
# Generate refresh token (opaque, with hashing) # Generate refresh token (opaque, with hashing)
@@ -596,6 +569,7 @@ class OidcController < ApplicationController
application: application, application: application,
user: user, user: user,
oidc_access_token: access_token_record, oidc_access_token: access_token_record,
oidc_authorization_code: auth_code,
scope: auth_code.scope, scope: auth_code.scope,
auth_time: auth_code.auth_time, auth_time: auth_code.auth_time,
acr: auth_code.acr acr: auth_code.acr
@@ -720,10 +694,15 @@ class OidcController < ApplicationController
refresh_token_record.revoke! refresh_token_record.revoke!
# Generate new access token record (opaque token with BCrypt hashing) # Generate new access token record (opaque token with BCrypt hashing)
# Carry the authorization-code FK forward across rotations so replay
# revocation reaches every descendant token in the chain.
issuing_auth_code = refresh_token_record.oidc_authorization_code
new_access_token = OidcAccessToken.create!( new_access_token = OidcAccessToken.create!(
application: application, application: application,
user: user, user: user,
scope: refresh_token_record.scope scope: refresh_token_record.scope,
oidc_authorization_code: issuing_auth_code
) )
# Generate new refresh token (token rotation) # Generate new refresh token (token rotation)
@@ -731,6 +710,7 @@ class OidcController < ApplicationController
application: application, application: application,
user: user, user: user,
oidc_access_token: new_access_token, oidc_access_token: new_access_token,
oidc_authorization_code: issuing_auth_code,
scope: refresh_token_record.scope, scope: refresh_token_record.scope,
token_family_id: refresh_token_record.token_family_id, # Keep same family for rotation tracking token_family_id: refresh_token_record.token_family_id, # Keep same family for rotation tracking
auth_time: refresh_token_record.auth_time, # Carry over original auth_time auth_time: refresh_token_record.auth_time, # Carry over original auth_time
@@ -1000,6 +980,55 @@ class OidcController < ApplicationController
private private
# Look up @application from client_id. RFC 6749 §4.1.2.1 requires that an
# invalid client_id be reported on-page, not via redirect.
def set_application
client_id = params[:client_id]
unless client_id.present?
render plain: "Invalid request: client_id is required", status: :bad_request
return
end
@application = Application.find_by(client_id: client_id, app_type: "oidc")
return if @application
Rails.logger.error "OAuth: Invalid request - application not found for client_id: #{client_id}"
error_msg = if Rails.env.development?
all_oidc_apps = Application.where(app_type: "oidc")
Rails.logger.error "OAuth: Available OIDC applications: #{all_oidc_apps.pluck(:id, :client_id, :name)}"
"Invalid request: Application not found for client_id '#{client_id}'. Available OIDC applications: #{all_oidc_apps.pluck(:name, :client_id).map { |name, id| "#{name} (#{id})" }.join(", ")}"
else
"Invalid request: Application not found"
end
render plain: error_msg, status: :bad_request
end
# Confirm the redirect_uri param is present and registered on @application.
# Must run after set_application. Errors render on-page per RFC 6749 §4.1.2.1.
def validate_redirect_uri
redirect_uri = params[:redirect_uri]
unless redirect_uri.present?
render plain: "Invalid request: redirect_uri is required", status: :bad_request
return
end
return if @application.parsed_redirect_uris.include?(redirect_uri)
Rails.logger.error "OAuth: Invalid request - redirect URI mismatch. Expected: #{@application.parsed_redirect_uris}, Got: #{redirect_uri}"
error_msg = if Rails.env.development?
"Invalid request: Redirect URI mismatch. Application is configured for: #{@application.parsed_redirect_uris.join(", ")}, but received: #{redirect_uri}"
else
"Invalid request: Redirect URI not registered for this application"
end
render plain: error_msg, status: :bad_request
end
def validate_pkce(application, auth_code, code_verifier) def validate_pkce(application, auth_code, code_verifier)
# Check if PKCE is required for this application # Check if PKCE is required for this application
pkce_required = application.requires_pkce? pkce_required = application.requires_pkce?
@@ -1041,16 +1070,14 @@ class OidcController < ApplicationController
# Recreate code challenge based on method # Recreate code challenge based on method
expected_challenge = case auth_code.code_challenge_method expected_challenge = case auth_code.code_challenge_method
when "plain"
code_verifier
when "S256" when "S256"
Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false) Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false)
else else
return { return {
valid: false, valid: false,
error: "server_error", error: "invalid_request",
error_description: "Unsupported code challenge method", error_description: "Unsupported code challenge method: only 'S256' is supported",
status: :internal_server_error status: :bad_request
} }
end end
@@ -1156,6 +1183,7 @@ class OidcController < ApplicationController
# id_token and/or userinfo keys, each mapping to claim requests # id_token and/or userinfo keys, each mapping to claim requests
def parse_claims_parameter(claims_string) def parse_claims_parameter(claims_string)
return {} if claims_string.blank? return {} if claims_string.blank?
return nil if claims_string.length > 4096
parsed = JSON.parse(claims_string) parsed = JSON.parse(claims_string)
return nil unless parsed.is_a?(Hash) return nil unless parsed.is_a?(Hash)

View File

@@ -20,6 +20,7 @@ class PasswordsController < ApplicationController
def update def update
if @user.update(params.permit(:password, :password_confirmation)) if @user.update(params.permit(:password, :password_confirmation))
SecurityMailer.password_changed(@user, **security_event_context).deliver_later
@user.sessions.destroy_all @user.sessions.destroy_all
redirect_to signin_path, notice: "Password has been reset." redirect_to signin_path, notice: "Password has been reset."
else else

View File

@@ -15,6 +15,7 @@ class ProfilesController < ApplicationController
end end
if @user.update(password_params) if @user.update(password_params)
SecurityMailer.password_changed(@user, **security_event_context).deliver_later
redirect_to profile_path, notice: "Password updated successfully." redirect_to profile_path, notice: "Password updated successfully."
else else
render :show, status: :unprocessable_entity render :show, status: :unprocessable_entity
@@ -27,7 +28,15 @@ class ProfilesController < ApplicationController
return return
end end
old_email = @user.email_address
if @user.update(email_params) if @user.update(email_params)
new_email = @user.email_address
if old_email != new_email
context = security_event_context
[old_email, new_email].uniq.each do |recipient|
SecurityMailer.email_address_changed(@user, recipient: recipient, old_email: old_email, new_email: new_email, **context).deliver_later
end
end
redirect_to profile_path, notice: "Email updated successfully." redirect_to profile_path, notice: "Email updated successfully."
else else
render :show, status: :unprocessable_entity render :show, status: :unprocessable_entity

View File

@@ -28,6 +28,8 @@ class SessionsController < ApplicationController
end end
end end
allow_oauth_redirect_in_csp
respond_to do |format| respond_to do |format|
format.html # render HTML login page format.html # render HTML login page
format.json { render json: {error: "Authentication required"}, status: :unauthorized } format.json { render json: {error: "Authentication required"}, status: :unauthorized }
@@ -76,6 +78,7 @@ class SessionsController < ApplicationController
# TOTP is enabled, proceed to verification # TOTP is enabled, proceed to verification
# Store user ID in session temporarily for TOTP verification # Store user ID in session temporarily for TOTP verification
session[:pending_totp_user_id] = user.id session[:pending_totp_user_id] = user.id
session[:pending_remember_me] = remember_me?
# Preserve the redirect URL through TOTP verification (after validation) # Preserve the redirect URL through TOTP verification (after validation)
if params[:rd].present? if params[:rd].present?
validated_url = validate_redirect_url(params[:rd]) validated_url = validate_redirect_url(params[:rd])
@@ -86,7 +89,7 @@ class SessionsController < ApplicationController
end end
# Sign in successful (password only) # Sign in successful (password only)
start_new_session_for user, acr: "1" start_new_session_for user, acr: "1", remember_me: remember_me?
# Use status: :see_other to ensure browser makes a GET request # Use status: :see_other to ensure browser makes a GET request
# This prevents Turbo from converting it to a TURBO_STREAM request # This prevents Turbo from converting it to a TURBO_STREAM request
@@ -118,6 +121,18 @@ class SessionsController < ApplicationController
return return
end 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) # Try TOTP verification first (password + TOTP = 2FA)
if user.verify_totp(code) if user.verify_totp(code)
session.delete(:pending_totp_user_id) session.delete(:pending_totp_user_id)
@@ -125,7 +140,7 @@ class SessionsController < ApplicationController
if session[:totp_redirect_url].present? if session[:totp_redirect_url].present?
session[:return_to_after_authenticating] = session.delete(:totp_redirect_url) session[:return_to_after_authenticating] = session.delete(:totp_redirect_url)
end end
start_new_session_for user, acr: "2" start_new_session_for user, acr: "2", remember_me: remember_me
redirect_to after_authentication_url, notice: "Signed in successfully.", allow_other_host: true redirect_to after_authentication_url, notice: "Signed in successfully.", allow_other_host: true
return return
end end
@@ -137,7 +152,7 @@ class SessionsController < ApplicationController
if session[:totp_redirect_url].present? if session[:totp_redirect_url].present?
session[:return_to_after_authenticating] = session.delete(:totp_redirect_url) session[:return_to_after_authenticating] = session.delete(:totp_redirect_url)
end end
start_new_session_for user, acr: "2" start_new_session_for user, acr: "2", remember_me: remember_me
redirect_to after_authentication_url, notice: "Signed in successfully using backup code.", allow_other_host: true redirect_to after_authentication_url, notice: "Signed in successfully using backup code.", allow_other_host: true
return return
end end
@@ -151,6 +166,8 @@ class SessionsController < ApplicationController
@user_has_webauthn = user&.can_authenticate_with_webauthn? @user_has_webauthn = user&.can_authenticate_with_webauthn?
@pending_email = user&.email_address @pending_email = user&.email_address
allow_oauth_redirect_in_csp
# Just render the form # Just render the form
end end
@@ -189,6 +206,7 @@ class SessionsController < ApplicationController
# Store user ID in session for verification # Store user ID in session for verification
session[:pending_webauthn_user_id] = user.id session[:pending_webauthn_user_id] = user.id
session[:pending_remember_me] = remember_me?
# Store redirect URL if present # Store redirect URL if present
if params[:rd].present? if params[:rd].present?
@@ -233,6 +251,14 @@ class SessionsController < ApplicationController
return return
end 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 # Get the credential and assertion from params
credential_data = params[:credential] credential_data = params[:credential]
if credential_data.blank? if credential_data.blank?
@@ -269,10 +295,14 @@ class SessionsController < ApplicationController
sign_count: stored_credential.sign_count 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) 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}" 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})"
# You might want to notify admins or temporarily disable the credential 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 end
# Update credential usage # Update credential usage
@@ -284,12 +314,13 @@ class SessionsController < ApplicationController
# Clean up session # Clean up session
session.delete(:pending_webauthn_user_id) session.delete(:pending_webauthn_user_id)
remember_me = session.delete(:pending_remember_me) || false
if session[:webauthn_redirect_url].present? if session[:webauthn_redirect_url].present?
session[:return_to_after_authenticating] = session.delete(:webauthn_redirect_url) session[:return_to_after_authenticating] = session.delete(:webauthn_redirect_url)
end end
# Create session (WebAuthn/passkey = phishing-resistant, ACR = "2") # Create session (WebAuthn/passkey = phishing-resistant, ACR = "2")
start_new_session_for user, acr: "2" start_new_session_for user, acr: "2", remember_me: remember_me
render json: { render json: {
success: true, success: true,
@@ -310,6 +341,10 @@ class SessionsController < ApplicationController
private private
def remember_me?
ActiveModel::Type::Boolean.new.cast(params[:remember_me]) || false
end
def validate_redirect_url(url) def validate_redirect_url(url)
return nil unless url.present? return nil unless url.present?

View File

@@ -12,6 +12,10 @@ class TotpController < ApplicationController
@totp_secret = ROTP::Base32.random @totp_secret = ROTP::Base32.random
@provisioning_uri = ROTP::TOTP.new(@totp_secret, issuer: "Clinch").provisioning_uri(@user.email_address) @provisioning_uri = ROTP::TOTP.new(@totp_secret, issuer: "Clinch").provisioning_uri(@user.email_address)
# Hold the secret server-side until the user confirms it with a valid code,
# so an attacker with session access cannot substitute one they control.
session[:pending_totp_secret] = @totp_secret
# Generate QR code # Generate QR code
require "rqrcode" require "rqrcode"
@qr_code = RQRCode::QRCode.new(@provisioning_uri) @qr_code = RQRCode::QRCode.new(@provisioning_uri)
@@ -19,9 +23,14 @@ class TotpController < ApplicationController
# POST /totp - Verify TOTP code and enable 2FA # POST /totp - Verify TOTP code and enable 2FA
def create def create
totp_secret = params[:totp_secret] totp_secret = session[:pending_totp_secret]
code = params[:code] code = params[:code]
unless totp_secret
redirect_to new_totp_path, alert: "Your TOTP setup session expired. Please start again."
return
end
# Verify the code works # Verify the code works
totp = ROTP::TOTP.new(totp_secret) totp = ROTP::TOTP.new(totp_secret)
if totp.verify(code, drift_behind: 30, drift_ahead: 30) if totp.verify(code, drift_behind: 30, drift_ahead: 30)
@@ -30,6 +39,9 @@ class TotpController < ApplicationController
plain_codes = @user.send(:generate_backup_codes) # Use private method from User model plain_codes = @user.send(:generate_backup_codes) # Use private method from User model
@user.save! @user.save!
session.delete(:pending_totp_secret)
TotpMailer.enabled(@user).deliver_later
# Store plain codes temporarily in session for display after redirect # Store plain codes temporarily in session for display after redirect
session[:temp_backup_codes] = plain_codes session[:temp_backup_codes] = plain_codes
@@ -91,6 +103,7 @@ class TotpController < ApplicationController
# Generate new backup codes and store BCrypt hashes # Generate new backup codes and store BCrypt hashes
plain_codes = @user.send(:generate_backup_codes) plain_codes = @user.send(:generate_backup_codes)
@user.save! @user.save!
SecurityMailer.backup_codes_regenerated(@user, **security_event_context).deliver_later
# Store plain codes temporarily in session for display # Store plain codes temporarily in session for display
session[:temp_backup_codes] = plain_codes session[:temp_backup_codes] = plain_codes
@@ -124,6 +137,7 @@ class TotpController < ApplicationController
end end
@user.disable_totp! @user.disable_totp!
SecurityMailer.totp_disabled(@user, **security_event_context).deliver_later
redirect_to profile_path, notice: "Two-factor authentication has been disabled." redirect_to profile_path, notice: "Two-factor authentication has been disabled."
end end

View File

@@ -8,12 +8,16 @@ class UsersController < ApplicationController
def create def create
@user = User.new(user_params) @user = User.new(user_params)
# First user becomes admin automatically
@user.admin = true if User.count.zero?
@user.status = "active" @user.status = "active"
first_user = User.count.zero?
if @user.save 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 start_new_session_for @user
redirect_to root_path, notice: "Welcome to Clinch! Your account has been created." redirect_to root_path, notice: "Welcome to Clinch! Your account has been created."
else else

View File

@@ -91,6 +91,8 @@ class WebauthnController < ApplicationController
backup_state: backup_state backup_state: backup_state
) )
SecurityMailer.passkey_added(user, nickname: @webauthn_credential.nickname, **security_event_context).deliver_later
render json: { render json: {
success: true, success: true,
message: "Passkey '#{nickname}' registered successfully", message: "Passkey '#{nickname}' registered successfully",
@@ -109,8 +111,11 @@ class WebauthnController < ApplicationController
# Remove a passkey # Remove a passkey
def destroy def destroy
nickname = @webauthn_credential.nickname nickname = @webauthn_credential.nickname
user = @webauthn_credential.user
@webauthn_credential.destroy @webauthn_credential.destroy
SecurityMailer.passkey_removed(user, nickname: nickname, **security_event_context).deliver_later
respond_to do |format| respond_to do |format|
format.html { format.html {
redirect_to profile_path, redirect_to profile_path,

View File

@@ -20,6 +20,21 @@ module ApplicationHelper
end end
end end
def oidc_env_lines(application, client_secret: nil)
lines = ["OIDC_CLIENT_ID=#{application.client_id}"]
lines << if client_secret
"OIDC_CLIENT_SECRET=#{client_secret}"
elsif application.public_client?
"OIDC_CLIENT_SECRET="
else
"OIDC_CLIENT_SECRET=<your-client-secret>"
end
lines << "OIDC_DISCOVERY_URL=#{OidcJwtService.issuer_url}"
lines << "OIDC_PROVIDER_NAME='Clinch'"
lines << "OIDC_REQUIRE_PKCE=#{application.requires_pkce? ? "true" : "false"}"
lines
end
def border_class_for(type) def border_class_for(type)
case type.to_s case type.to_s
when "notice" then "border-green-200 dark:border-green-700" when "notice" then "border-green-200 dark:border-green-700"
@@ -29,4 +44,49 @@ module ApplicationHelper
else "border-gray-200 dark:border-gray-700" else "border-gray-200 dark:border-gray-700"
end end
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 end

View File

@@ -180,7 +180,8 @@ export default class extends Controller {
"X-CSRF-Token": this.getCSRFToken() "X-CSRF-Token": this.getCSRFToken()
}, },
body: JSON.stringify({ body: JSON.stringify({
email: this.getUserEmail() email: this.getUserEmail(),
remember_me: this.getRememberMe()
}) })
}); });
@@ -295,6 +296,11 @@ export default class extends Controller {
return emailInput ? emailInput.value.trim() : ""; return emailInput ? emailInput.value.trim() : "";
} }
getRememberMe() {
const checkbox = document.querySelector('input[name="remember_me"][type="checkbox"]');
return checkbox ? checkbox.checked : false;
}
isValidEmail(email) { isValidEmail(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
} }

View File

@@ -28,6 +28,14 @@ class BackchannelLogoutJob < ApplicationJob
# Send HTTP POST to the application's backchannel logout URI # Send HTTP POST to the application's backchannel logout URI
uri = URI.parse(application.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 begin
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 5, read_timeout: 5) do |http| 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 || "/") 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

@@ -0,0 +1,65 @@
class SecurityMailer < ApplicationMailer
SUBJECT_PREFIX = "[Clinch security alert] ".freeze
def password_changed(user, ip:, user_agent:, occurred_at:)
assign_context(user, ip, user_agent, occurred_at)
mail subject: "#{SUBJECT_PREFIX}Your password was changed", to: user.email_address
end
def totp_disabled(user, ip:, user_agent:, occurred_at:)
assign_context(user, ip, user_agent, occurred_at)
mail subject: "#{SUBJECT_PREFIX}Two-factor authentication was disabled", to: user.email_address
end
def backup_codes_regenerated(user, ip:, user_agent:, occurred_at:)
assign_context(user, ip, user_agent, occurred_at)
mail subject: "#{SUBJECT_PREFIX}Two-factor backup codes were regenerated", to: user.email_address
end
def passkey_added(user, nickname:, ip:, user_agent:, occurred_at:)
assign_context(user, ip, user_agent, occurred_at)
@nickname = nickname
mail subject: "#{SUBJECT_PREFIX}A passkey was added to your account", to: user.email_address
end
def passkey_removed(user, nickname:, ip:, user_agent:, occurred_at:)
assign_context(user, ip, user_agent, occurred_at)
@nickname = nickname
mail subject: "#{SUBJECT_PREFIX}A passkey was removed from your account", to: user.email_address
end
def api_key_created(user, name:, ip:, user_agent:, occurred_at:)
assign_context(user, ip, user_agent, occurred_at)
@api_key_name = name
mail subject: "#{SUBJECT_PREFIX}An API key was created on your account", to: user.email_address
end
def api_key_revoked(user, name:, ip:, user_agent:, occurred_at:)
assign_context(user, ip, user_agent, occurred_at)
@api_key_name = name
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
@old_email = old_email
@new_email = new_email
mail subject: "#{SUBJECT_PREFIX}Your account email address was changed", to: recipient
end
private
def assign_context(user, ip, user_agent, occurred_at)
@user = user
@ip = ip
@user_agent = user_agent
@occurred_at = occurred_at
end
end

View File

@@ -0,0 +1,7 @@
class TotpMailer < ApplicationMailer
def enabled(user)
@user = user
mail subject: "Two-factor authentication enabled on your account",
to: user.email_address
end
end

View File

@@ -25,9 +25,12 @@ class Application < ApplicationRecord
after_commit :bust_forward_auth_cache, if: :forward_auth? after_commit :bust_forward_auth_cache, if: :forward_auth?
has_one_attached :icon has_one_attached :icon
has_one_attached :icon_dark
# Fix SVG content type after attachment ICON_ATTACHMENTS = %i[icon icon_dark].freeze
after_save :fix_icon_content_type, if: -> { icon.attached? && saved_change_to_attribute?(:id) == false }
before_validation :sanitize_svg_icons
after_save :fix_icon_content_types
has_many :application_groups, dependent: :destroy has_many :application_groups, dependent: :destroy
has_many :allowed_groups, through: :application_groups, source: :group 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" 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_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 # Icon validation using ActiveStorage validators
validate :icon_validation, if: -> { icon.attached? } validate :icon_validation
# Token TTL validations (for OIDC apps) # 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 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 end
# Access control # 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) def user_allowed?(user)
return false unless active? return false unless active?
return false unless user.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? (user.groups & allowed_groups).any?
end end
@@ -168,10 +170,6 @@ class Application < ApplicationRecord
return "deny" unless active? return "deny" unless active?
return "deny" unless user.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) if user_allowed?(user)
# Require 2FA if user has TOTP configured, otherwise one factor # Require 2FA if user has TOTP configured, otherwise one factor
user.totp_enabled? ? "two_factor" : "one_factor" user.totp_enabled? ? "two_factor" : "one_factor"
@@ -274,27 +272,84 @@ class Application < ApplicationRecord
Rails.application.config.forward_auth_cache&.delete("fa_apps") Rails.application.config.forward_auth_cache&.delete("fa_apps")
end end
def fix_icon_content_type def fix_icon_content_types
return unless icon.attached? ICON_ATTACHMENTS.each do |attr|
attachment = public_send(attr)
next unless attachment.attached?
# Fix SVG content type if it was detected incorrectly # Fix SVG content type if it was detected incorrectly
if icon.filename.extension == "svg" && icon.content_type == "application/octet-stream" if attachment.filename.extension == "svg" && attachment.content_type == "application/octet-stream"
icon.blob.update(content_type: "image/svg+xml") attachment.blob.update(content_type: "image/svg+xml")
end
end
end
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")
doc = Loofah.xml_document(raw_svg)
doc.scrub!(SvgScrubber.new)
clean_svg = doc.to_xml
sanitized = {
io: StringIO.new(clean_svg),
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
end end
def icon_validation def icon_validation
return unless icon.attached?
# Check content type
allowed_types = ["image/png", "image/jpg", "image/jpeg", "image/gif", "image/svg+xml"] 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 end
# Check file size (2MB limit) if attachment.blob.byte_size > 2.megabytes
if icon.blob.byte_size > 2.megabytes errors.add(attr, "must be less than 2MB")
errors.add(:icon, "must be less than 2MB") end
end end
end end
@@ -336,4 +391,17 @@ class Application < ApplicationRecord
# Let the format validator handle invalid URIs # Let the format validator handle invalid URIs
end end
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 end

View File

@@ -15,6 +15,11 @@ class Group < ApplicationRecord
normalizes :name, with: ->(name) { name.strip.downcase } normalizes :name, with: ->(name) { name.strip.downcase }
validate :no_reserved_claim_names 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 # Parse custom_claims JSON field
def parsed_custom_claims def parsed_custom_claims
return {} if custom_claims.blank? return {} if custom_claims.blank?
@@ -23,6 +28,13 @@ class Group < ApplicationRecord
private 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 def no_reserved_claim_names
return if custom_claims.blank? return if custom_claims.blank?

View File

@@ -1,6 +1,7 @@
class OidcAccessToken < ApplicationRecord class OidcAccessToken < ApplicationRecord
belongs_to :application belongs_to :application
belongs_to :user belongs_to :user
belongs_to :oidc_authorization_code, optional: true
has_many :oidc_refresh_tokens, dependent: :destroy has_many :oidc_refresh_tokens, dependent: :destroy
before_validation :generate_token, on: :create before_validation :generate_token, on: :create

View File

@@ -1,6 +1,8 @@
class OidcAuthorizationCode < ApplicationRecord class OidcAuthorizationCode < ApplicationRecord
belongs_to :application belongs_to :application
belongs_to :user belongs_to :user
has_many :oidc_access_tokens
has_many :oidc_refresh_tokens
attr_accessor :plaintext_code attr_accessor :plaintext_code
@@ -9,7 +11,7 @@ class OidcAuthorizationCode < ApplicationRecord
validates :code_hmac, presence: true, uniqueness: true validates :code_hmac, presence: true, uniqueness: true
validates :redirect_uri, presence: true validates :redirect_uri, presence: true
validates :code_challenge_method, inclusion: {in: %w[plain S256], allow_nil: true} validates :code_challenge_method, inclusion: {in: %w[S256], allow_nil: true}
validate :validate_code_challenge_format, if: -> { code_challenge.present? } validate :validate_code_challenge_format, if: -> { code_challenge.present? }
scope :valid, -> { where(used: false).where("expires_at > ?", Time.current) } scope :valid, -> { where(used: false).where("expires_at > ?", Time.current) }

View File

@@ -2,6 +2,7 @@ class OidcRefreshToken < ApplicationRecord
belongs_to :application belongs_to :application
belongs_to :user belongs_to :user
belongs_to :oidc_access_token belongs_to :oidc_access_token
belongs_to :oidc_authorization_code, optional: true
before_validation :generate_token, on: :create before_validation :generate_token, on: :create
before_validation :set_expiry, on: :create before_validation :set_expiry, on: :create
@@ -48,11 +49,21 @@ class OidcRefreshToken < ApplicationRecord
update!(revoked_at: Time.current) update!(revoked_at: Time.current)
end 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! def revoke_family!
return unless token_family_id.present? 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 end
private private

View File

@@ -7,6 +7,9 @@ class Session < ApplicationRecord
# Scopes # Scopes
scope :active, -> { where("expires_at > ?", Time.current) } scope :active, -> { where("expires_at > ?", Time.current) }
scope :expired, -> { 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? def expired?
expires_at.present? && expires_at <= Time.current expires_at.present? && expires_at <= Time.current

View File

@@ -0,0 +1,73 @@
# Loofah scrubber that strips dangerous content from SVG files
# while preserving safe SVG elements and attributes for icon display.
class SvgScrubber < Loofah::Scrubber
ALLOWED_ELEMENTS = %w[
svg g defs use symbol
circle ellipse line path polygon polyline rect
text tspan textPath
clipPath mask pattern
linearGradient radialGradient stop
filter feBlend feColorMatrix feComponentTransfer feComposite
feConvolveMatrix feDiffuseLighting feDisplacementMap feFlood
feGaussianBlur feImage feMerge feMergeNode feMorphology
feOffset feSpecularLighting feTile feTurbulence
title desc metadata
].freeze
ALLOWED_ATTRIBUTES = %w[
id class style
x y x1 y1 x2 y2 cx cy r rx ry
width height viewBox preserveAspectRatio
d points
fill stroke stroke-width stroke-linecap stroke-linejoin stroke-dasharray
opacity fill-opacity stroke-opacity
transform translate rotate scale
font-family font-size font-weight text-anchor
clip-path mask filter
gradientUnits gradientTransform spreadMethod
offset stop-color stop-opacity
dx dy textLength lengthAdjust
xmlns xmlns:xlink
color display visibility overflow
fill-rule clip-rule
marker-start marker-mid marker-end
].freeze
# Loofah hands attribute names back in their source case (e.g. "viewBox").
# Compare against a downcased copy so SVG-spec camelCase attributes aren't
# stripped from legitimate icons.
ALLOWED_ATTRIBUTES_LOOKUP = ALLOWED_ATTRIBUTES.map(&:downcase).to_set.freeze
# Event handler attributes that must always be removed
EVENT_HANDLER_PATTERN = /\Aon/i
def initialize
@direction = :top_down
end
def scrub(node)
return CONTINUE if node.text? || node.cdata?
if node.element?
if ALLOWED_ELEMENTS.include?(node.name)
# Remove disallowed and event handler attributes
node.attribute_nodes.each do |attr|
attr.remove unless safe_attribute?(attr)
end
return CONTINUE
end
end
node.remove
STOP
end
private
def safe_attribute?(attr)
name = attr.name.downcase
return false if name.match?(EVENT_HANDLER_PATTERN)
return false if attr.value&.match?(/javascript:|data:/i)
ALLOWED_ATTRIBUTES_LOOKUP.include?(name)
end
end

View File

@@ -41,8 +41,24 @@ class User < ApplicationRecord
# Enum - automatically creates scopes (User.active, User.disabled, etc.) # Enum - automatically creates scopes (User.active, User.disabled, etc.)
enum :status, {active: 0, disabled: 1, pending_invitation: 2} 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 # 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 # TOTP methods
def totp_enabled? def totp_enabled?
@@ -52,7 +68,10 @@ class User < ApplicationRecord
def enable_totp! def enable_totp!
require "rotp" require "rotp"
self.totp_secret = ROTP::Base32.random 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! save!
end end
@@ -75,7 +94,13 @@ class User < ApplicationRecord
require "rotp" require "rotp"
totp = ROTP::TOTP.new(totp_secret) 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 end
# Console/debug helper: get current TOTP code # Console/debug helper: get current TOTP code
@@ -107,12 +132,12 @@ class User < ApplicationRecord
save! # Save the updated array save! # Save the updated array
# Log successful backup code usage for security monitoring # Log successful backup code usage for security monitoring
Rails.logger.info "Backup code used successfully - User ID: #{id}, IP: #{Current.session&.client_ip}" Rails.logger.info "Backup code used successfully - User ID: #{id}, IP: #{Current.session&.ip_address}"
true true
else else
# Increment failed attempt counter and log for security monitoring # Increment failed attempt counter and log for security monitoring
increment_backup_code_failed_attempts increment_backup_code_failed_attempts
Rails.logger.warn "Failed backup code attempt - User ID: #{id}, IP: #{Current.session&.client_ip}" Rails.logger.warn "Failed backup code attempt - User ID: #{id}, IP: #{Current.session&.ip_address}"
false false
end end
end end
@@ -222,6 +247,17 @@ class User < ApplicationRecord
private 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 def no_reserved_claim_names
return if custom_claims.blank? return if custom_claims.blank?

View File

@@ -52,13 +52,17 @@ class WebauthnCredential < ApplicationRecord
end end
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) def suspicious_sign_count?(new_sign_count)
return false if sign_count.zero? && new_sign_count > 0 # First use return false if sign_count.zero? || new_sign_count.zero?
return false if new_sign_count > sign_count # Normal increment
# Sign count didn't increase - possible clone new_sign_count <= sign_count
true
end end
# Format for display in UI # Format for display in UI

View File

@@ -1,6 +1,8 @@
class OidcJwtService class OidcJwtService
extend ClaimsMerger extend ClaimsMerger
RESERVED_CLAIMS = %i[iss sub aud exp iat nbf jti nonce azp].freeze
class << self class << self
# Generate an ID token (JWT) for the user # Generate an ID token (JWT) for the user
def generate_id_token(user, application, consent: nil, nonce: nil, access_token: nil, auth_time: nil, acr: nil, scopes: "openid", claims_requests: {}) def generate_id_token(user, application, consent: nil, nonce: nil, access_token: nil, auth_time: nil, acr: nil, scopes: "openid", claims_requests: {})
@@ -79,15 +81,16 @@ class OidcJwtService
# Merge custom claims from groups (arrays are combined, not overwritten) # Merge custom claims from groups (arrays are combined, not overwritten)
# Note: Custom claims from groups are always merged (not scope-dependent) # Note: Custom claims from groups are always merged (not scope-dependent)
# Reserved claims are stripped as defense-in-depth (also validated at model layer)
user.groups.each do |group| user.groups.each do |group|
payload = deep_merge_claims(payload, group.parsed_custom_claims) payload = deep_merge_claims(payload, group.parsed_custom_claims.except(*RESERVED_CLAIMS))
end end
# Merge custom claims from user (arrays are combined, other values override) # Merge custom claims from user (arrays are combined, other values override)
payload = deep_merge_claims(payload, user.parsed_custom_claims) payload = deep_merge_claims(payload, user.parsed_custom_claims.except(*RESERVED_CLAIMS))
# Merge app-specific custom claims (highest priority, arrays are combined) # Merge app-specific custom claims (highest priority, arrays are combined)
payload = deep_merge_claims(payload, application.custom_claims_for_user(user)) payload = deep_merge_claims(payload, application.custom_claims_for_user(user).except(*RESERVED_CLAIMS))
# Filter custom claims based on claims parameter # Filter custom claims based on claims parameter
# If claims parameter is present, only include requested custom claims # If claims parameter is present, only include requested custom claims

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" %> <%= 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> <div class="space-y-4">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between -mb-2">
<%= form.label :icon, "Application Icon", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %> <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"> <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"> <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> <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 Browse icons at dashboardicons.com
</a> </a>
</div> </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"> <%= render "icon_uploader",
<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" form: form,
data-file-drop-target="dropzone" field: :icon,
data-image-paste-target="dropzone" label: "Icon",
data-action="dragover->file-drop#dragover dragleave->file-drop#dragleave drop->file-drop#drop paste->image-paste#handlePaste" current_attached: (application.persisted? ? application.icon : nil),
tabindex="0"> current_label: "Current icon" %>
<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"> <%= render "icon_uploader",
<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" /> form: form,
</svg> field: :icon_dark,
<div class="flex text-sm text-gray-600 dark:text-gray-400"> label: "Dark mode icon (optional)",
<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"> 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.",
<span>Upload a file</span> current_attached: (application.persisted? ? application.icon_dark : nil),
<%= form.file_field :icon, current_label: "Current dark-mode icon",
accept: "image/png,image/jpg,image/jpeg,image/gif,image/svg+xml", preview_extra_class: "bg-gray-900" %>
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>
</div> </div>
<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>
</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="mt-8 flow-root">
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8"> <div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8"> <div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
@@ -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">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">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">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"> <th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-0">
<span class="sr-only">Actions</span> <span class="sr-only">Actions</span>
</th> </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"> <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"> <div class="flex items-center gap-3">
<% if application.icon.attached? %> <% 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 %> <% 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"> <%= render "shared/app_monogram", name: application.name, class: "h-10 w-10 rounded-lg 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>
<% end %> <% end %>
<%= link_to application.name, admin_application_path(application), class: "text-blue-600 hover:text-blue-900" %> <%= link_to application.name, admin_application_path(application), class: "text-blue-600 hover:text-blue-900" %>
</div> </div>
@@ -62,10 +73,13 @@
<% end %> <% end %>
</td> </td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400"> <td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
<% if application.allowed_groups.empty? %> <% groups_count = application.allowed_groups.size %>
<span class="text-gray-400 dark:text-gray-500">All users</span> <% 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 %> <% 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 %> <% end %>
</td> </td>
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0"> <td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">

View File

@@ -25,6 +25,23 @@
Public clients do not have a client secret. PKCE is required. Public clients do not have a client secret. PKCE is required.
</div> </div>
<% end %> <% end %>
<% env_lines = oidc_env_lines(@application, client_secret: flash[:client_secret]) %>
<div class="mt-4" data-controller="clipboard">
<div class="flex items-center justify-between mb-2">
<span class="text-xs font-medium text-yellow-700 dark:text-yellow-300">Environment variables (copy &amp; paste):</span>
<button type="button"
data-action="clipboard#copy"
class="text-xs font-medium text-yellow-700 dark:text-yellow-300 hover:text-yellow-900 dark:hover:text-yellow-100 underline">
<span data-clipboard-target="label">Copy</span>
</button>
</div>
<textarea data-clipboard-target="source"
readonly
rows="<%= env_lines.length %>"
class="block w-full bg-yellow-100 dark:bg-yellow-900/50 px-3 py-2 rounded font-mono text-xs text-gray-900 dark:text-gray-100 resize-none focus:outline-none focus:ring-1 focus:ring-yellow-500"><%= env_lines.join("\n") %></textarea>
</div>
</div> </div>
</div> </div>
<% end %> <% end %>
@@ -32,13 +49,9 @@
<div class="sm:flex sm:items-start sm:justify-between"> <div class="sm:flex sm:items-start sm:justify-between">
<div class="flex items-start gap-4"> <div class="flex items-start gap-4">
<% if @application.icon.attached? %> <% 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 %> <% 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"> <%= render "shared/app_monogram", name: @application.name, class: "h-16 w-16 rounded-lg 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>
<% end %> <% end %>
<div> <div>
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100"><%= @application.name %></h1> <h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100"><%= @application.name %></h1>
@@ -157,6 +170,30 @@
</dd> </dd>
</div> </div>
<% end %> <% end %>
<div>
<details class="border border-gray-200 dark:border-gray-700 rounded-lg">
<summary class="cursor-pointer bg-gray-50 dark:bg-gray-700 px-4 py-3 hover:bg-gray-100 dark:hover:bg-gray-600 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300">
Environment variables
</summary>
<div class="px-4 py-3" data-controller="clipboard">
<% env_lines = oidc_env_lines(@application) %>
<div class="flex items-center justify-between mb-2">
<span class="text-xs text-gray-500 dark:text-gray-400">
<%= @application.confidential_client? ? "Replace <your-client-secret> with your saved secret." : "Public client — no secret required." %>
</span>
<button type="button"
data-action="clipboard#copy"
class="text-xs font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 underline">
<span data-clipboard-target="label">Copy</span>
</button>
</div>
<textarea data-clipboard-target="source"
readonly
rows="<%= env_lines.length %>"
class="block w-full bg-gray-100 dark:bg-gray-700 px-3 py-2 rounded font-mono text-xs text-gray-900 dark:text-gray-100 resize-none focus:outline-none focus:ring-1 focus:ring-gray-500"><%= env_lines.join("\n") %></textarea>
</div>
</details>
</div>
<% end %> <% end %>
<div> <div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Redirect URIs</dt> <dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Redirect URIs</dt>
@@ -233,11 +270,11 @@
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Allowed Groups</dt> <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"> <dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
<% if @allowed_groups.empty? %> <% 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="flex">
<div class="ml-3"> <div class="ml-3">
<p class="text-sm text-blue-700 dark:text-blue-300"> <p class="text-sm text-amber-700 dark:text-amber-300">
No groups assigned - all active users can access this application. No groups assigned — no one can access this application. Attach a group to grant access.
</p> </p>
</div> </div>
</div> </div>
@@ -258,4 +295,35 @@
</div> </div>
</div> </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> </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" %> <%= 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>
<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> <div>
<%= form.label :user_ids, "Group Members", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %> <%= 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"> <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> <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>
<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"> <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.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, <%= 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="mb-6">
<div class="sm:flex sm:items-center sm:justify-between"> <div class="sm:flex sm:items-center sm:justify-between">
<div> <div>
<div class="flex items-center gap-2">
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100"><%= @group.name %></h1> <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? %> <% if @group.description.present? %>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400"><%= @group.description %></p> <p class="mt-1 text-sm text-gray-500 dark:text-gray-400"><%= @group.description %></p>
<% end %> <% 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" %> <%= 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>
<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"> <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) %> <%= 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" %>
<%= form.label :admin, "Administrator", class: "ml-2 block text-sm text-gray-900 dark:text-gray-100" %> <%= label_tag "user_group_ids_#{group.id}", group.name, class: "ml-2 text-sm text-gray-900 dark:text-gray-100" %>
<% if user == Current.session.user %> <% if group.admin? %>
<span class="ml-2 text-xs text-gray-500 dark:text-gray-400">(Cannot change your own admin status)</span> <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 %> <% end %>
</div> </div>

View File

@@ -61,7 +61,7 @@
<% @users.each do |user| %> <% @users.each do |user| %>
<tr> <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"> <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>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400"> <td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
<% if user.status.present? %> <% if user.status.present? %>
@@ -110,6 +110,7 @@
data: { turbo_method: :post }, data: { turbo_method: :post },
class: "text-yellow-600 hover:text-yellow-900" %> class: "text-yellow-600 hover:text-yellow-900" %>
<% end %> <% 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 "Edit", edit_admin_user_path(user), class: "text-blue-600 hover:text-blue-900" %>
<%= link_to "Delete", admin_user_path(user), <%= link_to "Delete", admin_user_path(user),
data: { turbo_method: :delete, turbo_confirm: "Are you sure you want to delete this 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="p-6">
<div class="flex items-start gap-3 mb-4"> <div class="flex items-start gap-3 mb-4">
<% if app.icon.attached? %> <% 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 %> <% 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"> <%= render "shared/app_monogram", name: app.name, class: "h-12 w-12 rounded-lg 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>
<% end %> <% end %>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">

View File

@@ -1,8 +1,8 @@
You've been invited to join Clinch! You've been invited to join Clinch!
To set up your account and create your password, please visit: To set up your account and create your password, please visit:
#{invite_url(@user.invitation_login_token)} <%= invitation_url(@user.generate_token_for(:invitation_login)) %>
This invitation link will expire in #{distance_of_time_in_words(0, @user.invitation_login_token_expires_in)}. This invitation link will expire in 24 hours.
If you didn't expect this invitation, you can safely ignore this email. If you didn't expect this invitation, you can safely ignore this email.

View File

@@ -9,7 +9,7 @@
<%= csrf_meta_tags %> <%= csrf_meta_tags %>
<%= csp_meta_tag %> <%= csp_meta_tag %>
<script> <script nonce="<%= content_security_policy_nonce %>">
(function() { (function() {
var theme = localStorage.getItem('theme'); var theme = localStorage.getItem('theme');
if (theme === 'dark' || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) { 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="bg-white dark:bg-gray-800 py-8 px-6 shadow rounded-lg sm:px-10">
<div class="mb-8 text-center"> <div class="mb-8 text-center">
<% if @application.icon.attached? %> <% 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 %> <% 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"> <div class="mx-auto mb-4">
<svg class="h-10 w-10 text-gray-400 dark:text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <%= render "shared/app_monogram", name: @application.name, class: "h-20 w-20 rounded-xl shadow-sm" %>
<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> </div>
<% end %> <% end %>
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100">Authorize Application</h2> <h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100">Authorize Application</h2>

View File

@@ -1,6 +1,6 @@
<p> <p>
You can reset your password on You can reset your password on
<%= link_to "this password reset page", edit_password_url(@user.password_reset_token) %>. <%= link_to "this password reset page", edit_password_url(@user.generate_token_for(:password_reset)) %>.
This link will expire in <%= distance_of_time_in_words(0, @user.password_reset_token_expires_in) %>. This link will expire in 1 hour.
</p> </p>

View File

@@ -1,4 +1,4 @@
You can reset your password on You can reset your password on
<%= edit_password_url(@user.password_reset_token) %> <%= edit_password_url(@user.generate_token_for(:password_reset)) %>
This link will expire in <%= distance_of_time_in_words(0, @user.password_reset_token_expires_in) %>. This link will expire in 1 hour.

View File

@@ -0,0 +1,11 @@
<hr>
<p>
This action was recorded at <strong><%= @occurred_at.to_fs(:long) %></strong>
from IP <strong><%= @ip %></strong>
using <strong><%= @user_agent.presence || "an unknown client" %></strong>.
</p>
<p>
If you did <strong>not</strong> perform this action, reset your password
immediately and contact your administrator.
</p>

View File

@@ -0,0 +1,7 @@
---
This action was recorded at <%= @occurred_at.to_fs(:long) %>
from IP <%= @ip %>
using <%= @user_agent.presence || "an unknown client" %>.
If you did not perform this action, reset your password immediately
and contact your administrator.

View File

@@ -0,0 +1,8 @@
<p>Hello,</p>
<p>
A new API key (<strong><%= @api_key_name %></strong>) was just created
on your Clinch account (<strong><%= @user.email_address %></strong>).
</p>
<%= render "event_metadata" %>

View File

@@ -0,0 +1,6 @@
Hello,
A new API key ("<%= @api_key_name %>") was just created on your Clinch
account (<%= @user.email_address %>).
<%= render "event_metadata" %>

View File

@@ -0,0 +1,8 @@
<p>Hello,</p>
<p>
The API key <strong><%= @api_key_name %></strong> was just revoked
on your Clinch account (<strong><%= @user.email_address %></strong>).
</p>
<%= render "event_metadata" %>

View File

@@ -0,0 +1,6 @@
Hello,
The API key "<%= @api_key_name %>" was just revoked on your Clinch
account (<%= @user.email_address %>).
<%= render "event_metadata" %>

View File

@@ -0,0 +1,9 @@
<p>Hello,</p>
<p>
A new set of two-factor backup codes was generated on your Clinch
account (<strong><%= @user.email_address %></strong>).
Any previous backup codes are now invalid.
</p>
<%= render "event_metadata" %>

View File

@@ -0,0 +1,6 @@
Hello,
A new set of two-factor backup codes was generated on your Clinch account
(<%= @user.email_address %>). Any previous backup codes are now invalid.
<%= render "event_metadata" %>

View File

@@ -0,0 +1,22 @@
<p>Hello,</p>
<% if @recipient == @new_email %>
<p>
The email address on your Clinch account is now
<strong><%= @new_email %></strong>.
It was previously <strong><%= @old_email %></strong>.
</p>
<% else %>
<p>
The email address on your Clinch account was changed away from this
address (<strong><%= @old_email %></strong>) to
<strong><%= @new_email %></strong>.
</p>
<p>
If this was <strong>not</strong> you, contact your administrator
immediately — whoever made the change can now receive password
reset emails for the account.
</p>
<% end %>
<%= render "event_metadata" %>

View File

@@ -0,0 +1,14 @@
Hello,
<% if @recipient == @new_email %>
The email address on your Clinch account is now <%= @new_email %>.
It was previously <%= @old_email %>.
<% else %>
The email address on your Clinch account was changed away from this
address (<%= @old_email %>) to <%= @new_email %>.
If this was not you, contact your administrator immediately — whoever
made the change can now receive password reset emails for the account.
<% end %>
<%= render "event_metadata" %>

View File

@@ -0,0 +1,8 @@
<p>Hello,</p>
<p>
A new passkey (<strong><%= @nickname %></strong>) was just added to your
Clinch account (<strong><%= @user.email_address %></strong>).
</p>
<%= render "event_metadata" %>

View File

@@ -0,0 +1,6 @@
Hello,
A new passkey ("<%= @nickname %>") was just added to your Clinch account
(<%= @user.email_address %>).
<%= render "event_metadata" %>

View File

@@ -0,0 +1,8 @@
<p>Hello,</p>
<p>
A passkey (<strong><%= @nickname %></strong>) was just removed from your
Clinch account (<strong><%= @user.email_address %></strong>).
</p>
<%= render "event_metadata" %>

View File

@@ -0,0 +1,6 @@
Hello,
A passkey ("<%= @nickname %>") was just removed from your Clinch account
(<%= @user.email_address %>).
<%= render "event_metadata" %>

View File

@@ -0,0 +1,8 @@
<p>Hello,</p>
<p>
The password on your Clinch account
(<strong><%= @user.email_address %></strong>) was just changed.
</p>
<%= render "event_metadata" %>

View File

@@ -0,0 +1,5 @@
Hello,
The password on your Clinch account (<%= @user.email_address %>) was just changed.
<%= render "event_metadata" %>

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

@@ -0,0 +1,8 @@
<p>Hello,</p>
<p>
Two-factor authentication was just <strong>disabled</strong> on your
Clinch account (<strong><%= @user.email_address %></strong>).
</p>
<%= render "event_metadata" %>

View File

@@ -0,0 +1,6 @@
Hello,
Two-factor authentication was just disabled on your Clinch account
(<%= @user.email_address %>).
<%= render "event_metadata" %>

View File

@@ -1,6 +1,6 @@
<div class="mx-auto md:w-2/3 w-full" data-controller="webauthn login-form" data-webauthn-check-url-value="/webauthn/check"> <div class="mx-auto max-w-md w-full" data-controller="webauthn login-form" data-webauthn-check-url-value="/webauthn/check">
<div class="mb-8"> <div class="mb-8">
<h1 class="font-bold text-4xl">Sign in to Clinch</h1> <h1 class="font-bold text-4xl text-center">Sign in to Clinch</h1>
</div> </div>
<%= form_with url: signin_path, class: "contents", data: { controller: "form-errors" } do |form| %> <%= form_with url: signin_path, class: "contents", data: { controller: "form-errors" } do |form| %>
@@ -38,7 +38,7 @@
</svg> </svg>
Continue with Passkey Continue with Passkey
</button> </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>
<!-- Password section - shown by default, hidden if WebAuthn is required --> <!-- Password section - shown by default, hidden if WebAuthn is required -->
@@ -53,6 +53,11 @@
class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100" %> class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100" %>
</div> </div>
<div class="my-5 flex items-center">
<%= form.check_box :remember_me, { class: "rounded border-gray-400 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800" }, "1", "0" %>
<%= form.label :remember_me, "Remember me for 30 days", class: "ml-2 text-sm text-gray-600 dark:text-gray-400" %>
</div>
<div class="my-5"> <div class="my-5">
<%= form.submit "Sign in", <%= form.submit "Sign in",
class: "w-full rounded-md px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white font-medium cursor-pointer" %> class: "w-full rounded-md px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white font-medium cursor-pointer" %>

View File

@@ -54,7 +54,7 @@
</svg> </svg>
Use Passkey Instead Use Passkey Instead
</button> </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>
</div> </div>
<% end %> <% 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 Groups
<% end %> <% end %>
</li> </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 %> <% end %>
<!-- Profile --> <!-- Profile -->
@@ -115,6 +125,10 @@
</li> </li>
</ul> </ul>
</li> </li>
<li class="mt-auto pt-4 border-t border-gray-200 dark:border-gray-700">
<%= render "shared/version_info" %>
</li>
</ul> </ul>
</nav> </nav>
</div> </div>
@@ -192,6 +206,14 @@
Groups Groups
<% end %> <% end %>
</li> </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 %> <% end %>
<li> <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 %> <%= 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 %> <% end %>
</li> </li>
</ul> </ul>
<div class="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
<%= render "shared/version_info" %>
</div>
</nav> </nav>
</div> </div>
</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

@@ -35,8 +35,6 @@
<div> <div>
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100 mb-4">Step 2: Verify</h3> <h3 class="text-lg font-medium text-gray-900 dark:text-gray-100 mb-4">Step 2: Verify</h3>
<%= form_with url: totp_path, method: :post, class: "space-y-4" do |form| %> <%= form_with url: totp_path, method: :post, class: "space-y-4" do |form| %>
<%= hidden_field_tag :totp_secret, @totp_secret %>
<div> <div>
<%= label_tag :code, "Verification Code", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %> <%= label_tag :code, "Verification Code", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<%= text_field_tag :code, <%= text_field_tag :code,

View File

@@ -0,0 +1,16 @@
<p>Hello,</p>
<p>
Two-factor authentication was just enabled on the Clinch account for
<strong><%= @user.email_address %></strong>.
</p>
<p>
If you did this, you can ignore this email.
</p>
<p>
If you did <strong>not</strong> do this, your account may have been
accessed by someone else. Reset your password immediately and contact
your administrator.
</p>

View File

@@ -0,0 +1,9 @@
Hello,
Two-factor authentication was just enabled on the Clinch account for
<%= @user.email_address %>.
If you did this, you can ignore this email.
If you did NOT do this, your account may have been accessed by someone
else. Reset your password immediately and contact your administrator.

View File

@@ -38,10 +38,6 @@ env:
secret: secret:
- RAILS_MASTER_KEY - RAILS_MASTER_KEY
clear: clear:
# Run the Solid Queue Supervisor inside the web server's Puma process to do jobs.
# When you start using multiple servers, you should split out job processing to a dedicated machine.
SOLID_QUEUE_IN_PUMA: true
# Set number of processes dedicated to Solid Queue (default: 1) # Set number of processes dedicated to Solid Queue (default: 1)
# JOB_CONCURRENCY: 3 # JOB_CONCURRENCY: 3

View File

@@ -118,14 +118,17 @@ Rails.application.configure do
registrable_domain = domain.domain # Gets "example.com" from "auth.example.com" registrable_domain = domain.domain # Gets "example.com" from "auth.example.com"
if registrable_domain.present? if registrable_domain.present?
# Create regex to allow any subdomain of the registrable domain # Allow the registrable domain and any subdomain of it. The pattern is
allowed_hosts << /.*#{Regexp.escape(registrable_domain)}/ # 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 end
rescue PublicSuffix::DomainInvalid rescue PublicSuffix::DomainInvalid
# Fallback to simple domain extraction if PublicSuffix fails # Fallback to simple domain extraction if PublicSuffix fails
Rails.logger.warn "Could not parse domain '#{host_domain}' with PublicSuffix, using fallback" Rails.logger.warn "Could not parse domain '#{host_domain}' with PublicSuffix, using fallback"
base_domain = host_domain.split(".").last(2).join(".") 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
end end
@@ -136,9 +139,6 @@ Rails.application.configure do
# Allow internal IP access for cross-compose or host networking # Allow internal IP access for cross-compose or host networking
if ENV["CLINCH_ALLOW_INTERNAL_IPS"] == "true" if ENV["CLINCH_ALLOW_INTERNAL_IPS"] == "true"
# Specific host IP
allowed_hosts << "192.168.2.246"
# Private IP ranges for internal network access # Private IP ranges for internal network access
allowed_hosts += [ allowed_hosts += [
/192\.168\.\d+\.\d+/, # 192.168.0.0/16 private network /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. # Skip DNS rebinding protection for the default health check endpoint.
config.host_authorization = {exclude: ->(request) { request.path == "/up" }} config.host_authorization = {exclude: ->(request) { request.path == "/up" }}
# Sentry configuration for production # Sentry is configured in config/initializers/sentry.rb, gated on SENTRY_DSN.
# 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
end 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 # Default to self for everything, plus blob: for file downloads
policy.default_src :self, "blob:" policy.default_src :self, "blob:"
# Scripts: Allow self, importmaps, unsafe-inline for Turbo/StimulusJS, and blob: for downloads # Scripts: self + per-response nonce (see nonce config below) + blob: for
# Note: unsafe_inline is needed for Stimulus controllers and Turbo navigation # downloads. No unsafe-inline — importmap/Turbo/Stimulus inline tags carry the
policy.script_src :self, :unsafe_inline, :unsafe_eval, "blob:" # 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 # Styles: self + per-response nonce. No unsafe-inline Tailwind ships as an
# and Stimulus controller style manipulations # external stylesheet, Turbo's injected <style> carries the nonce, and Stimulus
policy.style_src :self, :unsafe_inline # sets styles via the CSSOM (not governed by CSP).
policy.style_src :self
# Images: Allow self, data URLs, and https for external images # Images: Allow self, data URLs, and https for external images
policy.img_src :self, :data, :https policy.img_src :self, :data, :https
@@ -51,14 +53,22 @@ Rails.application.configure do
# Child sources: Allow self for any future iframes # Child sources: Allow self for any future iframes
policy.child_src :self policy.child_src :self
# Additional security headers for WebAuthn # Do not enforce Trusted Types. The only valid value for
# Required for WebAuthn to work properly # require-trusted-types-for is 'script'; there is no 'none' token, so
policy.require_trusted_types_for :none # 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) # CSP reporting using report_uri (supported method)
policy.report_uri "/api/csp-violation-report" policy.report_uri "/api/csp-violation-report"
end 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 # Start with CSP in report-only mode for testing
# Set to false after verifying everything works in production # Set to false after verifying everything works in production
config.content_security_policy_report_only = Rails.env.development? config.content_security_policy_report_only = Rails.env.development?

View File

@@ -4,5 +4,8 @@
# Use this to limit dissemination of sensitive information. # Use this to limit dissemination of sensitive information.
# See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors. # See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors.
Rails.application.config.filter_parameters += [ 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 # Sentry configuration for error tracking and performance monitoring.
# Only initializes if SENTRY_DSN environment variable is set # Only initializes if the SENTRY_DSN environment variable is set.
return unless ENV["SENTRY_DSN"].present? return unless ENV["SENTRY_DSN"].present?
Rails.application.configure do Sentry.init do |config|
config.sentry.dsn = ENV["SENTRY_DSN"] config.dsn = ENV["SENTRY_DSN"]
# Set environment (defaults to Rails.env) # Environment label (defaults to Rails.env)
config.sentry.environment = ENV["SENTRY_ENVIRONMENT"] || Rails.env config.environment = ENV["SENTRY_ENVIRONMENT"] || Rails.env
# Set release version from Git or environment variable # Release version from an env var or the current Git SHA
config.sentry.release = ENV["SENTRY_RELEASE"] || `git rev-parse HEAD 2>/dev/null`.strip.presence || nil config.release = ENV["SENTRY_RELEASE"] || `git rev-parse HEAD 2>/dev/null`.strip.presence
# Sample rate for performance monitoring (0.0 to 1.0) # Only report from production unless explicitly enabled elsewhere.
config.sentry.traces_sample_rate = ENV.fetch("SENTRY_TRACES_SAMPLE_RATE", 0.1).to_f 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 # Don't send cookies, request bodies, or user IPs by default.
config.sentry.profiles_sample_rate = if Rails.env.production? 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 ENV.fetch("SENTRY_PROFILES_SAMPLE_RATE", 0.0).to_f
else else
ENV.fetch("SENTRY_PROFILES_SAMPLE_RATE", 0.5).to_f ENV.fetch("SENTRY_PROFILES_SAMPLE_RATE", 0.5).to_f
end 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 # Ignore common non-critical exceptions
config.sentry.excluded_exceptions += [ config.excluded_exceptions += [
"ActionController::RoutingError", "ActionController::RoutingError",
"ActionController::InvalidAuthenticityToken", "ActionController::InvalidAuthenticityToken",
"ActionController::UnknownFormat", "ActionController::UnknownFormat",
@@ -66,75 +48,38 @@ Rails.application.configure do
"ActiveRecord::RecordNotFound" "ActiveRecord::RecordNotFound"
] ]
# Add CSP-specific tags for security events # Attach application/user context and scrub anything sensitive before sending.
config.sentry.tags = lambda do config.before_send = lambda do |event, _hint|
{ event.tags = (event.tags || {}).merge(
# Add application context
app_name: "clinch", app_name: "clinch",
app_environment: Rails.env, app_environment: Rails.env
# Add CSP policy status )
csp_enabled: defined?(Rails.application.config.content_security_policy) &&
Rails.application.config.content_security_policy.present? 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 end
# Enhance before_send to handle CSP events properly if event.extra.is_a?(Hash)
config.sentry.before_send = lambda do |event, hint| event.extra.reject! do |key, value|
# 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) key.to_s.match?(/password|secret|token|key/i) || value.to_s.match?(/password|secret/i)
}
end 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 end
event event
end end
# Add CSP-specific breadcrumbs for security events # Scrub sensitive data out of breadcrumbs.
config.sentry.before_breadcrumb = lambda do |breadcrumb, hint| config.before_breadcrumb = lambda do |breadcrumb, _hint|
# Filter out sensitive breadcrumb data if breadcrumb.data.is_a?(Hash)
if breadcrumb[:data] breadcrumb.data.reject! do |key, value|
breadcrumb[:data].reject! { |key, value| key.to_s.match?(/password|secret|token|key|authorization/i) || value.to_s.match?(/password|secret/i)
key.to_s.match?(/password|secret|token|key|authorization/i) ||
value.to_s.match?(/password|secret/i)
}
end 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 end
breadcrumb breadcrumb
end end
# Only send errors in production unless explicitly enabled
config.sentry.enabled = Rails.env.production? || ENV["SENTRY_ENABLED_IN_DEVELOPMENT"] == "true"
end end

View File

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

View File

@@ -34,7 +34,9 @@ port ENV.fetch("PORT", 3000)
# Allow puma to be restarted by `bin/rails restart` command. # Allow puma to be restarted by `bin/rails restart` command.
plugin :tmp_restart plugin :tmp_restart
# Solid Queue plugin removed - now using async processor # Run the Solid Queue supervisor inside Puma. Clinch ships as a single
# container, so the web process is always the worker too.
plugin :solid_queue if ENV.fetch("RAILS_ENV", "development") == "production"
# Specify the PID file. Defaults to tmp/pids/server.pid in development. # Specify the PID file. Defaults to tmp/pids/server.pid in development.
# In other environments, only set the PID file if requested. # In other environments, only set the PID file if requested.

View File

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

View File

@@ -0,0 +1,8 @@
class AddOidcAuthorizationCodeIdToTokens < ActiveRecord::Migration[8.1]
def change
add_reference :oidc_access_tokens, :oidc_authorization_code,
null: true, foreign_key: true, index: true
add_reference :oidc_refresh_tokens, :oidc_authorization_code,
null: true, foreign_key: true, index: true
end
end

View File

@@ -0,0 +1,20 @@
class NullifyAuthCodeFkOnDelete < ActiveRecord::Migration[8.1]
# When an OidcAuthorizationCode is deleted (e.g. by OidcTokenCleanupJob),
# null out the FK on any descendant tokens instead of blocking the delete
# on the default RESTRICT. Token rows survive for the audit trail.
def up
remove_foreign_key :oidc_access_tokens, :oidc_authorization_codes
add_foreign_key :oidc_access_tokens, :oidc_authorization_codes, on_delete: :nullify
remove_foreign_key :oidc_refresh_tokens, :oidc_authorization_codes
add_foreign_key :oidc_refresh_tokens, :oidc_authorization_codes, on_delete: :nullify
end
def down
remove_foreign_key :oidc_access_tokens, :oidc_authorization_codes
add_foreign_key :oidc_access_tokens, :oidc_authorization_codes
remove_foreign_key :oidc_refresh_tokens, :oidc_authorization_codes
add_foreign_key :oidc_refresh_tokens, :oidc_authorization_codes
end
end

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

14
db/schema.rb generated
View File

@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2026_03_05_000001) do ActiveRecord::Schema[8.1].define(version: 2026_06_11_000001) do
create_table "active_storage_attachments", force: :cascade do |t| create_table "active_storage_attachments", force: :cascade do |t|
t.bigint "blob_id", null: false t.bigint "blob_id", null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
@@ -106,11 +106,15 @@ ActiveRecord::Schema[8.1].define(version: 2026_03_05_000001) do
end end
create_table "groups", force: :cascade do |t| 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.datetime "created_at", null: false
t.json "custom_claims", default: {}, null: false t.json "custom_claims", default: {}, null: false
t.text "description" t.text "description"
t.string "name", null: false t.string "name", null: false
t.datetime "updated_at", 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 t.index ["name"], name: "index_groups_on_name", unique: true
end end
@@ -118,6 +122,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_03_05_000001) do
t.integer "application_id", null: false t.integer "application_id", null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "expires_at", null: false t.datetime "expires_at", null: false
t.integer "oidc_authorization_code_id"
t.datetime "revoked_at" t.datetime "revoked_at"
t.string "scope" t.string "scope"
t.string "token_hmac" t.string "token_hmac"
@@ -126,6 +131,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_03_05_000001) do
t.index ["application_id", "user_id"], name: "index_oidc_access_tokens_on_application_id_and_user_id" t.index ["application_id", "user_id"], name: "index_oidc_access_tokens_on_application_id_and_user_id"
t.index ["application_id"], name: "index_oidc_access_tokens_on_application_id" t.index ["application_id"], name: "index_oidc_access_tokens_on_application_id"
t.index ["expires_at"], name: "index_oidc_access_tokens_on_expires_at" t.index ["expires_at"], name: "index_oidc_access_tokens_on_expires_at"
t.index ["oidc_authorization_code_id"], name: "index_oidc_access_tokens_on_oidc_authorization_code_id"
t.index ["revoked_at"], name: "index_oidc_access_tokens_on_revoked_at" t.index ["revoked_at"], name: "index_oidc_access_tokens_on_revoked_at"
t.index ["token_hmac"], name: "index_oidc_access_tokens_on_token_hmac", unique: true t.index ["token_hmac"], name: "index_oidc_access_tokens_on_token_hmac", unique: true
t.index ["user_id"], name: "index_oidc_access_tokens_on_user_id" t.index ["user_id"], name: "index_oidc_access_tokens_on_user_id"
@@ -162,6 +168,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_03_05_000001) do
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "expires_at", null: false t.datetime "expires_at", null: false
t.integer "oidc_access_token_id", null: false t.integer "oidc_access_token_id", null: false
t.integer "oidc_authorization_code_id"
t.datetime "revoked_at" t.datetime "revoked_at"
t.string "scope" t.string "scope"
t.integer "token_family_id" t.integer "token_family_id"
@@ -172,6 +179,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_03_05_000001) do
t.index ["application_id"], name: "index_oidc_refresh_tokens_on_application_id" t.index ["application_id"], name: "index_oidc_refresh_tokens_on_application_id"
t.index ["expires_at"], name: "index_oidc_refresh_tokens_on_expires_at" t.index ["expires_at"], name: "index_oidc_refresh_tokens_on_expires_at"
t.index ["oidc_access_token_id"], name: "index_oidc_refresh_tokens_on_oidc_access_token_id" t.index ["oidc_access_token_id"], name: "index_oidc_refresh_tokens_on_oidc_access_token_id"
t.index ["oidc_authorization_code_id"], name: "index_oidc_refresh_tokens_on_oidc_authorization_code_id"
t.index ["revoked_at"], name: "index_oidc_refresh_tokens_on_revoked_at" t.index ["revoked_at"], name: "index_oidc_refresh_tokens_on_revoked_at"
t.index ["token_family_id"], name: "index_oidc_refresh_tokens_on_token_family_id" t.index ["token_family_id"], name: "index_oidc_refresh_tokens_on_token_family_id"
t.index ["token_hmac"], name: "index_oidc_refresh_tokens_on_token_hmac", unique: true t.index ["token_hmac"], name: "index_oidc_refresh_tokens_on_token_hmac", unique: true
@@ -221,11 +229,11 @@ ActiveRecord::Schema[8.1].define(version: 2026_03_05_000001) do
end end
create_table "users", force: :cascade do |t| create_table "users", force: :cascade do |t|
t.boolean "admin", default: false, null: false
t.json "backup_codes" t.json "backup_codes"
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.json "custom_claims", default: {}, null: false t.json "custom_claims", default: {}, null: false
t.string "email_address", null: false t.string "email_address", null: false
t.integer "last_otp_at"
t.datetime "last_sign_in_at" t.datetime "last_sign_in_at"
t.string "name" t.string "name"
t.string "password_digest", null: false t.string "password_digest", null: false
@@ -274,11 +282,13 @@ ActiveRecord::Schema[8.1].define(version: 2026_03_05_000001) do
add_foreign_key "application_user_claims", "applications", on_delete: :cascade add_foreign_key "application_user_claims", "applications", on_delete: :cascade
add_foreign_key "application_user_claims", "users", on_delete: :cascade add_foreign_key "application_user_claims", "users", on_delete: :cascade
add_foreign_key "oidc_access_tokens", "applications" add_foreign_key "oidc_access_tokens", "applications"
add_foreign_key "oidc_access_tokens", "oidc_authorization_codes", on_delete: :nullify
add_foreign_key "oidc_access_tokens", "users" add_foreign_key "oidc_access_tokens", "users"
add_foreign_key "oidc_authorization_codes", "applications" add_foreign_key "oidc_authorization_codes", "applications"
add_foreign_key "oidc_authorization_codes", "users" add_foreign_key "oidc_authorization_codes", "users"
add_foreign_key "oidc_refresh_tokens", "applications" add_foreign_key "oidc_refresh_tokens", "applications"
add_foreign_key "oidc_refresh_tokens", "oidc_access_tokens" add_foreign_key "oidc_refresh_tokens", "oidc_access_tokens"
add_foreign_key "oidc_refresh_tokens", "oidc_authorization_codes", on_delete: :nullify
add_foreign_key "oidc_refresh_tokens", "users" add_foreign_key "oidc_refresh_tokens", "users"
add_foreign_key "oidc_user_consents", "applications" add_foreign_key "oidc_user_consents", "applications"
add_foreign_key "oidc_user_consents", "users" add_foreign_key "oidc_user_consents", "users"

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

Some files were not shown because too many files have changed in this diff Show More