"""Auth HTTP endpoints (OAuth login/callback, register/verify, password, session mgmt). Split out of app/auth.py; the core auth surface (current_user, scopes, token helpers) stays in app/auth.py, imported below.""" import secrets from datetime import datetime, timedelta, timezone from typing import NamedTuple from urllib.parse import urlencode from authlib.integrations.starlette_client import OAuth, OAuthError from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request from fastapi.responses import JSONResponse, RedirectResponse from pydantic import BaseModel from sqlalchemy import select, update from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from app.userscope import owned_by from app import apitokens from app import email as email_mod from app import logs from app import mailtext from app import sysconfig from app.auth import ( _YOUTUBE_SCOPES, BASE_SCOPES, MAX_SESSION_ACCOUNTS, PASSWORD_MIN_LEN, READ_SCOPES, WRITE_SCOPES, current_user, env_admin_canonicals, env_allowed_canonicals, find_user_by_email, google_enabled, is_allowed, log, operator_contact, resolved_user_id, ) from app.config import settings from app.db import SessionLocal, get_db from app.models import AuthToken, Invite, OAuthToken, User, display_text from app.ratelimit import RateLimiter, client_key from app.security import encrypt, hash_password, hash_token, verify_password from app.utils import canonical_email, valid_email from app.refusal import refused VERIFY_TTL = timedelta(hours=24) # A confirmation link that rides an APPROVAL or an invitation lives longer than one the person is # waiting for. Nobody sits refreshing their inbox for an approval — it can easily be read on Monday # after a weekend, and if it has expired by then the one-mail-one-click promise is spent and they are # back to two messages. The risk is small: this token only ever confirms an address. It signs nobody # in and sets no password. APPROVAL_VERIFY_TTL = timedelta(days=7) RESET_TTL = timedelta(hours=1) # An admin-issued set-password link travels out of band — the admin has to actually reach the # person and hand it over — so the one-hour reset window is far too short. A day matches VERIFY_TTL and # the affordance the admin was shown. ADMIN_SETPW_TTL = timedelta(hours=24) # Both kinds set a password from a link, and the SIGNED-OUT card consumes them through the same # `#reset=` entry, so the user-facing flow is one flow. They stay separate kinds for one reason: a # `reset` token proves control of the MAILBOX (it was mailed there) and therefore confirms the address; # an admin-issued one proves nothing about the mailbox — the admin could hand it to anyone — so it must # not silently satisfy the email gate. See password_reset_confirm. _PASSWORD_TOKEN_KINDS = ("reset", "setpw") _DECOY_PASSWORD_HASH = hash_password(secrets.token_urlsafe(16)) _register_limiter = RateLimiter(max_events=5, window_seconds=300) _login_limiter = RateLimiter(max_events=10, window_seconds=300) _reset_limiter = RateLimiter(max_events=5, window_seconds=300) # Separate from _reset_limiter on purpose: opening a link must not eat the budget for REQUESTING one. _reset_check_limiter = RateLimiter(max_events=10, window_seconds=300) _verify_resend_limiter = RateLimiter(max_events=5, window_seconds=300) _suspend_email_limiter = RateLimiter(max_events=1, window_seconds=3600) def _notify_suspended(background: BackgroundTasks, user: User) -> None: """Schedule the one-per-hour 'account suspended' email to a user whose (otherwise valid) sign-in was just blocked. Gated by verified credentials at the call site, so this can't be triggered by someone who doesn't control the account. Takes the user rather than the address because the mail needs their language, and the send runs as a background task — after this request's session, and the row, are out of reach.""" if _suspend_email_limiter.allow(user.email): background.add_task( email_mod.send_account_suspended, user.email, mailtext.lang_for_user(user), operator_contact(), ) router = APIRouter(prefix="/auth", tags=["auth"]) _oauth = OAuth() _oauth_creds: tuple[str, str] | None = None def google_oauth(db: Session): """The configured authlib Google client, or None when no credentials are set (Google sign-in is then disabled — email+password still works).""" global _oauth, _oauth_creds cid = sysconfig.get_str(db, "google_client_id") csec = sysconfig.get_str(db, "google_client_secret") if not cid or not csec: return None if _oauth_creds != (cid, csec): _oauth = OAuth() _oauth.register( name="google", client_id=cid, client_secret=csec, server_metadata_url="https://accounts.google.com/.well-known/openid-configuration", client_kwargs={"scope": BASE_SCOPES}, ) _oauth_creds = (cid, csec) return _oauth.google def admin_notify_groups(db: Session) -> dict[str, list[str]]: """Who to email about admin events (e.g. a new access request), grouped by the language each of them reads. The ACTUAL admins — every active, non-suspended `role=admin` user — UNIONED with the env ADMIN_EMAILS bootstrap list. Using the DB roles means an admin promoted through the UI is notified too (env alone would miss them, yet they CAN approve requests — the approve UI is role-gated, so the notify set must match); the env union keeps a freshly-seeded instance working before any DB admin exists. Grouped rather than one blast to everyone, because these recipients are people with their own UI language: a single message would have to pick one of them and be wrong for the rest. An env address with no admin account still gets looked up, so an operator listed in ADMIN_EMAILS whose account is (say) suspended is still written to in the language they chose.""" rows = db.execute( select(User.email, User.preferences).where( User.role == "admin", User.is_active.is_(True), User.is_suspended.is_(False) ) ).all() by_email = {e.lower(): mailtext.normalize((p or {}).get("language")) for e, p in rows if e} for email in settings.admin_email_set: if email not in by_email: by_email[email] = mailtext.lang_for_email(db, email) groups: dict[str, list[str]] = {} for email in sorted(by_email): groups.setdefault(by_email[email], []).append(email) return groups def notify_admins_of_request( db: Session, background: BackgroundTasks | None, requester: str ) -> None: """Tell every admin that someone is waiting — one mail per language group. Sends inline when there is no BackgroundTasks to hand (the registration path already runs outside a response).""" for lang, recipients in admin_notify_groups(db).items(): if background is None: email_mod.send_admin_new_request(recipients, lang, requester) else: background.add_task(email_mod.send_admin_new_request, recipients, lang, requester) class InviteRequest(NamedTuple): """What an access request settled on. `invite` is the row the request now lives under — which is NOT always the address that was just typed: one Gmail mailbox spelled two ways settles on whichever row already exists, and that row's address is where mail will go. Callers that tell the user what happened need it. `is_new` is the old boolean return in a name: true only when a row was created or a denied one reopened, i.e. exactly when admins should hear about it. A repeat request must not re-spam them. """ invite: Invite | None is_new: bool def upsert_pending_invite(db: Session, email: str, lang: str | None = None) -> InviteRequest: """Idempotently record an access request, and the language it was made in. "Already known" is decided on the CANONICAL form: `npeter83.temu@` and `npeter83temu@` are one Gmail mailbox, and treating them as two is what put a second, silent access request in the admin queue on 2026-09-03 for an already-approved user. An approved sibling wins — re-requesting must not reopen a decision that has already been made for that mailbox. The row is still STORED under the address as typed, because that is where mail has to go. `lang` is the language the request came in — the locale Google reported for a refused sign-in. The latest one wins: someone who asks again under another locale is telling us something newer than last time. Anything unsupported is ignored rather than stored, so a forged body cannot put junk in the column. The commit contract is unchanged: this still commits ONLY when it writes something. Callers rely on that — `_register_account` persists its own flushed user precisely because a no-op here commits nothing. """ email = (email or "").lower() if not valid_email(email): return InviteRequest(None, False) lang = lang if lang in mailtext.SUPPORTED else None canon = canonical_email(email) rows = ( db.execute(select(Invite).where(Invite.email_canonical == canon).order_by(Invite.id)) .scalars() .all() ) # An address the env already lets in has nothing to ask for, and a pending row for it would be # worse than nothing: `is_allowed` answers from the invite rows once any exist, so a stranger # registering a sibling spelling (`b.ob@` beside an env-granted `bob@`) would lock the real # owner out of a grant no admin could see. if not rows and canon in env_allowed_canonicals(): return InviteRequest(None, False) # An approved row wins, so a denied duplicate can't mask an approval. Then a row that is not # DENIED, preferring the exact spelling — a denied sibling must never be picked while a pending # row exists, or the reopen branch below would fire and put one person in the admin's queue # twice. Only when every row is denied does the spelling decide which one reopens. undecided = [r for r in rows if r.status != "denied"] inv = ( next((r for r in rows if r.status == "approved"), None) or next((r for r in undecided if r.email == email), None) or next(iter(undecided), None) or next((r for r in rows if r.email == email), None) or next(iter(rows), None) ) if inv is not None: relearned = lang is not None and inv.lang != lang if relearned: inv.lang = lang # Let a previously-denied person ask again; don't disturb approved/pending rows. if inv.status == "denied": inv.status = "pending" inv.requested_at = datetime.now(timezone.utc) inv.decided_at = None inv.decided_by = None db.commit() return InviteRequest(inv, True) if relearned: db.commit() return InviteRequest(inv, False) inv = Invite(email=email, status="pending", lang=lang) db.add(inv) db.commit() return InviteRequest(inv, True) def _access_notice_url(kind: str, email: str = "", inv: Invite | None = None) -> str: """The landing URL for a Google sign-in that did not reach an account: `requested` (we recorded the request) or `denied` (nothing to record). It rides the FRAGMENT, not the query string, for the same reason the verify/reset tokens do (SB3): a fragment never reaches the server, a proxy access log or a Referer header — and this notice now carries the user's address. Naming it is the point: on 2026-09-03 a registration as `npeter83.temu@` met an OAuth claim of `npeter83temu@`, and a screen that said "request recorded" without saying FOR WHAT hid the mismatch for a day. `linked` names the address the request actually lives under, and ONLY when that differs from the one Google just returned — the honest version of "we didn't open a second request". Same spelling twice is just a repeat, and saying anything there would be noise. urlencode (not an f-string): a `+tag` address must arrive as %2B, or the SPA's URLSearchParams would read it back as a space and mangle exactly the address this epic exists to protect. """ params = {"access": kind} if email: params["email"] = email if inv is not None and inv.email != email: params["linked"] = inv.email return "/#" + urlencode(params) _DEV_OAUTH_HOSTS = frozenset( {"localhost:5173", "localhost:8080", "127.0.0.1:5173", "127.0.0.1:8080"} ) def _oauth_redirect_uri(request: Request) -> str: """The redirect_uri handed to Google. Production always uses the fixed configured URL and never trusts the request Host (a forged Host header must not be able to redirect the OAuth code). In local dev we honor the request's own origin when it is one of the known dev hosts, so a login started on the Vite server (:5173) returns to :5173 and one on :8080 returns to :8080.""" if settings.session_https_only: # https redirect URL == real deployment return settings.oauth_redirect_url host = (request.headers.get("host") or "").lower() if host in _DEV_OAUTH_HOSTS: return f"http://{host}/auth/callback" return settings.oauth_redirect_url # --- Request bodies (typed contracts; B-core-6) --------------------------------------------- # Fields are `str | None` (not required) so the handlers keep their exact prior behaviour — a # missing/null field reads as None and the existing `(... or "")` coercions still apply — while # FastAPI now validates the body is a JSON object and publishes the schema. class EmailIn(BaseModel): email: str | None = None class EmailPasswordIn(BaseModel): email: str | None = None password: str | None = None # The language the sign-up page was in. Captured at registration because it is the ONLY moment # the choice exists anywhere the server can see: it lives in the browser until an account does, # and a preference nobody recorded cannot be backfilled later (as `google_email` showed: those # rows still read NULL until each user signs in again). It is stored as the account's # `preferences["language"]`, which the UI starts in and every mail to the account is written in # (`mailtext.lang_for_user` / `lang_for_email`). lang: str | None = None class TokenIn(BaseModel): token: str | None = None class TokenPasswordIn(BaseModel): token: str | None = None password: str | None = None class SetPasswordIn(BaseModel): password: str | None = None current_password: str | None = None @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 # prompt=consent so returning users get a quick sign-in; the stored refresh token # is kept when Google doesn't re-issue one (see the callback). client = google_oauth(db) if client is None: # 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="/") return await client.authorize_redirect( request, _oauth_redirect_uri(request), access_type="offline", prompt="select_account", include_granted_scopes="true", scope=BASE_SCOPES, ) @router.get("/callback") async def callback( request: Request, background: BackgroundTasks, db: Session = Depends(get_db), ): client = google_oauth(db) 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/link and # /auth/upgrade; 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. Popping up front means an aborted link flow leaves no residue in the session. link_uid = request.session.pop("oauth_link_uid", None) link_explicit = request.session.pop("oauth_link_explicit", False) try: token = await client.authorize_access_token(request) except OAuthError as exc: # The user cancelled or denied consent at Google — land back with a friendly notice instead # of a raw 400 JSON error page. An already-signed-in user (adding another account via # /auth/login, or linking/upgrading via /auth/link|upgrade) never sees the pre-auth login # banner, so surface a neutral toast via ?oauth=cancelled; a fresh sign-in cancel lands on # the login page with the oauth_cancelled banner instead. Keyed on the live session, not on # link_uid, so the add-account path (no marker) is covered too. log.info("Google OAuth cancelled/failed: %s", exc.error) if request.session.get("user_id") is not None: return RedirectResponse(url="/?oauth=cancelled") return RedirectResponse(url="/?login=oauth_cancelled") userinfo = token.get("userinfo") if not userinfo or not userinfo.get("sub"): raise refused(400, "google_no_userinfo") 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 not is_allowed(db, email): # No uid to name — having no account here is the branch. And the address is left out on # purpose: `redact_text` turns it into `[email]` before either sink sees it, so a line # whose only variable is always `[email]` says nothing. WHO asked is in the pending # invite recorded just below, which is where an admin looks anyway. log.warning( "Google sign-in denied: address not approved", # log-pii-ok: whether an address came back, not what it was extra=logs.event("auth.login.denied", reason="not_approved", has_email=bool(email)), ) # A denied Google login doubles as an access request: record it for the admin and # land the user on a friendly "request received" screen instead of a raw 403. if email: # Google just told us this person's locale — the only language signal that will ever # exist for an address with no account, and the decision mail is written from it. req = upsert_pending_invite(db, email, (userinfo.get("locale") or "").split("-")[0]) if req.is_new: notify_admins_of_request(db, background, email) return RedirectResponse(url=_access_notice_url("requested", email, req.invite)) return RedirectResponse(url=_access_notice_url("denied")) user = db.execute(select(User).where(User.google_sub == sub)).scalar_one_or_none() if user is None: # No account carries this Google identity yet. If one already exists for this MAILBOX — # e.g. an email+password registration — adopt the Google identity onto it instead of # inserting a duplicate. Google has verified the address, so this safely unifies password + # Google sign-in on one account. # # Matched canonically, and that is not cosmetic: on 2026-09-03 a registration as # `npeter83.temu@gmail.com` met an OAuth claim of `npeter83temu@gmail.com`, the literal # lookup found nothing, and prod ended up with TWO active user rows for one Gmail mailbox # (ids 13 and 14). Exact spelling still wins when both exist, so an account that already # matches literally is never passed over for a canonical sibling. canon = canonical_email(email) candidates = ( db.execute(select(User).where(User.email_canonical == canon).order_by(User.id)) .scalars() .all() ) existing = next((u for u in candidates if u.email == email), None) or next( iter(candidates), None ) if existing is not None: if existing.google_sub is not None and existing.google_sub != sub: # A different Google identity is already bound to this email — refuse rather than # hijack it. (Near-impossible with real Google accounts: email↔sub is stable.) log.warning( "Google sign-in refused: a different Google identity holds this address", extra=logs.event("auth.login.denied", reason="sub_collision", uid=existing.id), ) return RedirectResponse(url=_access_notice_url("denied")) if not userinfo.get("email_verified", False): # Adopting (and activating) a pre-existing password account requires Google to # actually attest the address — otherwise an unverified Google email that merely # collides with a pending registration could seize that account. Defense-in-depth. log.warning( "Google sign-in refused: unverified address, not adopting the account", extra=logs.event( "auth.login.denied", reason="email_unverified", uid=existing.id ), ) return RedirectResponse(url=_access_notice_url("denied")) user = existing user.google_sub = sub # SEC-10: drop any password nobody proved the mailbox for, now that Google attests # ownership (bumps the epoch to revoke any session it minted). Runs before the # activation below, which is what it reads. revoke_untrusted_preset_password(db, user) # Google verified the email and is_allowed granted access, so finish activating a # previously pending password registration (otherwise current_user would bounce it). user.email_verified = True user.is_active = True else: user = User(google_sub=sub, email=email) db.add(user) # A suspended account can't sign in by any method. Block before establishing the session and # tell the (verified) owner why — a successful Google auth proves they control this account. if user.is_suspended: log.info( "Google sign-in blocked: the account is suspended", extra=logs.event("auth.login.denied", reason="suspended", uid=user.id), ) _notify_suspended(background, user) return RedirectResponse(url="/?login=suspended") # Record what Google actually answers with, beside the address the user registered with. The two # are the same string for most people; where they differ (a deliberate `+tag`, a different dot # pattern) the UI shows both, so nobody has to guess which one signs them in. if email: user.google_email = email _sync_google_email(db, user, email) user.display_name = display_text(userinfo.get("name")) user.avatar_url = userinfo.get("picture") # ADMIN_EMAILS (env) is the bootstrap admin list and always wins, so the configured admin can't # be locked out. Everyone else's role is managed in the admin UI and stored in the DB, so a # routine login must NOT reset it (that would clobber a UI promotion/demotion). Only seed a # default for a brand-new account (its role attribute is still unset before flush). # Canonical, like every other read of the env lists: a dotted or `+tag`ged ADMIN_EMAILS entry # would otherwise never bootstrap its admin, because Google answers with the plain primary. # # Becoming admin this way drops any password the account holds, on whichever sign-in it # happens. Nothing here can tell who chose that password: without SMTP registration marks the # address verified with no proof (and SMTP configured later never revisits it), and an admin's # Activate vouches for access, not for the password. So a stranger who registered the address # first, and was let in, would otherwise see their own live session become an admin one. The # real owner, signed in through Google right now, sets a new password in Settings. if canonical_email(email) in env_admin_canonicals(): if user.role != "admin" and user.password_hash is not None: _revoke_password(db, user) user.role = "admin" elif not user.role: user.role = "user" # Google attests the email, so a Google sign-in implicitly verifies it. Setting this also lets # a Google user who later adds a password sign in (password-login gates on email_verified). if userinfo.get("email_verified", True): user.email_verified = True # Default UI language from the Google-reported locale on first login (if the user hasn't # picked one yet); unsupported locales fall back to English. prefs = dict(user.preferences or {}) if not prefs.get("language"): prefs["language"] = mailtext.normalize((userinfo.get("locale") or "").split("-")[0].lower()) user.preferences = prefs db.flush() _store_token(db, user, token) _establish_session(request, user) db.commit() log.info( "signed in", extra=logs.event("auth.login.ok", method="google", uid=user.id, role=user.role), ) return RedirectResponse(url="/") def _sync_google_email(db: Session, user: User, email: str) -> None: """Follow a genuine Google-side email change — and ONLY that. A different SPELLING of the same mailbox is the user's own choice and must survive. People register as `me+siftlode@gmail.com` precisely so a later leak from this site is identifiable, and silently rewriting that to `me@gmail.com` destroys the thing it was for. It also locked them out of the spelling they chose, because sign-in matches the stored address first. Google returns the account's PRIMARY address, which need not be what they registered with — and can never contain a `+` at all — so without this guard every plus-tagged registration lost its tag on the first Google sign-in. (Matching adoption across spellings made that reachable; the rewrite itself is older.) Never overwrite with an address another account already owns either: that would hit the unique constraint and 500, wedging this user's sign-in. """ if not email or user.email == email: return if canonical_email(user.email) == canonical_email(email): return # same mailbox, their spelling — leave it alone clash = db.execute( select(User).where(User.email == email, User.id != user.id) ).scalar_one_or_none() if clash is None: user.email = email def _store_token(db: Session, user: User, token: dict) -> None: """Persist the OAuth grant on the user's single token row, creating it if absent. Google only returns a refresh_token on (re)consent, so the previous one is kept otherwise. include_granted_scopes=true means `scope` is the union of everything granted, so this reflects read/write upgrades correctly.""" tok = user.token or OAuthToken(user=user) old = set((tok.scopes or "").split()) new = set((token.get("scope") or "").split()) # A plain re-sign-in requests only BASE_SCOPES, and Google can hand back a FRESH base-only # access+refresh token. Adopting it would DESTROY a previously-granted YouTube read/write grant — # can_read/can_write flip to false and the feed demands a reconnect (the exact bug users hit on a # logout→login). The OLD refresh token stays valid on Google's side, so when this exchange would # NARROW our YouTube scopes, keep the WHOLE existing grant (refresh token, access token, scopes) # untouched. The grant only shrinks on a full disconnect (purge_user deletes the token row); an # externally-revoked scope surfaces later as a YouTube API 403 → reconnect, not through this write. if (old & _YOUTUBE_SCOPES) - new: db.add(tok) return if token.get("refresh_token"): tok.refresh_token_enc = encrypt(token["refresh_token"]) # Encrypt the access token at rest too (it's a live ~1h bearer credential), matching the # refresh token — a DB/backup/log leak otherwise hands out working Google API tokens. tok.access_token = encrypt(token.get("access_token")) expires_at = token.get("expires_at") tok.expiry = datetime.fromtimestamp(expires_at, tz=timezone.utc) if expires_at else None # Union (never narrow): a broader-or-equal exchange may still list only the newly-added scope. tok.scopes = " ".join(sorted(old | new)) or BASE_SCOPES db.add(tok) def _complete_link( request: Request, db: Session, link_uid: int, explicit: bool, sub: str, userinfo: dict, token: dict, ): """Attach the just-authorized Google identity to the already-signed-in account that started the link/upgrade. 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 upgrade — the caller pops it up front so an aborted flow leaves nothing behind.""" # SEC-11: the marker must name an account signed into THIS browser — the session default OR any # account in the wallet — not only the default. A tab acting as a secondary account links Google to # ITSELF (the marker was set from current_user, which resolves the ?account= it navigated with), so # accepting wallet membership stops the identity/token landing on the default account A instead of B. accounts = request.session.get("account_ids") or [] if link_uid != request.session.get("user_id") and link_uid not in accounts: return RedirectResponse(url="/?link=error") user = db.get(User, link_uid) if user is None: return RedirectResponse(url="/?link=error") other = db.execute( select(User).where(User.google_sub == sub, User.id != user.id) ).scalar_one_or_none() if other is not None: return RedirectResponse(url="/?link=conflict") if user.google_sub is not None and user.google_sub != sub: return RedirectResponse(url="/?link=mismatch") user.google_sub = sub linked_email = (userinfo.get("email") or "").strip().lower() if linked_email: user.google_email = linked_email # see the sign-in callback: the other half of the identity if not user.display_name: user.display_name = display_text(userinfo.get("name")) if not user.avatar_url: user.avatar_url = userinfo.get("picture") _store_token(db, user, token) db.commit() log.info("Google linked: uid=%s scopes=%s", user.id, token.get("scope")) # An explicit "Connect Google" lands on Settings with a confirmation; a YouTube upgrade # (wizard / Settings access rows) returns to the app so the onboarding flow resumes as before. return RedirectResponse(url="/?link=ok" if explicit else "/") @router.post("/logout") async def logout(request: Request): """Sign the requesting tab's active account out of this browser (removing it from the wallet). Per-tab aware: the account is taken from the X-Siftlode-Account header when present, so logging out in one tab doesn't disturb another tab running a different account. If the account being removed was the session default, promote the most recent remaining account (or clear the session when none remain).""" # A tab still pinned to an account already signed out in another tab resolves to nobody, so it # signs nobody out — it used to fall back to the default and sign THAT account out in its place. active, is_default = resolved_user_id(request) remaining = [a for a in (request.session.get("account_ids") or []) if a != active] request.session["account_ids"] = remaining if is_default: if remaining: request.session["user_id"] = remaining[-1] return JSONResponse({"ok": True, "switched": True}) request.session.clear() return JSONResponse({"ok": True, "switched": False}) # A non-default (per-tab) account signed out: it's gone from the wallet and the session default # is untouched. The tab drops its own override and falls back to the default on reload. return JSONResponse({"ok": True, "switched": False}) def _establish_session(request: Request, user: User) -> None: """Sign `user` in for this browser session — the ONE sign-in path, shared by the Google callback and password login, so both join the same account switcher and both stamp `last_login_at` (the caller commits). Multi-session: every account that authenticated in this browser is remembered so the user can switch between them; only accounts that actually proved themselves HERE land in that wallet (it is the proof they may be switched to).""" accounts = [a for a in (request.session.get("account_ids") or []) if a != user.id] accounts.append(user.id) request.session["account_ids"] = accounts[-MAX_SESSION_ACCOUNTS:] request.session["user_id"] = user.id _remember_epoch(request, user) user.last_login_at = datetime.now(timezone.utc) def _app_base() -> str: """Public origin of the deployed app (OAUTH_REDIRECT_URL is .../auth/callback).""" return settings.app_base def _issue_token(db: Session, user: User, kind: str, ttl: timedelta) -> str: """Create a single-use token of `kind`; only its hash is stored. Returns the raw token (goes only into the emailed link).""" raw = secrets.token_urlsafe(32) db.add( AuthToken( user_id=user.id, kind=kind, token_hash=hash_token(raw), expires_at=datetime.now(timezone.utc) + ttl, ) ) db.commit() return raw def _peek_token(db: Session, raw: str | None, kind: str) -> User | None: """Is this token still usable — WITHOUT spending it. Same four conditions as _consume_token, and the same "does its user still exist" answer, so the two can never disagree about a given token. Deliberately NOT the read half of a read-check-write: _consume_token stays one atomic UPDATE, and this never feeds it. A peek is only ever an early warning — the confirm endpoint remains the authority, because a token can expire, be spent, or lose its account between the two calls. """ if not raw: return None row = db.execute( select(AuthToken).where( AuthToken.token_hash == hash_token(raw), AuthToken.kind == kind, AuthToken.used_at.is_(None), AuthToken.expires_at >= datetime.now(timezone.utc), ) ).scalar_one_or_none() # db.get, exactly as _consume_token does: a deleted account cascades its tokens away, so this is # belt-and-braces — but the two must agree, and agreeing by construction beats agreeing by luck. return db.get(User, row.user_id) if row is not None else None def _consume_token(db: Session, raw: str | None, kind: str) -> User | None: """Validate + burn a token atomically. Returns its user, or None if missing/expired/used/wrong kind. The burn is a single conditional UPDATE (…WHERE used_at IS NULL…RETURNING) rather than a read-check-write, so two concurrent requests can't both consume the same verify/reset token — only the one whose UPDATE flips used_at gets a row back.""" if not raw: return None now = datetime.now(timezone.utc) row = db.execute( update(AuthToken) .where( AuthToken.token_hash == hash_token(raw), AuthToken.kind == kind, AuthToken.used_at.is_(None), AuthToken.expires_at >= now, ) .values(used_at=now) .returning(AuthToken.user_id) ).first() db.commit() if row is None: return None return db.get(User, row.user_id) def _consume_password_token(db: Session, raw: str | None) -> tuple[User, str] | None: """Spend a password-setting token of either kind; returns (user, kind). The kind is returned, not swallowed, because the caller has to treat them differently — only a mailed reset attests the address.""" for kind in _PASSWORD_TOKEN_KINDS: user = _consume_token(db, raw, kind) if user is not None: return user, kind return None def issue_set_password_link(db: Session, user: User) -> tuple[str, datetime]: """An admin-issued, single-use link that lets `user` set a password without any email. One mechanism answers three things: SEC-15 (on a no-SMTP instance every registration is auto-verified, so a pre-empted address cannot be recovered by mail), SEC-16 (a pre-empted address stays takeable until its owner resets, and password-reset needs working mail), and the everyday "no SMTP configured and someone forgot their password". Issuing does NOT revoke the current password: an admin misclick must not lock anyone out, and the account may be perfectly healthy. USING the link replaces the hash and bumps the session epoch, which is what evicts a squatter — see password_reset_confirm.""" expires_at = datetime.now(timezone.utc) + ADMIN_SETPW_TTL raw = _issue_token(db, user, "setpw", ADMIN_SETPW_TTL) # The same `#reset=` fragment the mailed link uses: the signed-out card already knows how to # consume it, and a second entry shape would be a second thing to keep correct. return f"{_app_base()}/#reset={raw}", expires_at @router.post("/register") def register( body: EmailPasswordIn, request: Request, background: BackgroundTasks, db: Session = Depends(get_db), ) -> dict: """Email+password registration. Creates a pending (inactive, unverified) account, sends a verification link, and records an access request for admin approval. Two gates before sign-in: verified email AND admin approval. Responses are uniform once input is valid, so the endpoint can't be used to discover which emails are registered.""" email = (body.email or "").strip().lower() password = body.password or "" # Input validation is safe to reveal (independent of whether the email exists). if not valid_email(email): raise refused(400, "invalid_email") if len(password) < PASSWORD_MIN_LEN: raise refused(400, "weak_password", min=PASSWORD_MIN_LEN) if not sysconfig.get_bool(db, "allow_registration"): raise refused(403, "registration_closed") if not _register_limiter.allow(client_key(request)): return {"ok": True, "status": "ok"} # silently throttle; uniform response # Do the existence check + account creation OFF the response path (a background task with its own # session). The create path hashes the password + writes several rows + schedules emails; doing it # inline would make a NEW email respond measurably slower than an already-registered one (which # skips all that) — a timing oracle for enumeration. Off-path, any valid email responds the same. background.add_task(_register_account, email, password, body.lang) return {"ok": True, "status": "ok"} def _register_account(email: str, password: str, lang: str | None = None) -> None: """Background worker for /register: create the pending account (+ verification email + admin notice) for a genuinely new email; no-op for an already-registered one. Runs after the response with its own DB session, so the request's timing never reveals whether the email already exists.""" with SessionLocal() as db: # Canonical, so a second spelling of one Gmail mailbox cannot open a SECOND account. Prod # already carries such a pair (user ids 13 and 14, both for npeter83temu@gmail.com); this is # the end that stops new ones appearing, while the Google callback copes with the existing. candidates = ( db.execute( select(User).where(User.email_canonical == canonical_email(email)).order_by(User.id) ) .scalars() .all() ) # Exact spelling wins when a mailbox carries two rows, same as every other canonical lookup # in this epic (`find_user_by_email`, `_activate_user_by_email`, the Google callback). Without # it `.first()` handed back whichever row Postgres felt like, so a re-registration of the # dotted spelling could resolve to the undotted, already-confirmed twin and send nothing. existing = next((u for u in candidates if u.email == email), None) or next( iter(candidates), None ) if existing is not None: # Already registered. This used to return in silence, and that silence is the most # likely explanation for the 2026-09-03 report that a confirmation mail "never arrived": # nothing was ever SENT. The person re-registered, got the same cheerful "check your # inbox", and waited for a message that did not exist. # # Re-sending the link instead leaks nothing new. The response and its timing are # unchanged (uniform "ok", decided off the response path), the mail goes to the address # ON THE ACCOUNT, and this is exactly what the public /auth/verify/resend already does # for the same input — so it is not a new capability, just the end of a dead end. An # account that is already confirmed still gets nothing: there is nothing to confirm. # # And a re-send is ALL it does: the submitted password is discarded. A closing # review tried "while the address is unverified, the newest registration owns it" # (replace the hash, kill the outstanding verify tokens) as a fix for address # pre-emption, and it proved worse than the problem — DO NOT REPEAT IT. # The rule is symmetric, and an attacker can always be last: it hands an unauthenticated # caller the power to overwrite a real registrant's chosen password and delete the # confirmation link already in their inbox, and on a SEC-10-stripped row (password_hash # NULL, password_revoked_at set) it would install a password where the whole point was # that there is none. The owner's real recourse already exists and needs no new # primitive: password-reset accepts this account, proves the mailbox, rewrites the hash # and bumps the session epoch. The residual is logged as SEC-16. user = existing if not user.email_verified and email_mod.email_enabled(): send_verification_link(db, user) return # Without working SMTP we can't deliver a verification link, so email ownership can't gate # sign-in. Admin approval stays the real gate (is_active=False); mark the account verified so # the flow still completes on a no-SMTP self-host. See email.email_enabled(). email_ok = email_mod.email_enabled() user = User( email=email, password_hash=hash_password(password), is_active=False, email_verified=not email_ok, # Only a supported code is stored, so a forged body can't put junk in preferences. preferences={"language": lang} if lang in mailtext.SUPPORTED else {}, ) db.add(user) try: db.flush() except IntegrityError: # A concurrent registration for the same new email won the race between the pre-check # above and this flush (the unique-email constraint fires). Same already-registered # no-op as the pre-check hit — roll back and stop. The line carries nothing: the # answer to the caller is deliberately identical either way, and so is this. log.info( "a registration lost the race for its address", extra=logs.event("auth.register.race_lost"), ) db.rollback() return upsert_pending_invite(db, email) # admin-approval gate # Persist the account ourselves. upsert_pending_invite commits only when it actually writes an # invite row — not when this address already has a pending or approved one — and _issue_token # below only runs with SMTP on. So for an address the admin had already approved (or one whose # request was still pending), on an instance with no working SMTP, NOTHING along this path # committed and the flushed user was silently rolled back when the session closed: the # would-be user got the uniform "ok" and no account, and re-registering did the same again. # Commit unconditionally; it is a no-op when upsert already committed the same transaction. db.commit() if email_ok: send_verification_link(db, user) # Fired unconditionally, unlike the Google callback, which gates on a fresh invite row: for # an address the admin already approved, nothing lands in the Access-requests queue, so this # is the only place the account's existence is announced at all. Delivery is a separate # matter — with no working SMTP `_send` drops it, which is why the pre-approved registrant # still needs the admin to notice them (roadmap UX-25). notify_admins_of_request(db, None, email) def issue_verification_link(db: Session, user: User, ttl: timedelta | None = None) -> str: """A fresh confirmation link for `user`, returned rather than sent — for a mail that carries the link inside a larger message (the branching approval/invitation mails). Same token machinery and the same fragment (SB3) as the standalone confirmation mail, so the two kinds of link cannot drift apart in shape or in how they are consumed.""" raw = _issue_token(db, user, "verify", ttl or VERIFY_TTL) return f"{_app_base()}/#verify={raw}" def send_verification_link(db: Session, user: User) -> bool: """Issue a fresh verification token for `user` and mail them the link. Always to the address ON THE ACCOUNT, never a spelling typed into some form: the account's address is the one its owner chose to be reachable at, and a `+tag` there is usually deliberate. The token rides the URL FRAGMENT (#) so it can never reach a proxy/access log or a Referer (SB3). One definition, three callers — registration, the self-service resend, and the admin-side resend. The link's shape and TTL must not drift between them; a verification mail that differs by who triggered it is a bug waiting to be discovered by exactly one user. Returns whether the mail actually went out, so a caller with a human waiting on the answer (the admin-side resend) can say so instead of reporting success for a message that was dropped. """ return email_mod.send_verify_email( user.email, mailtext.lang_for_user(user), issue_verification_link(db, user), hours=int(VERIFY_TTL.total_seconds() // 3600), ) @router.post("/verify") def verify_email_confirm(body: TokenIn, db: Session = Depends(get_db)) -> dict: """Confirm an email-verification token the SPA read from the URL fragment (SB3). Uniform 400 on any invalid/expired/used token. `password_reset_required` tells the SPA to send the user straight to the reset flow: SEC-10 had dropped the password this account registered with, so sign-in would answer with the deliberately uniform "Invalid email or password" — true, but unreadable to the person who just proved they own the mailbox. Saying so leaks nothing: holding a valid single-use verify token for this account already proves that much. The common source of that state is gone (admin approval no longer revokes), leaving accounts stripped before that change and the Google-adoption path, so this flag is rare now — but it must stay, because those users have no other way back in.""" user = _consume_token(db, body.token, "verify") if user is None: raise HTTPException(status_code=400, detail={"code": "verify", "reason": "invalid"}) user.email_verified = True needs_password = user.password_hash is None and user.password_revoked_at is not None email = user.email db.commit() # Naming the address lets the SPA say WHICH mailbox it just confirmed — the same reasoning as # password_reset_required above: holding a valid single-use verify token for this account already # proves that much, so echoing it back leaks nothing. It matters in a browser that holds a session # for a different account, where an unqualified "email confirmed" would be actively misleading. return {"ok": True, "password_reset_required": needs_password, "email": email} @router.post("/verify/resend") def verify_email_resend( body: EmailIn, request: Request, background: BackgroundTasks, ) -> dict: """Re-issue an email-verification link. Needed because the original link expires (VERIFY_TTL) and re-registering an already-registered email is a silent no-op, so a user who lost the first mail had no way back. Uniform response + timing regardless of whether the email has a pending account (the lookup runs off the response path), so it can't probe for registered emails.""" if not _verify_resend_limiter.allow(client_key(request)): return {"ok": True, "status": "ok"} # silently throttle; uniform response email = (body.email or "").strip().lower() if valid_email(email): background.add_task(_resend_verify_if_eligible, email) return {"ok": True, "status": "ok"} def _resend_verify_if_eligible(email: str) -> None: """Background worker for verify/resend: issue a fresh verify token + email the link, but ONLY for a real account that still needs verifying. Runs after the response with its own DB session, so the request's timing never reveals whether the account exists. No-op when SMTP is disabled (accounts are auto-verified on a no-SMTP instance, so there is nothing to resend). Token rides the URL FRAGMENT (#) so it can't leak into proxy/access logs or a Referer (SB3).""" if not email_mod.email_enabled(): return with SessionLocal() as db: user = find_user_by_email(db, email) if user is not None and not user.email_verified: send_verification_link(db, user) def _login_refused(status: int, reason: str, user: User | None = None, **extra) -> HTTPException: """Every refusal password_login can answer with, in the codebase's structured `{code, reason}` shape so the SPA can say it in the user's own language (the old bare English strings were unreadable under a bilingual UI, and unusable as anything but a one-line error). The account-shaped reasons carry the address ON THE ACCOUNT plus both gate flags. That is what turns a flat 403 into an honest screen — it names the mailbox and says which of the two gates is still shut. It is not an enumeration leak: this branch is only reachable once the password has been proven, so the caller already knows the account exists. The two reasons that answer BEFORE that proof (`invalid`, `rate_limited`) carry nothing at all, so they stay byte-identical for a real account and an unknown one. The two gates are order-independent, so `approved` really can be true while `email_verified` is false, and vice versa — the SPA renders whichever is actually outstanding rather than assuming a sequence. """ detail: dict = {"code": "login", "reason": reason} if user is not None: detail |= { "email": user.email, "email_verified": user.email_verified, "approved": user.is_active, } detail |= {k: v for k, v in extra.items() if v is not None} return HTTPException(status_code=status, detail=detail) @router.post("/password-login") def password_login( body: EmailPasswordIn, request: Request, background: BackgroundTasks, db: Session = Depends(get_db), ) -> dict: """Email+password sign-in. Wrong email/password give a single uniform 401 (no enumeration). Once the password is proven correct, the owner gets a specific reason if their account is still pending or suspended — that's not an enumeration leak (they already hold the password).""" if not _login_limiter.allow(client_key(request)): raise _login_refused(429, "rate_limited") email = (body.email or "").strip().lower() password = body.password or "" user = find_user_by_email(db, email) # Always run the KDF (against a decoy hash for an unknown / passwordless account) so the # response time is the same whether or not the email exists — no timing-based enumeration. candidate_hash = user.password_hash if user else None password_ok = verify_password(password, candidate_hash or _DECOY_PASSWORD_HASH) if user is None or not password_ok: raise _login_refused(401, "invalid") if user.is_suspended: _notify_suspended(background, user) raise _login_refused(403, "suspended", user, operator=operator_contact() or None) if not user.email_verified: raise _login_refused(403, "email_unverified", user) if not user.is_active: raise _login_refused(403, "pending_approval", user) _establish_session(request, user) db.commit() log.info( "signed in", extra=logs.event("auth.login.ok", method="password", uid=user.id, role=user.role), ) return {"ok": True} @router.post("/password-reset/request") def password_reset_request( body: EmailIn, request: Request, background: BackgroundTasks, ) -> dict: """Request a password-reset link. Uniform response + timing regardless of whether the email has a password account (the lookup runs off the response path), so it can't probe for registered emails.""" if not _reset_limiter.allow(client_key(request)): return {"ok": True, "status": "ok"} email = (body.email or "").strip().lower() # Do the account lookup + token issue + email OFF the response path (a background task with its # own session), so the endpoint takes the same time for any valid email whether or not it has a # password account — no timing-based enumeration. Any valid email schedules the same task. if valid_email(email): background.add_task(_send_reset_if_eligible, email) return {"ok": True, "status": "ok"} def _send_reset_if_eligible(email: str) -> None: """Background worker for password_reset_request: issue a reset token + email the link, but ONLY for a password account. Runs after the response with its own DB session, so the request's timing never reveals whether the account exists. Token rides the URL FRAGMENT (#) so it can't leak into proxy/access logs or a Referer (SB3). "Password account" is `password_hash OR password_revoked_at` — an account SEC-10 stripped had a password and must be able to get a new one, or it is locked out permanently (no password to sign in with, and formerly no reset link either). A Google-only account that never had a password stays ineligible on purpose: its owner adds one from Settings (/auth/set-password) while signed in, which proves identity through the session rather than by mail.""" with SessionLocal() as db: user = find_user_by_email(db, email) if user is None: return if user.password_hash or user.password_revoked_at: raw = _issue_token(db, user, "reset", RESET_TTL) # The account's own address, as above — never the spelling that was typed into the form. email_mod.send_password_reset( user.email, mailtext.lang_for_user(user), f"{_app_base()}/#reset={raw}" ) @router.post("/password-reset/check") def password_reset_check(body: TokenIn, request: Request, db: Session = Depends(get_db)) -> dict: """Is the reset link the SPA just opened still good? Answers without spending the token. Why this exists: the card used to render "set a new password" purely because a `#reset=` fragment was present, never asking whether it could still succeed. Reported on 2026-09-05 after deleting an account and then opening its reset link — the user types a new password into a form that answers 400. Opening a dead reset link is not an edge case: the token lives one hour, mail is slow or lands in spam, and whoever got two mails usually clicks the older one. Not an enumeration oracle: probing requires already holding a 43-character random, and the answer is exactly what submitting it would reveal. Throttled per IP anyway — and a throttled caller is told `true`, never `false`: this check may only ever save a user a wasted form, so when it cannot answer it must degrade to the old behaviour instead of declaring a live link dead. """ if not _reset_check_limiter.allow(client_key(request)): return {"ok": True} return {"ok": any(_peek_token(db, body.token, k) is not None for k in _PASSWORD_TOKEN_KINDS)} @router.post("/password-reset/confirm") def password_reset_confirm(body: TokenPasswordIn, db: Session = Depends(get_db)) -> dict: """Set a new password from a valid reset token, then invalidate any other reset tokens.""" token = body.token new_password = body.password or "" if len(new_password) < PASSWORD_MIN_LEN: raise HTTPException( status_code=400, detail={"code": "reset", "reason": "weak_password", "min": PASSWORD_MIN_LEN}, ) spent = _consume_password_token(db, token) if spent is None: raise HTTPException(status_code=400, detail={"code": "reset", "reason": "invalid"}) user, kind = spent user.password_hash = hash_password(new_password) user.password_revoked_at = None # the account has a password again — SEC-10's trace is spent # A valid reset link proves control of the mailbox, so completing a reset also verifies the # email — this rescues an account that registered but never clicked the verification link (it # can then sign in). The separate admin-approval gate (is_active) is deliberately NOT touched # here: a reset proves email ownership, not that an admin approved the account. # # An admin-issued `setpw` link proves NOTHING about the mailbox — the admin hands it over by # whatever means they choose — so it must not satisfy the email gate. Claiming otherwise would let # an admin confirm an address nobody ever proved, which is the same false attestation SEC-15 is # about. The person still confirms their address the normal way. if kind == "reset": user.email_verified = True # SA4: a reset (often a compromise response) kills all existing sessions — and API tokens. bump_session_epoch(db, user) # Burn any other outstanding reset tokens for this user. for row in db.execute( select(AuthToken).where( owned_by(AuthToken.user_id, user), AuthToken.kind.in_(_PASSWORD_TOKEN_KINDS), AuthToken.used_at.is_(None), ) ).scalars(): row.used_at = datetime.now(timezone.utc) db.commit() return {"ok": True} def _remember_epoch(request: Request, user: User) -> None: """Record this account's current `session_epoch` in the signed cookie (SA4), so `current_user` can later reject the cookie if the account's epoch was bumped (password reset/change, or "log out everywhere"). Per-account so one account's revocation doesn't evict the others.""" epochs = dict(request.session.get("epochs") or {}) epochs[str(user.id)] = user.session_epoch request.session["epochs"] = epochs def bump_session_epoch(db: Session, user: User) -> None: """Invalidate every EXISTING signed cookie for this account by advancing its epoch (SA4). Cookies minted before the bump carry a now-stale epoch and are rejected by `current_user`. The account's API tokens go with them (`apitokens.revoke_all`): a token minted from a stolen session must not outlive the answer to that theft. Not committed — the caller commits it with its change. The user's row is locked before the tokens go, the lock `create_token` takes too: a token made at the same moment either commits first and is deleted here, or waits and sees the new epoch. FOR NO KEY UPDATE (`key_share`): the two still exclude each other, but a row that only refers to the user (a notice, an audit line) is not held up — a token's expiry notice would deadlock with it.""" if user.id is None: user.session_epoch = (user.session_epoch or 0) + 1 return epoch = db.execute( select(User.session_epoch).where(User.id == user.id).with_for_update(key_share=True) ).scalar_one() user.session_epoch = (epoch or 0) + 1 apitokens.revoke_all(db, user.id) def revoke_untrusted_preset_password(db: Session, user: User) -> None: """SEC-10 (account pre-emption): a password set on an account whose email was NEVER verified is untrusted — whoever registered the address (to pre-empt the real owner) could have chosen it. When the real owner attests ownership through Google, drop that password and bump the epoch to revoke any session it minted; the owner re-sets one via password-reset, or from Settings while the Google session lasts. A NEVER-ACTIVATED account is untrusted too, verified or not: without SMTP, registration marks the address verified with no proof at all. For an ADMIN_EMAILS / ALLOWED_EMAILS address no invite row gates the registration, so a stranger who registered it first would otherwise be activated here with their own password still working. (An account the callback makes ADMIN loses its password whatever its state — see the ADMIN_EMAILS block there.) The Google-adoption callback is the ONLY caller. Admin approval used to call this too, and dropping it was right for the reason given — the revoke destroyed the password a legitimate registrant had just chosen, and since the admin normally decides before the user reads their mail, that made the "your password was cleared" detour the DEFAULT path rather than an edge case. The revoke was also called REDUNDANT there, because `password_login` refuses an unverified account before it reads `is_active`. That part was wrong, and the epic's own review caught it: the gate holds only while the address is unverified, and the approval mail exists to get the real owner to end that state — at which point a pre-emptor's password works. Nothing replaced the revoke, and that is deliberate. The review tried "the newest registration owns an unverified account" and reverted it: the rule is symmetric, so an attacker can simply register last, and it would have handed anonymous callers a write onto a real account's credential. What the owner actually has is password-reset, which proves the mailbox, rewrites the hash and bumps the epoch. The residual is logged as SEC-16; re-adding a caller here would put "your password was cleared" back in front of every legitimate registrant, which is the whole reason it was removed. `password_revoked_at` is what makes "the owner re-sets one via password-reset" actually true. The revoke leaves `password_hash IS NULL`, which is also what a Google-only account looks like — and _send_reset_if_eligible keyed off exactly that, so a revoked account silently got no reset link and was locked out for good (it could not sign in either). The stamp separates the two cases.""" if not user.email_verified or (not user.is_active and user.password_hash is not None): _revoke_password(db, user) def _revoke_password(db: Session, user: User) -> None: """Drop the account's password, stamp why (so password-reset still accepts it) and end every session it signed in.""" user.password_hash = None user.password_revoked_at = datetime.now(timezone.utc) bump_session_epoch(db, user) @router.post("/logout-others") def logout_others( request: Request, user: User = Depends(current_user), db: Session = Depends(get_db) ) -> dict: """SA4: sign every OTHER session for this account out — bump the epoch so all existing cookies are rejected, then re-stamp THIS cookie so the caller stays signed in. For "I left myself logged in somewhere" / a suspected session compromise.""" # Commit the bump BEFORE re-stamping this cookie, so a failed commit can't strand the current # session at a newer epoch than the DB (see set_password). bump_session_epoch(db, user) db.commit() _remember_epoch(request, user) log.info("Logout-others: uid=%s (epoch=%s)", user.id, user.session_epoch) return {"ok": True} @router.get("/link") async def link_google( request: Request, user: User = Depends(current_user), db: Session = Depends(get_db), ): """Start linking a Google account to the signed-in (e.g. password) account. Requests only the identity scopes — YouTube access is granted separately via /auth/upgrade. The callback sees `oauth_link_uid` and attaches the identity instead of creating a new account.""" client = google_oauth(db) if client is None: return RedirectResponse(url="/?link=error") request.session["oauth_link_uid"] = user.id request.session["oauth_link_explicit"] = True return await client.authorize_redirect( request, _oauth_redirect_uri(request), access_type="offline", prompt="select_account", include_granted_scopes="true", scope=BASE_SCOPES, ) @router.post("/set-password") def set_password( body: SetPasswordIn, request: Request, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: """Set or change the signed-in account's password. Setting a first password (Google-only account) needs no current password — the session already proves identity. Changing an existing one requires the current password, so a hijacked session can't silently rotate it.""" new_password = body.password or "" if len(new_password) < PASSWORD_MIN_LEN: raise refused(400, "weak_password", min=PASSWORD_MIN_LEN) if user.password_hash: if not verify_password(body.current_password or "", user.password_hash): raise refused(403, "wrong_password") user.password_hash = hash_password(new_password) user.password_revoked_at = None # same as the reset path: a password exists again # SA4: a password change logs out every OTHER session; re-stamp this cookie at the new epoch so # the person making the change stays signed in here. Commit BEFORE re-stamping so a failed commit # can't leave the cookie at a newer epoch than the DB (which would wrongly 401 THIS session). bump_session_epoch(db, user) db.commit() _remember_epoch(request, user) log.info("Password set/changed: uid=%s", user.id) return {"ok": True} @router.get("/upgrade") async def upgrade( request: Request, access: str = "read", user: User = Depends(current_user), db: Session = Depends(get_db), ): """Incremental consent for the onboarding wizard. `access=read` grants YouTube read-only (enough to build the feed); `access=write` additionally grants management (unsubscribe). The shared callback stores whatever Google returns, so `can_read` / `can_write` flip on once granted. Anything other than an explicit `write` defaults to the least-privileged read scope.""" client = google_oauth(db) if client is None: return RedirectResponse(url="/") # Attach the grant to THIS account in the callback. Without it a password account (no # google_sub) would get a brand-new, separate Google user created instead of being linked. request.session["oauth_link_uid"] = user.id scope = WRITE_SCOPES if access == "write" else READ_SCOPES return await client.authorize_redirect( request, _oauth_redirect_uri(request), access_type="offline", prompt="consent", include_granted_scopes="true", scope=scope, ) @router.get("/config") def auth_config(db: Session = Depends(get_db)) -> dict: """Public: which sign-in options this instance offers, so the login page can hide what's off (e.g. no Google button when Google OAuth isn't configured).""" return { "google_enabled": google_enabled(db), "allow_registration": sysconfig.get_bool(db, "allow_registration"), # Contact shown on the public legal pages (the operator's admin email), if configured. "operator_contact": operator_contact(), }