- /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
427 lines
17 KiB
Python
427 lines
17 KiB
Python
"""The Google callback's account-matching branches, through the real route, DB-lane.
|
|
|
|
Each `select()` here was rewritten from a `.query()` without a test that drove it, and one branch —
|
|
adopting an existing row — is where a no-SMTP squat of an env-allowed address turned into an admin
|
|
account with the squatter's password. These pin the adoption (and what it revokes), its two refusals,
|
|
the refused sign-in that doubles as an access request, and the link flow's conflict answers.
|
|
"""
|
|
|
|
import pytest
|
|
from authlib.integrations.starlette_client import OAuthError
|
|
from sqlalchemy import select
|
|
from starlette.responses import RedirectResponse
|
|
|
|
from app.config import settings
|
|
from app.models import Invite, User
|
|
from app.routes import auth as auth_routes
|
|
|
|
_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. 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
|
|
self.asked: dict = {}
|
|
|
|
async def authorize_redirect(self, _request, _redirect_uri, **kw):
|
|
self.asked = kw
|
|
return RedirectResponse(url="/fake-google")
|
|
|
|
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"}
|
|
|
|
|
|
@pytest.fixture
|
|
def google(monkeypatch):
|
|
from cryptography.fernet import Fernet
|
|
|
|
from app import security
|
|
|
|
# The callback stores the OAuth grant encrypted; a throwaway key stands in for TOKEN_ENCRYPTION_KEY.
|
|
monkeypatch.setattr(security, "_fernet", Fernet(Fernet.generate_key()))
|
|
fake = _FakeGoogle({})
|
|
monkeypatch.setattr(auth_routes, "google_oauth", lambda _db: fake)
|
|
return fake
|
|
|
|
|
|
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}
|
|
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):
|
|
db.expire_all()
|
|
return db.execute(select(model).where(column == value)).scalars().all()
|
|
|
|
|
|
def test_a_no_smtp_squat_of_an_admin_address_loses_its_password(client, db, google, monkeypatch):
|
|
# A stranger registers the ADMIN_EMAILS address (a sibling spelling will do) before its owner
|
|
# does. Without SMTP the row is marked verified, and an env-allowed address gets no invite row
|
|
# to gate it — so the owner's first Google sign-in activates THIS row as admin. The squatter's
|
|
# password must not survive that.
|
|
monkeypatch.setattr(auth_routes.email_mod, "email_enabled", lambda db=None: False)
|
|
monkeypatch.setattr(auth_routes, "notify_admins_of_request", lambda *_a, **_k: None)
|
|
monkeypatch.setattr(settings, "admin_emails", "bob@gmail.com")
|
|
auth_routes._register_account("b.ob@gmail.com", "the squatter's own")
|
|
[squat] = _rows(db, User, User.email, "b.ob@gmail.com")
|
|
assert squat.email_verified is True and squat.is_active is False
|
|
|
|
r = _sign_in(client, google, sub="sub-bob", email="bob@gmail.com")
|
|
|
|
assert r.headers["location"] == "/"
|
|
[owner] = _rows(db, User, User.email_canonical, squat.email_canonical)
|
|
assert (owner.id, owner.role, owner.is_active) == (squat.id, "admin", True)
|
|
assert owner.password_hash is None and owner.password_revoked_at is not None
|
|
assert (
|
|
client.post(
|
|
"/auth/password-login",
|
|
json={"email": "b.ob@gmail.com", "password": "the squatter's own"},
|
|
).status_code
|
|
== 401
|
|
)
|
|
|
|
|
|
def test_an_activated_squat_loses_its_password_when_the_sign_in_makes_it_admin(
|
|
client, db, google, make_user, monkeypatch
|
|
):
|
|
# The same squat after an admin clicked Activate on it: activation vouches for access, not for
|
|
# who chose the password, and without SMTP nothing else ever proved the mailbox. ADMIN_EMAILS
|
|
# must not raise the squatter's live session to admin.
|
|
monkeypatch.setattr(auth_routes.email_mod, "email_enabled", lambda db=None: False)
|
|
monkeypatch.setattr(settings, "admin_emails", "bob@gmail.com")
|
|
squat = make_user(email="b.ob@gmail.com", password="the squatter's own", is_active=True)
|
|
squatter = client.__class__(client.app)
|
|
assert (
|
|
squatter.post(
|
|
"/auth/password-login",
|
|
json={"email": "b.ob@gmail.com", "password": "the squatter's own"},
|
|
).status_code
|
|
== 200
|
|
)
|
|
|
|
_sign_in(client, google, sub="sub-bob", email="bob@gmail.com")
|
|
|
|
db.refresh(squat)
|
|
assert squat.role == "admin"
|
|
assert squat.password_hash is None and squat.password_revoked_at is not None
|
|
assert squatter.get("/api/me").json() == {"authenticated": False}
|
|
|
|
|
|
def test_with_smtp_too_an_account_made_admin_loses_its_password(
|
|
client, db, google, make_user, monkeypatch
|
|
):
|
|
# SMTP configured now says nothing about a row verified before it was: the rule does not ask.
|
|
monkeypatch.setattr(auth_routes.email_mod, "email_enabled", lambda db=None: True)
|
|
monkeypatch.setattr(settings, "admin_emails", "own@example.com")
|
|
user = make_user(email="own@example.com", password=_PW, is_active=True)
|
|
|
|
_sign_in(client, google, sub="sub-own", email="own@example.com")
|
|
|
|
db.refresh(user)
|
|
assert user.role == "admin" and user.password_hash is None
|
|
# …and the owner, signed in through Google, is not thrown out by the epoch bump.
|
|
assert client.get("/api/me").json()["id"] == user.id
|
|
|
|
|
|
def test_a_later_sign_in_that_makes_the_account_admin_drops_its_password(
|
|
client, db, google, make_user, monkeypatch
|
|
):
|
|
# Adopted while the address was only allowed (the password kept: nothing promoted it), then
|
|
# added to ADMIN_EMAILS: the promotion happens on a sign-in matched by Google identity.
|
|
user = make_user(email="later@example.com", password=_PW, is_active=True, google_sub="sub-l")
|
|
monkeypatch.setattr(settings, "admin_emails", "later@example.com")
|
|
|
|
_sign_in(client, google, sub="sub-l", email="later@example.com")
|
|
|
|
db.refresh(user)
|
|
assert user.role == "admin"
|
|
assert user.password_hash is None and user.password_revoked_at is not None
|
|
|
|
|
|
def test_an_admin_sign_in_keeps_an_admin_password(client, db, google, make_user, monkeypatch):
|
|
# Only the promotion drops it: an account that is already admin signs in as before.
|
|
user = make_user(email="boss@example.com", password=_PW, role="admin", google_sub="sub-b")
|
|
monkeypatch.setattr(settings, "admin_emails", "boss@example.com")
|
|
|
|
_sign_in(client, google, sub="sub-b", email="boss@example.com")
|
|
|
|
db.refresh(user)
|
|
assert user.password_hash is not None
|
|
|
|
|
|
def test_adopting_an_account_already_let_in_keeps_its_password(
|
|
client, db, google, make_user, monkeypatch
|
|
):
|
|
# The revoke is for passwords nobody vouched for; an active, verified account that this sign-in
|
|
# does not make admin keeps its own.
|
|
monkeypatch.setattr(auth_routes.email_mod, "email_enabled", lambda db=None: False)
|
|
user = make_user(email="kept@example.com", password=_PW, is_active=True)
|
|
monkeypatch.setattr(settings, "allowed_emails", "kept@example.com")
|
|
|
|
_sign_in(client, google, sub="sub-kept", email="kept@example.com")
|
|
|
|
db.refresh(user)
|
|
assert user.google_sub == "sub-kept"
|
|
assert user.password_hash is not None and user.password_revoked_at is None
|
|
|
|
|
|
def test_adoption_matches_the_mailbox_not_the_spelling(client, db, google, make_user, monkeypatch):
|
|
user = make_user(email="pair.one@gmail.com", is_active=True)
|
|
monkeypatch.setattr(settings, "admin_emails", "pairone@gmail.com")
|
|
|
|
_sign_in(client, google, sub="sub-pair", email="pairone@gmail.com")
|
|
|
|
assert [u.id for u in _rows(db, User, User.email_canonical, user.email_canonical)] == [user.id]
|
|
db.refresh(user)
|
|
assert user.google_sub == "sub-pair"
|
|
|
|
|
|
def test_a_different_google_identity_on_the_address_is_refused(
|
|
client, db, google, make_user, monkeypatch
|
|
):
|
|
user = make_user(email="held@example.com", google_sub="sub-first", is_active=True)
|
|
monkeypatch.setattr(settings, "admin_emails", "held@example.com")
|
|
|
|
r = _sign_in(client, google, sub="sub-second", email="held@example.com")
|
|
|
|
assert "access=denied" in r.headers["location"]
|
|
db.refresh(user)
|
|
assert user.google_sub == "sub-first"
|
|
|
|
|
|
def test_an_unverified_google_address_does_not_adopt(client, db, google, make_user, monkeypatch):
|
|
user = make_user(email="pending@example.com", is_active=False, email_verified=False)
|
|
monkeypatch.setattr(settings, "admin_emails", "pending@example.com")
|
|
|
|
google.userinfo = {"sub": "sub-unv", "email": "pending@example.com", "email_verified": False}
|
|
r = client.get("/auth/callback", follow_redirects=False)
|
|
|
|
assert "access=denied" in r.headers["location"]
|
|
db.refresh(user)
|
|
assert (user.google_sub, user.is_active) == (None, False)
|
|
|
|
|
|
def test_a_refused_sign_in_records_a_request_in_the_reported_language(
|
|
client, db, google, monkeypatch
|
|
):
|
|
notified: list[str] = []
|
|
monkeypatch.setattr(
|
|
auth_routes, "notify_admins_of_request", lambda _db, _bg, email: notified.append(email)
|
|
)
|
|
|
|
r = _sign_in(client, google, sub="sub-new", email="new@example.com", locale="hu-HU")
|
|
|
|
assert "access=requested" in r.headers["location"]
|
|
[invite] = _rows(db, Invite, Invite.email, "new@example.com")
|
|
assert (invite.status, invite.lang) == ("pending", "hu")
|
|
assert notified == ["new@example.com"]
|
|
assert _rows(db, User, User.email, "new@example.com") == []
|
|
|
|
|
|
def _password_login(client, email: str):
|
|
assert (
|
|
client.post("/auth/password-login", json={"email": email, "password": _PW}).status_code
|
|
== 200
|
|
)
|
|
|
|
|
|
def _connect(client, access: str):
|
|
return client.get(f"/auth/google/connect?access={access}", follow_redirects=False)
|
|
|
|
|
|
def _start_link(client, email: str):
|
|
_password_login(client, email)
|
|
assert _connect(client, "identity").headers["location"] == "/fake-google"
|
|
|
|
|
|
def test_linking_an_identity_another_account_holds_is_a_conflict(client, db, google, make_user):
|
|
me = make_user(email="me@example.com")
|
|
make_user(email="them@example.com", google_sub="sub-theirs")
|
|
_start_link(client, "me@example.com")
|
|
|
|
r = _sign_in(client, google, sub="sub-theirs", email="them@example.com")
|
|
|
|
assert r.headers["location"] == "/?link=conflict"
|
|
db.refresh(me)
|
|
assert me.google_sub is None
|
|
|
|
|
|
def test_linking_a_second_identity_is_a_mismatch(client, db, google, make_user):
|
|
me = make_user(email="me2@example.com", google_sub="sub-mine")
|
|
_start_link(client, "me2@example.com")
|
|
|
|
r = _sign_in(client, google, sub="sub-other", email="other@example.com")
|
|
|
|
assert r.headers["location"] == "/?link=mismatch"
|
|
db.refresh(me)
|
|
assert me.google_sub == "sub-mine"
|
|
|
|
|
|
def test_linking_a_free_identity_attaches_it(client, db, google, make_user):
|
|
me = make_user(email="me3@example.com")
|
|
_start_link(client, "me3@example.com")
|
|
|
|
r = _sign_in(client, google, sub="sub-free", email="me3.google@example.com")
|
|
|
|
assert r.headers["location"] == "/?link=ok"
|
|
db.refresh(me)
|
|
assert (me.google_sub, me.google_email) == ("sub-free", "me3.google@example.com")
|
|
|
|
|
|
def test_a_connect_abandoned_at_google_leaves_the_next_sign_in_a_sign_in(
|
|
client, db, google, make_user, monkeypatch
|
|
):
|
|
# A connect left at Google's screen (Back, a closed tab) never reaches the callback that pops
|
|
# its marker. "Sign in as someone else" afterwards must sign that person in, not link their
|
|
# Google identity onto the account still signed in here.
|
|
me = make_user(email="me4@example.com")
|
|
make_user(email="them4@example.com", google_sub="sub-them4", password=None)
|
|
monkeypatch.setattr(settings, "allowed_emails", "them4@example.com")
|
|
_start_link(client, "me4@example.com")
|
|
assert client.get("/auth/login", follow_redirects=False).headers["location"] == "/fake-google"
|
|
|
|
r = _sign_in(client, google, sub="sub-them4", email="them4@example.com")
|
|
|
|
assert r.headers["location"] == "/"
|
|
assert client.get("/api/me").json()["email"] == "them4@example.com"
|
|
db.refresh(me)
|
|
assert me.google_sub is None
|
|
|
|
|
|
def test_a_link_whose_session_was_revoked_meanwhile_does_not_finish(client, db, google, make_user):
|
|
# "Log out everywhere" (or a password change) between the start and the callback: the cookie
|
|
# still names the account, but its session no longer holds, so it may not attach an identity.
|
|
me = make_user(email="me5@example.com")
|
|
_start_link(client, "me5@example.com")
|
|
me.session_epoch = (me.session_epoch or 0) + 1
|
|
db.commit()
|
|
|
|
r = _sign_in(client, google, sub="sub-late", email="late@example.com")
|
|
|
|
assert r.headers["location"] == "/?link=error"
|
|
db.refresh(me)
|
|
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"),
|
|
[
|
|
("identity", "openid email profile", "select_account"),
|
|
("read", auth_routes.READ_SCOPES, "consent"),
|
|
("write", auth_routes.WRITE_SCOPES, "consent"),
|
|
],
|
|
)
|
|
def test_one_connect_route_asks_google_for_what_the_access_needs(
|
|
client, google, make_user, access, scope, prompt
|
|
):
|
|
make_user(email=f"ask-{access}@example.com")
|
|
_password_login(client, f"ask-{access}@example.com")
|
|
|
|
assert _connect(client, access).headers["location"] == "/fake-google"
|
|
|
|
assert (google.asked["scope"], google.asked["prompt"]) == (scope, prompt)
|
|
assert google.asked["access_type"] == "offline"
|
|
|
|
|
|
@pytest.mark.parametrize("access", ["read", "write"])
|
|
def test_a_youtube_grant_attaches_to_the_password_account_and_returns_to_the_app(
|
|
client, db, google, make_user, access
|
|
):
|
|
me = make_user(email=f"grant-{access}@example.com")
|
|
_password_login(client, f"grant-{access}@example.com")
|
|
_connect(client, access)
|
|
|
|
r = _sign_in(client, google, sub=f"sub-grant-{access}", email=f"g-{access}@example.com")
|
|
|
|
# Back to the app, not the "linked" notice: the onboarding wizard picks up where it was.
|
|
assert r.headers["location"] == "/"
|
|
db.refresh(me)
|
|
assert me.google_sub == f"sub-grant-{access}"
|
|
assert _rows(db, User, User.google_sub, f"sub-grant-{access}") == [me]
|
|
|
|
|
|
@pytest.mark.parametrize("access", ["", "admin", "READ"])
|
|
def test_an_unknown_access_starts_no_connect(client, google, make_user, access):
|
|
make_user(email="odd@example.com")
|
|
_password_login(client, "odd@example.com")
|
|
|
|
assert _connect(client, access).status_code == 422
|
|
assert google.asked == {}
|
|
|
|
|
|
def test_a_connect_needs_a_signed_in_account(client, google):
|
|
assert _connect(client, "identity").status_code == 401
|
|
assert google.asked == {}
|
|
|
|
|
|
@pytest.mark.parametrize("access", ["identity", "read", "write"])
|
|
def test_without_google_configured_a_connect_says_it_failed(client, make_user, monkeypatch, access):
|
|
monkeypatch.setattr(auth_routes, "google_oauth", lambda _db: None)
|
|
make_user(email=f"nog-{access}@example.com")
|
|
_password_login(client, f"nog-{access}@example.com")
|
|
|
|
assert _connect(client, access).headers["location"] == "/?link=error"
|
|
|
|
|
|
@pytest.mark.parametrize("path", ["/auth/link", "/auth/upgrade", "/auth/upgrade?access=write"])
|
|
def test_the_old_connect_routes_are_gone(client, google, make_user, path):
|
|
make_user(email="old@example.com")
|
|
_password_login(client, "old@example.com")
|
|
|
|
assert client.get(path, follow_redirects=False).status_code == 404
|
|
assert google.asked == {}
|