feat(auth): make HTTPS enforcement legible instead of silently failing sign-in - #112
Merged
Merged
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 aSecurecookie set on anhttp://page — so the login returned200, 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.
Secureis deliberately not auto-dropped from the observed scheme. A reverse proxy terminating TLS makes the app see HTTP, so auto-detection would stripSecureon genuinely-HTTPS deployments — a silent security downgrade of every correctly-configured production install.What changed
Startup log —
log_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 emptySCRYE_FORWARDED_ALLOW_IPSeach draw their own warning.Refusal at every session-minting path — password login, first-admin setup, MFA verification, and the OIDC login start now return
503with 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
ERRORand, on the password flow, says which way the credentials came out: a valid-credential rejection readsTHE SUBMITTED CREDENTIALS WERE VALID/This is NOT a bad-password rejection. A newauth.login_blocked_insecure_transportaudit action carries{flow, scheme, credentials_valid}; bad credentials still also recordauth.login_failed, so failed-login accounting is intact.Login/setup banner —
InsecureTransportAlert, driven by two new transport-only fields onGET /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-Protohandled properly — newapp/core/forwarded.py(ForwardedProtoMiddleware+ aTrustedProxiesparser), 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.authenticatealready burns an argon2 verification for an unknown user). The banner renders from/auth/statusbefore 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-ipsbydocker/entrypoint.sh, so the header was honored in the shipped image — but only there: untested, invisible to app-level code, and absent underTestClientor a dev server started without those flags. The middleware makes it explicit and testable, reusingSCRYE_FORWARDED_ALLOW_IPSrather than adding a second, divergable setting. Two load-bearing properties:http→https, 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.testclientplaceholder — 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.503was chosen over401(the credentials are not what's being rejected) and over421(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-Protohonored 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, andCHANGELOG.mdtouched;.env.exampleregenerated from the amendedSettingsdescriptions.See
docs/ARCHIVE.md§ Deviations (2026-07-29) for the full rationale.