Skip to content

feat(auth): make HTTPS enforcement legible instead of silently failing sign-in - #112

Merged
tyler-rich merged 1 commit into
devfrom
claude/https-enforcement-cookies-kpgqht
Jul 29, 2026
Merged

feat(auth): make HTTPS enforcement legible instead of silently failing sign-in#112
tyler-rich merged 1 commit into
devfrom
claude/https-enforcement-cookies-kpgqht

Conversation

@tyler-rich

@tyler-rich tyler-rich commented Jul 29, 2026

Copy link
Copy Markdown
Owner

Summary

A user deployed over plain HTTP and got 401s with valid credentials, with nothing in the logs or the UI explaining why. The app was working correctly: the session cookie is Secure, and a browser silently discards a Secure cookie set on an http:// page — so the login returned 200, the cookie never landed, and every request after it was unauthenticated. Nothing observable said so from either end.

The cookie posture is unchanged. The failure is now legible.

Secure is deliberately not auto-dropped from the observed scheme. A reverse proxy terminating TLS makes the app see HTTP, so auto-detection would strip Secure on genuinely-HTTPS deployments — a silent security downgrade of every correctly-configured production install.

What changed

Startup loglog_https_enforcement() states whether enforcement is on and, when it is, that logins over plain HTTP will fail unless the operator opts out, naming the exact variable and value (SCRYE_SESSION_COOKIE_SECURE=false) plus the reverse-proxy alternative. With enforcement off it warns that session cookies now travel in cleartext. A *, unparseable entries, or an empty SCRYE_FORWARDED_ALLOW_IPS each draw their own warning.

Refusal at every session-minting path — password login, first-admin setup, MFA verification, and the OIDC login start now return 503 with a transport-specific message rather than a session the browser will throw away. Setup is refused before the admin is created: creating it and then failing to log in would leave bootstrap permanently 409ing with nobody able to sign in.

Distinct log + audit path — the refusal logs at ERROR and, on the password flow, says which way the credentials came out: a valid-credential rejection reads THE SUBMITTED CREDENTIALS WERE VALID / This is NOT a bad-password rejection. A new auth.login_blocked_insecure_transport audit action carries {flow, scheme, credentials_valid}; bad credentials still also record auth.login_failed, so failed-login accounting is intact.

Login/setup bannerInsecureTransportAlert, driven by two new transport-only fields on GET /auth/status (https_enforced, transport_secure). It says this is an HTTPS configuration issue and not wrong credentials, and names all three remedies.

X-Forwarded-Proto handled properly — new app/core/forwarded.py (ForwardedProtoMiddleware + a TrustedProxies parser), wired as the outermost middleware. This is the real fix for shape 2, where most affected users actually are.

Non-disclosure

The credential check still runs before the refusal — that is what lets the log distinguish a valid from an invalid login — but the client-visible result is byte-identical for valid, invalid, and unknown accounts: same status, same detail, and the same work performed (service.authenticate already burns an argon2 verification for an unknown user). The banner renders from /auth/status before anything is submitted, so it cannot reflect credential state either. Only the server-side log and the admin-only audit log carry the distinction, and a test asserts the identical-response property directly.

Trusted-proxy design

uvicorn was already given --proxy-headers --forwarded-allow-ips by docker/entrypoint.sh, so the header was honored in the shipped image — but only there: untested, invisible to app-level code, and absent under TestClient or a dev server started without those flags. The middleware makes it explicit and testable, reusing SCRYE_FORWARDED_ALLOW_IPS rather than adding a second, divergable setting. Two load-bearing properties:

  • Upgrade-only (httphttps, never the reverse). uvicorn rewrites the ASGI scope's client entry to the forwarded client once it trusts a hop, so by the time this middleware runs the peer is no longer the proxy's address and a downgrade decided from it would be wrong. Real TLS at Scrye's own listener also always wins.
  • Never blanket. A peer that isn't a parseable IP inside a configured network — including hostnames and the testclient placeholder — is untrusted, so no client can simply claim HTTPS. * is accepted for parity with uvicorn's flag but draws a loud startup warning; unparseable entries are reported and ignored rather than silently shifting the boundary.

503 was chosen over 401 (the credentials are not what's being rejected) and over 421 (browsers give it special retry handling on HTTP/2 connection coalescing).

Tests

  • backend/tests/test_https_enforcement.py — 29 tests: cookie attributes under all three deployment shapes (direct HTTPS / behind a TLS-terminating proxy / plain HTTP, with and without the opt-out), the distinct valid- vs. invalid-credential log path and its audit record, the identical-response property, X-Forwarded-Proto honored only from trusted sources (bare IP, CIDR, untrusted peer, non-IP peer, proxy chain, upgrade-only), and the startup log's contents.
  • frontend/src/pages/LoginPage.httpsAlert.test.tsx — 4 tests covering the banner.

Full backend suite and the Vitest suite pass; ruff, black, ESLint, and Prettier are clean.

Docs

README gains "If you're not using HTTPS" under Reverse proxy (TLS), covering the three shapes, the env var, the reverse-proxy guidance, and an explicit security caveat about the cleartext session cookie. Security model, Configuration, and Troubleshooting sections updated; CONTRIBUTING.md, docker/docker-compose.yml, and CHANGELOG.md touched; .env.example regenerated from the amended Settings descriptions.

See docs/ARCHIVE.md § Deviations (2026-07-29) for the full rationale.

…g sign-in

Session cookies are Secure by default, and a browser discards a Secure cookie
set on an http:// page. Deploying over plain HTTP therefore produced repeated
401s with valid credentials, with nothing in the logs or the UI explaining why:
the login returned 200, the cookie never landed, and every request after it was
unauthenticated. The cookie posture is unchanged; the failure is now legible.

- Startup logs whether HTTPS enforcement is on and, when it is, that logins over
  plain HTTP will fail unless the operator opts out — naming
  SCRYE_SESSION_COOKIE_SECURE=false explicitly. Enforcement off warns that
  cookies now travel in cleartext. Wildcard, unparseable, and empty
  SCRYE_FORWARDED_ALLOW_IPS values each get their own warning.
- Every session-minting path (password login, first-admin setup, MFA verify,
  OIDC login start) refuses with 503 and a transport-specific message rather
  than issuing a session the browser will throw away. Setup is refused before
  the admin is created, so bootstrap stays re-runnable.
- The refusal logs at ERROR and states whether the submitted credentials were
  valid, so a valid-credential rejection never reads as a bad-password 401, and
  records a distinct auth.login_blocked_insecure_transport audit action. The
  client-visible refusal is identical for valid, invalid, and unknown accounts.
- The login and setup screens show a banner explaining this is an HTTPS
  configuration issue, not wrong credentials, driven by two transport-only
  fields on /auth/status so it never reflects credential state.
- X-Forwarded-Proto is honored so a TLS-terminating proxy can report the
  client's real scheme, trusted only from the peers already configured in
  SCRYE_FORWARDED_ALLOW_IPS and only ever upgrading http -> https. Secure is
  never dropped from an auto-detected scheme, which would silently downgrade
  every deployment behind such a proxy.

See docs/ARCHIVE.md § Deviations (2026-07-29) for the full rationale.
@tyler-rich
tyler-rich merged commit b533bd1 into dev Jul 29, 2026
4 checks passed
@tyler-rich
tyler-rich deleted the claude/https-enforcement-cookies-kpgqht branch July 29, 2026 04:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant