fix(auth): address R4 S1 code-review findings
- link/upgrade OAuth cancel now redirects to /?link=cancelled (a channel App.tsx surfaces as a toast) instead of the pre-auth /?login=oauth_cancelled banner a signed-in user never sees; a plain sign-in cancel is unchanged - the login banner (suspended / oauth_cancelled) is now state, cleared on the first deliberate action like the verify banner — no more stale lingering - add a "Didn't get the verification email?" resend entry on the sign-in screen so a never-arrived verification mail isn't a dead end (was only reachable from the expired-link banner) - unit-test _oauth_redirect_uri (dev-host derive, unknown-host fallback, prod ignores the Host header — the injection guard) - collapse the 5-deep title/button ternaries into Record<Mode,string> maps
This commit is contained in:
+6
-2
@@ -308,9 +308,13 @@ async def callback(
|
||||
try:
|
||||
token = await client.authorize_access_token(request)
|
||||
except OAuthError as exc:
|
||||
# The user cancelled or denied consent at Google — land back on the login page with a
|
||||
# friendly banner instead of a raw 400 JSON error page (applies to sign-in and link flows).
|
||||
# The user cancelled or denied consent at Google — land back with a friendly notice instead
|
||||
# of a raw 400 JSON error page. A link/upgrade cancel is an ALREADY-signed-in user who never
|
||||
# sees the pre-auth login banner, so route it through the ?link= channel App.tsx surfaces as
|
||||
# a toast; a plain sign-in cancel lands on the login page with the oauth_cancelled banner.
|
||||
log.info("Google OAuth cancelled/failed: %s", exc.error)
|
||||
if link_uid is not None:
|
||||
return RedirectResponse(url="/?link=cancelled")
|
||||
return RedirectResponse(url="/?login=oauth_cancelled")
|
||||
|
||||
userinfo = token.get("userinfo")
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"""_oauth_redirect_uri decides where Google sends the OAuth code back. In production it must ALWAYS
|
||||
use the fixed configured URL and never trust the request Host (a forged Host must not be able to
|
||||
redirect the code); in local dev it honors a small allowlist of dev origins so a login started on the
|
||||
Vite server (:5173) returns to :5173."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app import auth
|
||||
|
||||
|
||||
def _req(host):
|
||||
"""Minimal stand-in for a Starlette Request: only .headers.get('host') is read."""
|
||||
return SimpleNamespace(headers={"host": host} if host is not None else {})
|
||||
|
||||
|
||||
def _dev_settings():
|
||||
return SimpleNamespace(
|
||||
session_https_only=False,
|
||||
oauth_redirect_url="http://localhost:8080/auth/callback",
|
||||
)
|
||||
|
||||
|
||||
def _prod_settings():
|
||||
return SimpleNamespace(
|
||||
session_https_only=True,
|
||||
oauth_redirect_url="https://siftlode.example.eu/auth/callback",
|
||||
)
|
||||
|
||||
|
||||
def test_dev_known_host_returns_to_that_origin(monkeypatch):
|
||||
monkeypatch.setattr(auth, "settings", _dev_settings())
|
||||
assert auth._oauth_redirect_uri(_req("localhost:5173")) == "http://localhost:5173/auth/callback"
|
||||
assert auth._oauth_redirect_uri(_req("localhost:8080")) == "http://localhost:8080/auth/callback"
|
||||
assert auth._oauth_redirect_uri(_req("127.0.0.1:5173")) == "http://127.0.0.1:5173/auth/callback"
|
||||
|
||||
|
||||
def test_dev_host_is_case_insensitive(monkeypatch):
|
||||
monkeypatch.setattr(auth, "settings", _dev_settings())
|
||||
assert auth._oauth_redirect_uri(_req("LOCALHOST:5173")) == "http://localhost:5173/auth/callback"
|
||||
|
||||
|
||||
def test_dev_unknown_or_missing_host_falls_back_to_configured(monkeypatch):
|
||||
monkeypatch.setattr(auth, "settings", _dev_settings())
|
||||
# An origin not on the allowlist (or a request with no Host) must not drive the redirect.
|
||||
assert auth._oauth_redirect_uri(_req("evil.example.com")) == "http://localhost:8080/auth/callback"
|
||||
assert auth._oauth_redirect_uri(_req(None)) == "http://localhost:8080/auth/callback"
|
||||
|
||||
|
||||
def test_prod_ignores_host_even_when_it_matches_a_dev_origin(monkeypatch):
|
||||
monkeypatch.setattr(auth, "settings", _prod_settings())
|
||||
# The host-injection guard: in production the fixed URL wins regardless of the Host header.
|
||||
assert (
|
||||
auth._oauth_redirect_uri(_req("localhost:5173"))
|
||||
== "https://siftlode.example.eu/auth/callback"
|
||||
)
|
||||
assert (
|
||||
auth._oauth_redirect_uri(_req("attacker.test"))
|
||||
== "https://siftlode.example.eu/auth/callback"
|
||||
)
|
||||
@@ -192,6 +192,9 @@ export default function App() {
|
||||
void meQuery.refetch();
|
||||
setAccountRaw(LS.settingsTab, "account");
|
||||
setPage("settings");
|
||||
} else if (result === "cancelled") {
|
||||
// The user backed out at Google's consent screen — nothing changed, just acknowledge it.
|
||||
notify({ level: "info", message: t("settings.account.googleLink.cancelled") });
|
||||
} else {
|
||||
const key = result === "conflict" || result === "mismatch" ? result : "error";
|
||||
notify({ level: "error", message: t(`settings.account.googleLink.${key}`) });
|
||||
|
||||
@@ -26,6 +26,23 @@ const PASSWORD_MIN = 10;
|
||||
|
||||
type Mode = "signin" | "register" | "forgot" | "reset" | "resend";
|
||||
|
||||
// Per-mode i18n keys for the card title and the submit button. A flat map (vs nested ternaries)
|
||||
// keeps adding a new mode a one-line change and makes a missing key a compile error.
|
||||
const TITLE_KEY: Record<Mode, string> = {
|
||||
signin: "welcome.auth.signinTitle",
|
||||
register: "welcome.auth.createTitle",
|
||||
forgot: "welcome.auth.forgotTitle",
|
||||
reset: "welcome.auth.resetTitle",
|
||||
resend: "welcome.auth.resendTitle",
|
||||
};
|
||||
const SUBMIT_KEY: Record<Mode, string> = {
|
||||
signin: "welcome.auth.signinButton",
|
||||
register: "welcome.auth.createButton",
|
||||
forgot: "welcome.auth.forgotButton",
|
||||
reset: "welcome.auth.resetButton",
|
||||
resend: "welcome.auth.resendButton",
|
||||
};
|
||||
|
||||
// Public landing + authentication. One scrollable page: a hero with the auth card (email+
|
||||
// password, Google, explicit demo) over a short marketing pitch with feature cards. Shown
|
||||
// whenever the user isn't signed in (see App.tsx). The auth forms talk to /auth/* (see the
|
||||
@@ -267,8 +284,10 @@ function AuthCard() {
|
||||
const resetToken = hashParams.get("reset");
|
||||
const verifyToken = hashParams.get("verify"); // raw token → POST to confirm (new flow)
|
||||
const bounced = params.get("access"); // Google denied → "requested" | "denied"
|
||||
const login = params.get("login"); // Google login blocked → "suspended"
|
||||
const deleted = params.get("deleted"); // self-service account deletion just completed
|
||||
// Google login outcome → "suspended" | "oauth_cancelled". State (not a const) so it clears on the
|
||||
// first deliberate action, like the verify banner — otherwise it lingers stalely on later screens.
|
||||
const [login, setLogin] = useState<string | null>(() => params.get("login"));
|
||||
// Verification outcome: from POSTing the fragment token (new flow), or the legacy
|
||||
// ?verify=ok|invalid redirect (pre-SB3 emails still in flight).
|
||||
const [verify, setVerify] = useState<string | null>(() => params.get("verify"));
|
||||
@@ -321,9 +340,10 @@ function AuthCard() {
|
||||
const reset = () => {
|
||||
setError("");
|
||||
setDone("");
|
||||
// The verify banners are a one-time entry notice — dismiss once the user takes any deliberate
|
||||
// action (mode switch, CTA, submit) so it doesn't linger stalely on later screens.
|
||||
// The verify/login banners are one-time entry notices — dismiss once the user takes any
|
||||
// deliberate action (mode switch, CTA, submit) so they don't linger stalely on later screens.
|
||||
setVerify(null);
|
||||
setLogin(null);
|
||||
};
|
||||
|
||||
async function onSubmit(e: React.FormEvent) {
|
||||
@@ -392,16 +412,7 @@ function AuthCard() {
|
||||
|
||||
const inputCls =
|
||||
"w-full bg-card border border-border rounded-xl px-3 py-2.5 text-sm outline-none focus:border-accent";
|
||||
const titleKey =
|
||||
mode === "register"
|
||||
? "welcome.auth.createTitle"
|
||||
: mode === "forgot"
|
||||
? "welcome.auth.forgotTitle"
|
||||
: mode === "reset"
|
||||
? "welcome.auth.resetTitle"
|
||||
: mode === "resend"
|
||||
? "welcome.auth.resendTitle"
|
||||
: "welcome.auth.signinTitle";
|
||||
const titleKey = TITLE_KEY[mode];
|
||||
|
||||
return (
|
||||
<div className="glass rounded-2xl p-6 sm:p-7 w-full max-w-md lg:justify-self-end">
|
||||
@@ -479,19 +490,7 @@ function AuthCard() {
|
||||
disabled={busy}
|
||||
className="px-4 py-2.5 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 disabled:opacity-50 transition"
|
||||
>
|
||||
{busy
|
||||
? t("welcome.auth.working")
|
||||
: t(
|
||||
mode === "register"
|
||||
? "welcome.auth.createButton"
|
||||
: mode === "forgot"
|
||||
? "welcome.auth.forgotButton"
|
||||
: mode === "reset"
|
||||
? "welcome.auth.resetButton"
|
||||
: mode === "resend"
|
||||
? "welcome.auth.resendButton"
|
||||
: "welcome.auth.signinButton",
|
||||
)}
|
||||
{busy ? t("welcome.auth.working") : t(SUBMIT_KEY[mode])}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -531,6 +530,20 @@ function AuthCard() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* A verification email can be lost or never arrive, so offer a resend path from sign-in
|
||||
too — not only from the expired-link banner (which needs a broken link to appear). */}
|
||||
{mode === "signin" && (
|
||||
<button
|
||||
onClick={() => {
|
||||
reset();
|
||||
setMode("resend");
|
||||
}}
|
||||
className="mt-2 block w-full text-center text-xs text-muted hover:text-fg"
|
||||
>
|
||||
{t("welcome.auth.resendLink")}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{mode === "signin" && (
|
||||
<>
|
||||
<div className="flex items-center gap-3 my-4 text-[11px] uppercase tracking-wide text-muted">
|
||||
|
||||
@@ -78,7 +78,8 @@
|
||||
"linked": "Google account linked.",
|
||||
"conflict": "That Google account is already linked to a different Siftlode account.",
|
||||
"mismatch": "That's a different Google account than the one already linked here. Sign in with the linked one.",
|
||||
"error": "Couldn't link the Google account. Please try again."
|
||||
"error": "Couldn't link the Google account. Please try again.",
|
||||
"cancelled": "Google account linking was cancelled."
|
||||
},
|
||||
"password": {
|
||||
"title": "Password",
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
"resetButton": "Set password",
|
||||
"resendButton": "Resend verification",
|
||||
"resendTitle": "Resend verification email",
|
||||
"resendLink": "Didn't get the verification email?",
|
||||
"createLink": "Create an account",
|
||||
"forgotLink": "Forgot password?",
|
||||
"backToSignin": "Back to sign in",
|
||||
|
||||
@@ -78,7 +78,8 @@
|
||||
"linked": "Google-fiók összekapcsolva.",
|
||||
"conflict": "Ez a Google-fiók már egy másik Siftlode-fiókhoz van kapcsolva.",
|
||||
"mismatch": "Ez nem az a Google-fiók, amely ide már össze van kapcsolva. Az összekapcsolttal jelentkezz be.",
|
||||
"error": "Nem sikerült összekapcsolni a Google-fiókot. Próbáld újra."
|
||||
"error": "Nem sikerült összekapcsolni a Google-fiókot. Próbáld újra.",
|
||||
"cancelled": "A Google-fiók összekapcsolása megszakadt."
|
||||
},
|
||||
"password": {
|
||||
"title": "Jelszó",
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
"resetButton": "Jelszó beállítása",
|
||||
"resendButton": "Megerősítő link újraküldése",
|
||||
"resendTitle": "Megerősítő e-mail újraküldése",
|
||||
"resendLink": "Nem kaptad meg a megerősítő e-mailt?",
|
||||
"createLink": "Hozz létre egy fiókot",
|
||||
"forgotLink": "Elfelejtetted a jelszót?",
|
||||
"backToSignin": "Vissza a belépéshez",
|
||||
|
||||
Reference in New Issue
Block a user