fix(auth): a Google connect names its tab's account too, and a refused tab says why

- /auth/google/connect is a navigation and carries no headers: the tab names the account it
  was built for in `expect`, checked by the same comparison as the request header
  (built_for_another_account); a mismatch lands on /?account=changed
- the link marker names the OAuth state of its own flow: an older flow coming back no longer
  spends the marker of the connect still open (which then returned as a plain sign-in)
- a 401 account_changed reloads the tab with a one-time flag, and the reloaded tab says it
  now shows the account signed in
- a refused switch to a suspended or not-yet-approved account says so in the signed-in
  app's words, naming the address a request was recorded for
- the prefs merge locks FOR NO KEY UPDATE, so a transaction that only added a row referring
  to the account does not hold the save up; a test pins the lock
- giving up a lost page closes a channel page open over it
- e2e README names the chromium-only specs
This commit is contained in:
2026-10-21 02:05:33 +02:00
parent 94418a087e
commit ef99f7b5c7
19 changed files with 487 additions and 121 deletions
+15 -7
View File
@@ -272,15 +272,23 @@ def stamp_session(request: Request, user: User) -> None:
EXPECTED_ACCOUNT_HEADER = "x-siftlode-expect-account"
def built_for_another_account(user_id: int, expected: str | None) -> bool:
"""Whether a tab naming `expected` is showing another account than the one signed in. No name is
no claim (a caller from before this check, a script): only a mismatch is refused, and the
session stays — it is the other tab's sign-in, and it holds. The one comparison for every way a
tab names its account: this header on a request, `expect` on a navigation."""
if expected is None or expected == str(user_id):
return False
log.info(
"a request from a tab built for another account was refused",
extra=logs.event("auth.session.account_changed", user_id=user_id),
)
return True
def current_user(request: Request, db: Session = Depends(get_db)) -> User:
user = _session_user(request, db, session_user_id(request))
expected = request.headers.get(EXPECTED_ACCOUNT_HEADER)
if expected is not None and expected != str(user.id):
# The session stays: it is the other tab's sign-in, and it holds.
log.info(
"a request from a tab built for another account was refused",
extra=logs.event("auth.session.account_changed", user_id=user.id),
)
if built_for_another_account(user.id, request.headers.get(EXPECTED_ACCOUNT_HEADER)):
raise refused(401, "account_changed")
return user
+56 -24
View File
@@ -8,6 +8,7 @@ from datetime import datetime, timedelta, timezone
from typing import Literal, NamedTuple
from urllib.parse import urlencode
from authlib.common.security import generate_token
from authlib.integrations.starlette_client import OAuth, OAuthError
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
from fastapi.responses import JSONResponse, RedirectResponse
@@ -29,6 +30,7 @@ from app.auth import (
PASSWORD_MIN_LEN,
READ_SCOPES,
WRITE_SCOPES,
built_for_another_account,
current_user,
env_admin_canonicals,
env_allowed_canonicals,
@@ -322,6 +324,25 @@ class SetPasswordIn(BaseModel):
current_password: str | None = None
# A connect in flight, in the session: `{state, uid, explicit}`. It names the OAuth state of the flow
# that set it, because the session is the browser's, not the flow's: a callback coming back with
# another state (an older flow, which authlib then refuses) must not spend the marker of the connect
# still open, or that connect would return as a plain sign-in — signing someone else in, or storing
# a YouTube grant on whichever account the Google identity already belongs to.
_LINK_MARKER = "oauth_link"
# Where a navigation from a tab showing another account than the one signed in lands: the app, which
# boots as the account signed in now and says why (the frontend's `ACCOUNT_CHANGED_FLAG`).
ACCOUNT_CHANGED_LANDING = "/?account=changed"
def _take_link_marker(request: Request) -> dict | None:
marker = request.session.get(_LINK_MARKER)
if not isinstance(marker, dict) or marker.get("state") != request.query_params.get("state"):
return None
return request.session.pop(_LINK_MARKER)
@router.get("/login")
async def login(request: Request, db: Session = Depends(get_db)):
# access_type=offline ensures a refresh_token on first authorization. We avoid
@@ -332,11 +353,10 @@ async def login(request: Request, db: Session = Depends(get_db)):
# Google sign-in isn't configured on this instance — land back on the login page (the UI
# hides the button, so this is just a safety net for a stale/direct hit).
return RedirectResponse(url="/")
# A sign-in, never a link: a connect abandoned at Google (Back, a closed tab) never reached the
# callback that pops its markers, and left behind they would attach the identity signing in
# here to the account still signed into this browser.
request.session.pop("oauth_link_uid", None)
request.session.pop("oauth_link_explicit", None)
# A sign-in, never a link. Starting it retires every earlier flow's state (authlib keeps only the
# newest), so a connect's marker left from before — abandoned at Google, or still open in
# another tab — names a flow that can no longer finish.
request.session.pop(_LINK_MARKER, None)
return await client.authorize_redirect(
request,
_oauth_redirect_uri(request),
@@ -357,13 +377,12 @@ async def callback(
if client is None:
return RedirectResponse(url="/")
# Linking mode: an already-authenticated account is attaching this Google identity (a
# password account enabling YouTube or connecting SSO). The markers are set by
# /auth/google/connect; when present we attach to the current user instead of identifying by sub.
# Pop them BEFORE the token exchange: if the user aborts Google's consent screen the exchange
# throws below, and a marker left behind would silently turn the NEXT clean sign-in into a link
# attempt. A connect that never comes back here at all is cleared by /auth/login instead.
link_uid = request.session.pop("oauth_link_uid", None)
link_explicit = request.session.pop("oauth_link_explicit", False)
# password account enabling YouTube or connecting SSO). The marker is set by
# /auth/google/connect; when it is this flow's we attach to the current user instead of
# identifying by sub. Taken BEFORE the token exchange: if the user aborts Google's consent screen
# the exchange throws below, and a marker left behind would silently turn the NEXT clean sign-in
# into a link attempt. A connect that never comes back here at all is cleared by /auth/login.
link = _take_link_marker(request)
try:
token = await client.authorize_access_token(request)
except OAuthError as exc:
@@ -385,8 +404,8 @@ async def callback(
sub = userinfo["sub"]
email = (userinfo.get("email") or "").lower()
if link_uid is not None:
return _complete_link(request, db, link_uid, link_explicit, sub, userinfo, token)
if link is not None:
return _complete_link(request, db, link["uid"], link["explicit"], sub, userinfo, token)
if not is_allowed(db, email):
# No uid to name — having no account here is the branch. And the address is left out on
@@ -589,8 +608,8 @@ def _complete_link(
"""Attach the just-authorized Google identity to the already-signed-in account that started
the connect. No is_allowed gate — they're already an active user. Refuses to hijack a
Google identity owned by another account, or to silently swap a different linked identity.
`explicit` (the popped oauth_link_explicit marker) distinguishes a "Connect Google" click from
a YouTube grant — the caller pops it up front so an aborted flow leaves nothing behind."""
`explicit` (from the link marker) distinguishes a "Connect Google" click from a YouTube grant —
the caller takes the marker up front so an aborted flow leaves nothing behind."""
# The marker must name the account still signed into THIS browser, and that session must still
# hold: a sign-in as someone else between the start and the callback replaced it (the identity
# must not land on the newcomer), and a session revoked meanwhile ("log out everywhere", a
@@ -626,9 +645,10 @@ async def logout(request: Request):
"""Sign this browser out: the session holds one account, and it ends — unless the tab asking was
built for another account than the one signed in now (a sign-in as someone else in another tab):
its Sign out would end THAT person's session (see `EXPECTED_ACCOUNT_HEADER`)."""
expected = request.headers.get(EXPECTED_ACCOUNT_HEADER)
signed_in = session_user_id(request)
if expected is not None and signed_in is not None and expected != str(signed_in):
if signed_in is not None and built_for_another_account(
signed_in, request.headers.get(EXPECTED_ACCOUNT_HEADER)
):
raise refused(401, "account_changed")
request.session.clear()
return JSONResponse({"ok": True})
@@ -1248,23 +1268,35 @@ _CONNECT: dict[str, tuple[str, str]] = {
async def google_connect(
request: Request,
access: Literal["identity", "read", "write"],
expect: str | None = None,
user: User = Depends(current_user),
db: Session = Depends(get_db),
):
"""Attach Google to the signed-in account: its identity, or YouTube read/write access on top.
The shared callback sees the `oauth_link_*` markers and attaches what Google returns to THIS
account — without them a password account (no google_sub) would get a brand-new, separate Google
user created instead. It stores whatever scopes Google grants, so `can_read` / `can_write` flip
on once granted."""
The shared callback sees the link marker and attaches what Google returns to THIS account —
without it a password account (no google_sub) would get a brand-new, separate Google user
created instead. It stores whatever scopes Google grants, so `can_read` / `can_write` flip on
once granted.
`expect` is the account the tab was built for. A navigation carries no headers, so the check
every request gets (`EXPECTED_ACCOUNT_HEADER`) rides the query here: a tab still showing the
account signed in before would otherwise attach a Google identity to the one signed in now."""
if built_for_another_account(user.id, expect):
return RedirectResponse(url=ACCOUNT_CHANGED_LANDING)
client = google_oauth(db)
if client is None:
return RedirectResponse(url="/?link=error")
scope, prompt = _CONNECT[access]
request.session["oauth_link_uid"] = user.id
request.session["oauth_link_explicit"] = access == "identity"
state = generate_token(48)
request.session[_LINK_MARKER] = {
"state": state,
"uid": user.id,
"explicit": access == "identity",
}
return await client.authorize_redirect(
request,
_oauth_redirect_uri(request),
state=state,
access_type="offline",
prompt=prompt,
include_granted_scopes="true",
+4 -1
View File
@@ -139,7 +139,10 @@ def update_preferences(
# Merge so partial updates don't wipe other keys — over the stored row read again under a row
# lock: a client saves keys one at a time (several at once on a boot that moves this browser's
# copies to the account), and two merges over the same earlier read would keep only the last.
db.refresh(user, with_for_update=True)
# FOR NO KEY UPDATE, as bump_session_epoch: it still orders two merges, and a transaction that
# only added a row referring to the account (a playlist pull, then minutes of YouTube) does not
# hold the save up.
db.refresh(user, with_for_update={"key_share": True})
merged = dict(user.preferences or {})
merged.update(preferences.root)
user.preferences = merged
+56 -4
View File
@@ -7,6 +7,7 @@ the refused sign-in that doubles as an access request, and the link flow's confl
"""
import pytest
from authlib.integrations.starlette_client import OAuthError
from sqlalchemy import select
from starlette.responses import RedirectResponse
@@ -19,7 +20,8 @@ _PW = "correct horse battery"
class _FakeGoogle:
"""authlib's client, reduced to the two calls the routes make: the redirect out to Google and
the token exchange when Google redirects back."""
the token exchange when Google redirects back. Like authlib, it honours only the newest flow's
state: starting a flow retires every earlier one."""
def __init__(self, userinfo: dict):
self.userinfo = userinfo
@@ -29,7 +31,9 @@ class _FakeGoogle:
self.asked = kw
return RedirectResponse(url="/fake-google")
async def authorize_access_token(self, _request):
async def authorize_access_token(self, request):
if request.query_params.get("state") != self.asked.get("state"):
raise OAuthError(error="mismatching_state")
return {"userinfo": self.userinfo, "access_token": "at", "scope": "openid email profile"}
@@ -46,9 +50,13 @@ def google(monkeypatch):
return fake
def _sign_in(client, google, **userinfo):
def _sign_in(client, google, state: str | None = None, **userinfo):
"""Google redirecting back: by default with the state of the flow started last."""
google.userinfo = {"email_verified": True, **userinfo}
return client.get("/auth/callback", follow_redirects=False)
state = state or google.asked.get("state")
return client.get(
"/auth/callback", params={"state": state} if state else {}, follow_redirects=False
)
def _rows(db, model, column, value):
@@ -305,6 +313,50 @@ def test_a_link_whose_session_was_revoked_meanwhile_does_not_finish(client, db,
assert me.google_sub is None
def test_an_older_flow_coming_back_leaves_the_connect_in_flight_a_link(
client, db, google, make_user
):
# Two round-trips from one browser: an older one (another tab's, or Back to Google's screen)
# returns after a connect started. Its state is refused, and it must not spend the connect's
# marker: the connect itself, coming back, would then land as a plain sign-in.
me = make_user(email="me6@example.com")
_start_link(client, "me6@example.com")
older = _sign_in(client, google, state="an-older-flow", sub="sub-6", email="g6@example.com")
assert older.headers["location"] == "/?oauth=cancelled"
r = _sign_in(client, google, sub="sub-6", email="g6@example.com")
assert r.headers["location"] == "/?link=ok"
db.refresh(me)
assert me.google_sub == "sub-6"
def test_a_connect_from_a_tab_showing_another_account_starts_nothing(client, google, make_user):
# A navigation carries no headers, so the tab names the account it was built for in the query.
# One still showing the account signed in before must not start a link onto the one signed in
# now: it lands on the app, which boots as that one and says why.
me = make_user(email="me7@example.com")
_password_login(client, "me7@example.com")
r = client.get(
f"/auth/google/connect?access=identity&expect={me.id + 1}", follow_redirects=False
)
assert r.headers["location"] == auth_routes.ACCOUNT_CHANGED_LANDING == "/?account=changed"
assert google.asked == {}
assert client.get("/api/me").json()["email"] == "me7@example.com"
def test_a_connect_naming_the_signed_in_account_goes_to_google(client, google, make_user):
me = make_user(email="me8@example.com")
_password_login(client, "me8@example.com")
r = client.get(f"/auth/google/connect?access=identity&expect={me.id}", follow_redirects=False)
assert r.headers["location"] == "/fake-google"
assert google.asked["state"]
@pytest.mark.parametrize(
("access", "scope", "prompt"),
[
+31 -1
View File
@@ -6,7 +6,8 @@ the request first read it — two merges over the same earlier read keep only th
"""
from fastapi import Depends, Request
from sqlalchemy import update
from sqlalchemy import event, update
from sqlalchemy.engine import Engine
from sqlalchemy.orm import Session
from app.auth import current_user
@@ -42,3 +43,32 @@ def test_a_save_keeps_a_key_another_save_stored_after_this_one_read_the_account(
assert r.status_code == 200
db.expire_all()
assert db.get(User, user.id).preferences == {"seenVersion": "1.2.3", "startOnHome": False}
def test_the_merge_reads_the_account_under_a_lock_that_orders_merges_and_nothing_else(
client, make_user
):
# The re-read above is half of it: two merges that both read before either commits would still
# keep only the last, unless the read takes a lock the other merge waits for. FOR NO KEY UPDATE
# is that lock, and the weakest one: a plain FOR UPDATE would also wait behind any transaction
# that only added a row referring to the account (a playlist pull, then minutes of YouTube).
make_user(email="prefs-lock@example.com", password=_PW)
assert (
client.post(
"/auth/password-login", json={"email": "prefs-lock@example.com", "password": _PW}
)
).status_code == 200
sent: list[str] = []
def record(_conn, _cursor, statement, *_rest):
sent.append(statement)
event.listen(Engine, "before_cursor_execute", record)
try:
assert client.put("/api/me/preferences", json={"startOnHome": False}).status_code == 200
finally:
event.remove(Engine, "before_cursor_execute", record)
locks = [s for s in sent if "FROM users" in s and " FOR " in s]
assert locks, sent
assert all(s.rstrip().endswith("FOR NO KEY UPDATE") for s in locks), locks