handle_authorization_code_grant and handle_device_code_grant each inlined
the same ~50-line access-token / refresh-token / id-token minting and RFC
6749 §5.1 response block. Factor it into mint_and_render_tokens, parameterized
by the redeemed grant (auth code or device code), its belongs_to association,
and the claims request. Also moves the auth-code consent lookup ahead of minting
so a missing consent record can no longer leave orphaned tokens committed in the
transaction (a return inside the locked transaction does not roll back).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DUaWqESTbUEdSi9erQ3jgt
Regenerate the user_code when a freshly generated one collides with an existing
code (up to USER_CODE_MAX_ATTEMPTS), so a client never gets a uniqueness error
just because two codes happened to match; the DB unique index stays the final
guard against a concurrent-insert race.
Replace the model test that asserted Rails' uniqueness validator message
("has already been taken") — per CLAUDE.md we don't test framework behavior —
with one that exercises the app-specific property: generation retries past a
collision and yields a unique code.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F7cwhwDJp3MJJDoNPVE6zq
The supported-scope list lived on OidcController, so the device authorization
controller (and a test) had to reach across into OidcController::SUPPORTED_SCOPES.
Move it to a shared OidcScopes::SUPPORTED module — one source of truth for the
OIDC controller, the device flow, and consent handling — and update every
reference. No behavior change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Q4ATZHoMCWqvSpE2yYoie
Security-review follow-ups on the OIDC flows.
IPv6 loopback redirect_uri in dynamic client registration (RFC 8252):
- valid_redirect_uri? compared URI#host, which returns the bracketed "[::1]"
for IPv6 literals, so http://[::1]:PORT/... was always rejected. Compare
#hostname (unbracketed) instead, unblocking IPv6-only native clients.
Extract redirect_authorize_error (removes 12 copies of the boilerplate):
- The authorize/consent flows built the error redirect by hand ~a dozen times
(error_uri = "...?error=..."; += "&error_description=#{CGI.escape ...}"; +=
state; redirect_to). One helper now composes the query, and — unlike every
copy — appends "&error=..." when the redirect_uri already has a query string
instead of a malformed second "?".
Extract token-endpoint helpers (removes ~80 duplicated lines):
- authenticate_token_client: the identical client-auth preamble shared by the
authorization-code, refresh, and device-code grants.
- render_token_triple: the access + refresh + id_token mint and RFC 6749 §5.1
response shared by the authorization-code and device-code grants.
Single source of truth for scope descriptions:
- New OidcHelper#scope_description + shared shared/_scope_list partial, used by
both the browser consent screen and the device authorization screen; drops the
scope_labels hash and the hard-coded per-scope blocks that would have drifted.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Q4ATZHoMCWqvSpE2yYoie
The terminal states (expired / already-used / not-found) were duplicated as
markup in show and result, and #verify re-implemented the nil→expired→!pending
cascade that #show already had — a copy edit meant four touch points.
- device_code_state(dc) resolves a code to :not_found / :expired /
:already_handled / :ok. Both #show and #verify branch on it, so the cascade
lives in one place.
- _terminal_state partial renders the terminal heading/message/link once;
result.html.erb now renders it instead of inlining the markup.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F7cwhwDJp3MJJDoNPVE6zq
The consent record is unique on user+application and shared between the browser
authorization flow and device-flow approval. Centralize the upsert in
OidcUserConsent.record!(merge:) so a narrower device request unions its scopes
into any existing grant (merge: true) rather than overwriting it and wiping
stored claims, while the browser consent screen records exactly the approved
scopes (merge: false). scopes is now nil-safe for not-yet-saved records.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F7cwhwDJp3MJJDoNPVE6zq
Discovery advertised the device_code grant in grant_types_supported, but dynamic
client registration only allowed authorization_code/refresh_token, so a client
listing the device grant in its RFC 7591 metadata was rejected with
invalid_client_metadata.
Introduce a single source of truth — OidcController::SUPPORTED_GRANT_TYPES —
used by discovery (grant_types_supported) and by registration validation, so
advertisement and registration can never drift. The token dispatcher already
handles exactly these grants for all clients (they are user-context grants gated
by consent + user_allowed?, so there is no per-client grant restriction), making
advertisement, registration, and enforcement consistent.
Tests: a client registering with the device_code grant now succeeds, plus an
assertion that registration's accepted set equals discovery's advertised set.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F7cwhwDJp3MJJDoNPVE6zq
More device-flow security-review fixes.
Replay-revocation on device_code redemption (was: destroy on use):
- Redemption now marks the code redeemed_at and links the issued access/refresh
tokens back to it (new oidc_device_code_id FKs, on_delete: :nullify) instead of
destroying the row. A replayed redeemed code is detected as reuse — revoking
every descended token and returning a distinguishable "already been used"
invalid_grant rather than the generic "Invalid device_code" — mirroring the
authorization-code reuse semantics (RFC 6749 §4.1.2). The device_code FK is
carried forward across refresh rotation so revocation reaches the whole chain.
Redeemed codes are reaped by the existing expiry cleanup sweep.
user_code CSPRNG (RFC 8628 §6.1):
- The 8-char user_code is a credential (type it + Approve mints tokens), so draw
it from SecureRandom instead of Ruby's global Mersenne Twister (Array#sample).
Rate-limit the /device verification endpoint (RFC 8628 §5.1):
- Add the app's standard 10/min limit on show + verify so a signed-in user can't
brute-force the short code space to deny or hijack a pending authorization.
Also carried in this commit (other agent's working-tree change):
- Give each OidcController rate_limit a distinct name: so frequent device polling
on the token endpoint no longer shares one counter with (and 429s) unrelated
token/refresh/revoke/introspect calls.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Q4ATZHoMCWqvSpE2yYoie
Two more fixes from the device-flow security review:
slow_down interval grew without bound (OidcController):
- Each too-fast poll bumped the persisted interval by 5s with no ceiling, so a
client polling slightly fast — or an attacker spamming a known device_code —
could balloon it (5→10→15→…) past the 10-minute expiry and starve a legitimate
client of its token. Clamp the bump to OidcDeviceCode::MAX_INTERVAL (30s); a
client polling at the advertised interval is never throttled.
Expired device codes accumulated forever (OidcTokenCleanupJob):
- Anonymous callers can create device codes via /oauth/device_authorization, and
expired/denied/abandoned rows were never reaped. Sweep rows past expiry (with a
1-hour grace to stay clear of in-flight redemption); redeemed codes are already
destroyed at token issuance.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Q4ATZHoMCWqvSpE2yYoie
Fixes from review of the device flow / introspection / DCR work:
Introspection over-disclosure (RFC 7662):
- Restrict introspection to authorized callers — the client a token was issued
to, or a resource server registered (resource_identifiers) to serve the token's
bound RFC 8707 audience. Unauthorized callers get {active:false}, disclosing
nothing, instead of any confidential client reading any token.
- Scope-gate disclosed claims: username only with the email scope, groups only
with the groups scope (mirrors userinfo). ADR 0005.
Tokens minted for revoked users:
- Re-check application.user_allowed?(user) at mint time in the device, refresh,
and authorization-code grants (covers app-active, user-active, group
membership). A user deactivated or removed from the allowed group between
approval and the token request is refused with access_denied. The refresh
check runs before rotation so a denied refresh has no side effects.
Device authorization hardening:
- Require confidential clients to authenticate, and require PKCE (code_challenge)
up front for clients that require it, so an intercepted device_code plus a
known public client_id cannot redeem tokens.
- Merge (not overwrite) the shared consent record on device approval.
Tests: new oidc_introspection_test and oidc_mint_authorization_test; existing
PKCE/claims tests updated to grant app access (they created ungrouped apps and
relied on the token endpoint not checking authorization). Full suite green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F7cwhwDJp3MJJDoNPVE6zq
Clients can name a target resource server via the `resource` parameter at the
authorization and device-authorization endpoints. Clinch binds it to the issued
token as its audience and reports it at introspection as `aud`, so a token minted
for one API cannot be replayed against another that also trusts clinch.
- `resource` is validated syntax-only (absolute URI, no fragment) — pass-through,
no resource registry; the resource server enforces the audience on introspection
- threaded from authorize / device_authorization onto the auth/device code, then
the access and refresh tokens, and carried across refresh rotation
- invalid values are rejected with error=invalid_target
- introspection `aud` is the bound resource, falling back to the client_id
- ADR 0004 records the pass-through-vs-registry decision
Completes the clinch-side OAuth surface for MCP connectors (with DCR +
introspection); Protected Resource Metadata (RFC 9728) lives on the resource server.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F7cwhwDJp3MJJDoNPVE6zq
Adds three OAuth surfaces to the OIDC provider so CLIs, terminal agents, and
MCP connectors can authenticate as real users instead of using static API keys.
Device Authorization Grant (RFC 8628):
- OidcDeviceCode model (HMAC device_code, short plaintext user_code, nullable
user until approval, slow_down polling state), mirroring OidcAuthorizationCode
- POST /oauth/device_authorization issues the code pair + verification URIs
- device_code grant on /oauth/token returns authorization_pending / slow_down /
access_denied / expired_token, then the standard token triple; single-use
- Authenticated /device approval page, gated by Application#user_allowed?
Token Introspection (RFC 7662):
- POST /oauth/introspect: confidential-caller-authenticated; returns active,
scope, and the user's groups so resource servers can authorize on membership
Dynamic Client Registration (RFC 7591):
- POST /oauth/register creates public/confidential clients (PKCE required)
- Runtime toggle via a new Setting store + admin switch on the Applications page;
off by default, env var CLINCH_DCR_ENABLED as bootstrap fallback
- New clients are default-deny (no allowed_groups) until an admin grants access
- RFC 8414 metadata alias at /.well-known/oauth-authorization-server;
registration_endpoint advertised only while the window is open
Discovery advertises all three grants/endpoints. Seeds add a clinch-cli public
client and a c2a2-introspection confidential client. ADRs in docs/decisions
record the opaque-vs-JWT, device-flow, and DCR-security decisions.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F7cwhwDJp3MJJDoNPVE6zq
Ships the access-check GET-form fix (782e197) as a published image.
v0.16.2 was bumped before the version-bump build workflow existed, so it
never built; this bump triggers the build via the registered push trigger.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Port the build pipeline from the splat sibling project. Instead of
triggering on git tags, the image now builds when
config/initializers/version.rb changes on main — a version bump IS the
release — plus a workflow_dispatch button for manual builds.
Reads Clinch::VERSION, tags the image :vX.Y.Z, and moves :latest only
for non-pre-release versions. Also builds multi-arch (amd64 + arm64) on
native runners and stitches a manifest, replacing the amd64-only build.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
backchannel_logout_uri was validated only for scheme/HTTPS, so an admin (or a
compromised admin account) could point it at internal infrastructure — cloud
metadata (169.254.169.254), loopback, or RFC1918 hosts — and every user logout
would fire a server-side POST there.
Add PrivateAddressCheck (app/lib) and apply it as defense-in-depth:
- Application validation rejects URIs whose host is, or is a literal, internal
address (loopback / private / link-local / 0.0.0.0 / localhost / metadata
hostnames). Fast, DNS-free, immediate admin feedback.
- BackchannelLogoutJob re-checks at request time WITH DNS resolution and aborts
(no retry) if the host resolves to a non-public address — covering URIs that
predate the validation and public hostnames pointed at internal IPs.
Tests cover the address classification, the model validation, and updates an
existing test that used a localhost logout URI.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
verify_totp called ROTP without `after:`, so a captured 6-digit code stayed
valid for the full ~90s drift window and could be replayed in a separate
sign-in. Add a last_otp_at column, pass it as ROTP's `after:`, and persist the
matched timestep on success so a code (or any earlier one) cannot be reused.
Also fixes a latent bug surfaced by the new replay path: enable_totp! did
`self.backup_codes = generate_backup_codes`, reassigning backup_codes to the
plaintext return value (generate_backup_codes already stores the BCrypt hashes
internally). That stored backup codes in plaintext and broke verification.
enable_totp! is test-only today, but it is public and backup_codes is not
encrypted, so this is a real footgun. Now it just calls generate_backup_codes.
Rewrites the mislabeled "TOTP code cannot be reused" test to actually assert
that replaying an accepted code is rejected.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
determine_base_url fell back to request.host when CLINCH_HOST was unset. Rails
resolves request.host from X-Forwarded-Host behind a trusted proxy, so a spoofed
header could make the forward-auth login redirect point at an attacker origin
(host-header phishing).
- Add config/initializers/clinch_host.rb: fail fast at boot in any non-local
environment when CLINCH_HOST is blank. It anchors the OIDC issuer, WebAuthn
RP ID, and login redirect, so it must be explicit, never inferred.
- determine_base_url now uses CLINCH_HOST (guaranteed in production) with a safe
localhost default for dev/test, and never reads the request host.
- Simplify the spoofed-host regression test now that the fallback is safe.
Verified: production boot aborts with a clear message when CLINCH_HOST is blank,
and boots normally when set.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
render_unauthorized built the post-login return URL directly from the
attacker-influenceable X-Forwarded-Host / X-Forwarded-Uri headers, stored it
in the session, and reflected it into the signin `rd`. After authentication
that URL is followed with allow_other_host, so a spoofed host was an open
redirect.
Now the forwarded URL is only honoured if it resolves to a known, active
forward-auth application (via validate_redirect_url); otherwise it falls back
to a validated `rd` or the IdP's base URL. Once render_unauthorized only ever
stores a validated value, the sessions_controller "supplement, don't replace"
behaviour is safe, so no change is needed there.
Two integration tests were asserting the old behaviour by reflecting
unregistered hosts (grafana.example.com, app.example.com); they now register
those domains as forward-auth apps so they exercise the real feature. Adds a
regression test that a spoofed X-Forwarded-Host is neither stored nor
reflected.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The ApiKey model only validates group access on creation (user_must_have_access
runs on create). The bearer path in /api/verify never re-checked, so a user
removed from an application's allowed groups kept access via an existing key
until it was manually revoked.
Add an app.user_allowed?(user) check to authenticate_bearer_token, matching the
session path, returning 401 when the user no longer has group access. Adds a
regression test that revokes membership after key creation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two HIGH-severity findings from the security review:
- ForwardAuth: when no host header was present, /api/verify skipped the
application lookup and group check entirely, returning 200 with identity
headers (including all of the user's groups). This bypassed per-domain
access control. Now fails closed with 403, and the unreachable
DEFAULT_HEADERS fallback (the bypass path) is removed so headers are
always scoped to a resolved, active application.
- OIDC: the consent endpoint was in the verify_authenticity_token skip
list, so a forged cross-site POST could silently grant OAuth scopes.
Removed :consent from the skip list (the form already embeds the token).
Adds regression tests for both: fail-closed with no identity headers when
host is absent, and 422 on a tokenless consent POST.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Applications index used to render "All users" whenever an app had
no allowed_groups; under default-deny that's the opposite of the truth.
Replaced with a "No one" badge and, when groups are present, a
"N users · M groups" cell so the access reality is visible at a glance.
Added a small stats strip above the apps table: applications, users
with access, and groups granting access. Backed by preloaded counts in
the controller to avoid N+1.
Added /admin/access — a small "Access check" tool that takes a user
and an application and reports whether the user can reach it, with the
granting group(s) when allowed, and the specific reason when not
(inactive app/user, no allowed groups, or no shared group). Wired into
the admin sidebar.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 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>
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>
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.
When an application has no icon attached, render a deterministic
monogram SVG instead of the generic picture-frame placeholder. Initials
are picked from capital letters in the name (ShelfLife -> SL); fall
back to the first two letters when fewer than two capitals exist
(Audiobookshelf -> AU). Background colour is hashed from the name for
stable per-app identity across visits.
Adds an optional second icon attachment, icon_dark, alongside the main
icon. When present, render a <picture> with a prefers-color-scheme:
dark source so the browser swaps automatically; when absent, the main
icon is used in both modes. The SVG sanitization, content-type fix,
and size/format validation now run over both attachments uniformly.
Bumps to 0.14.0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The sanitize_svg_icon before_validation callback called icon.download,
but Active Storage uploads pending blobs in before_save — so at
before_validation time the file only existed in the request tempfile,
not at the configured storage path. Read from the pending attachable
(UploadedFile / IO hash) instead. Guards against the recursive callback
that icon.attach would otherwise trigger by tracking the cleaned
attachable by object identity. Bumps to 0.13.1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the implicit "empty allowed_groups means public" rule with
explicit default-deny across both OIDC and ForwardAuth. Adds two boolean
flags on Group — auto_assign (Keycloak-style auto-join on user create)
and admin (members can reach the admin panel) — and drops the
users.admin column entirely. Adds "Users with access" and "Accessible
applications" panels with via-group badges on the application/user show
pages.
BEHAVIOR CHANGE: a ForwardAuth app with no allowed_groups previously
bypassed authentication entirely; it now returns 403 like any other
unauthorized request. The data migration seeds an "everyone" group and
attaches it to all previously group-less apps to preserve behavior on
existing installs. An "admins" group is seeded and backfilled from any
user with the old admin column.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds an "Assigned Applications" checkbox list to the group new/edit
form so admins can grant a group access to multiple apps from one
screen, instead of editing each application individually.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.
Previously the copy-pasteable env-var block only appeared right after
creating an app or regenerating credentials. Operators had no easy way
back to it, so they had to reconstruct OIDC_DISCOVERY_URL etc. from
memory.
Adds a collapsed <details> disclosure inside the OIDC Configuration
card with the same env vars (placeholder for the secret, which can't
be re-shown). Extracts the env-line construction into an
oidc_env_lines helper so the flash panel and the persistent display
share one source of truth.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Surfaces OIDC_CLIENT_ID/SECRET, discovery URL, provider name, and PKCE
flag in a single textarea on the credentials flash so the client config
can be dropped straight into a consuming app's .env file.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>