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>
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>
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>