From f7fa516332ae1f2cb495e88681199a29ae218f62 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 12 Jul 2026 04:13:11 +0200 Subject: [PATCH 1/6] fix(auth): close SA5 timing oracles on register/reset + dedup messages_ws MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Loose ends to finish the auth security round: - register + password-reset-request had an enumeration TIMING oracle: an already- registered email skipped the create path (hash + row writes + email scheduling) and responded measurably faster than a new one. Move the whole lookup+create (register) and lookup+token+email (reset) into a background task with its own DB session, so the endpoint returns in the same time for any valid email regardless of whether it exists. Verified: existing vs new now ~equal (was 34ms vs 82ms on register); accounts/tokens still created off-path. - messages_ws re-implemented resolved_user_id's per-tab wallet-gated account resolution. Generalize resolved_user_id to take any HTTPConnection (Request OR WebSocket) and call it from the WS — one shared, wallet-gated resolution. Behavior-identical (unit-checked). --- backend/app/auth.py | 83 ++++++++++++++++++++-------------- backend/app/routes/messages.py | 21 +++------ 2 files changed, 56 insertions(+), 48 deletions(-) diff --git a/backend/app/auth.py b/backend/app/auth.py index 5874b65..a63afa6 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -7,13 +7,14 @@ import httpx from authlib.integrations.starlette_client import OAuth, OAuthError from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request from fastapi.responses import JSONResponse, RedirectResponse +from starlette.requests import HTTPConnection from sqlalchemy import delete, func, select from sqlalchemy.orm import Session from app import email as email_mod from app import sysconfig from app.config import settings -from app.db import get_db +from app.db import SessionLocal, get_db from app.models import AuthToken, DemoWhitelist, Invite, OAuthToken, User from app.ratelimit import RateLimiter from app.security import decrypt, encrypt, hash_password, hash_token, verify_password @@ -596,11 +597,24 @@ def register( if not _register_limiter.allow(_client_ip(request)): return {"status": "ok"} # silently throttle; uniform response - existing = db.execute(select(User).where(User.email == email)).scalar_one_or_none() - if existing is None: - # 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(). + # 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) + return {"status": "ok"} + + +def _register_account(email: str, password: str) -> 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: + if db.execute(select(User).where(User.email == email)).scalar_one_or_none() is not None: + return # already registered — nothing to do (the uniform "ok" was already returned) + # 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, @@ -610,20 +624,13 @@ def register( ) db.add(user) db.flush() - upsert_pending_invite(db, email) # admin-approval gate + upsert_pending_invite(db, email) # admin-approval gate (commits) if email_ok: raw = _issue_token(db, user, "verify", VERIFY_TTL) - # Fragment (#), not a query token: keeps the token out of proxy/access logs + Referer. - # The SPA reads the fragment and POSTs it to /auth/verify (SB3). - background.add_task( - email_mod.send_verify_email, email, f"{_app_base()}/#verify={raw}" - ) + # Fragment (#), not a query token: keeps the token out of proxy/access logs + Referer (SB3). + email_mod.send_verify_email(email, f"{_app_base()}/#verify={raw}") if settings.admin_email_set: - background.add_task( - email_mod.send_admin_new_request, sorted(settings.admin_email_set), email - ) - # Existing email → do nothing visible (no enumeration). Uniform success either way. - return {"status": "ok"} + email_mod.send_admin_new_request(sorted(settings.admin_email_set), email) @router.post("/verify") @@ -692,21 +699,30 @@ def password_reset_request( payload: dict, request: Request, background: BackgroundTasks, - db: Session = Depends(get_db), ) -> dict: - """Request a password-reset link. Uniform response regardless of whether the email has a - password account, so it can't probe for registered emails.""" + """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_ip(request)): return {"status": "ok"} email = (payload.get("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 {"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 real (non-demo) 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).""" + with SessionLocal() as db: user = db.execute(select(User).where(User.email == email)).scalar_one_or_none() if user is not None and user.password_hash and not user.is_demo: raw = _issue_token(db, user, "reset", RESET_TTL) - # Token in the URL FRAGMENT (#), not the query (?): a fragment is never sent to the - # server, so it can't leak into proxy/access logs or a Referer header (SB3). - background.add_task(email_mod.send_password_reset, email, f"{_app_base()}/#reset={raw}") - return {"status": "ok"} + email_mod.send_password_reset(email, f"{_app_base()}/#reset={raw}") @router.post("/password-reset/confirm") @@ -741,21 +757,22 @@ def password_reset_confirm(payload: dict, db: Session = Depends(get_db)) -> dict ACTIVE_ACCOUNT_HEADER = "x-siftlode-account" -def resolved_user_id(request: Request) -> tuple[int | None, bool]: - """Which account this request acts as, and whether that's the session's default account. +def resolved_user_id(conn: HTTPConnection) -> tuple[int | None, bool]: + """Which account this connection acts as, and whether that's the session's default account. + Takes any HTTPConnection — an HTTP Request OR a WebSocket — so the WS push channel shares this + exact per-tab resolution instead of re-implementing it. The signed cookie holds the *wallet* (`account_ids`, every account signed into this browser) - plus a default `user_id`. A request may override the default with the X-Siftlode-Account - header, but ONLY for an account already in the wallet — so a tab can't impersonate an account - that never authenticated here. Everything else (WebSocket, plain navigations) keeps using the - cookie default. + plus a default `user_id`. A caller may override the default with the X-Siftlode-Account header, + but ONLY for an account already in the wallet — so a tab can't impersonate an account that never + authenticated here. Everything else (WebSocket, plain navigations) keeps using the cookie default. """ - default_id = request.session.get("user_id") - wallet = request.session.get("account_ids") or [] + default_id = conn.session.get("user_id") + wallet = conn.session.get("account_ids") or [] # Header for normal XHR; ?account= for contexts that can't set headers (WebSocket, and a # plain file download). Both are wallet-gated below, so neither can impersonate an # account that never signed into this browser. - hdr = request.headers.get(ACTIVE_ACCOUNT_HEADER) or request.query_params.get("account") + hdr = conn.headers.get(ACTIVE_ACCOUNT_HEADER) or conn.query_params.get("account") if hdr: try: hid = int(hdr) diff --git a/backend/app/routes/messages.py b/backend/app/routes/messages.py index 420bd58..5800220 100644 --- a/backend/app/routes/messages.py +++ b/backend/app/routes/messages.py @@ -20,7 +20,7 @@ from pydantic import BaseModel from sqlalchemy import and_, func, or_, select from sqlalchemy.orm import Session -from app.auth import require_human +from app.auth import require_human, resolved_user_id from app.db import SessionLocal, get_db from app.models import Message, MessageKey, User from app.ratelimit import RateLimiter @@ -363,19 +363,10 @@ async def messages_ws(ws: WebSocket) -> None: """Live push channel: authenticated by the session cookie, registers the connection so sent messages reach this user's open tabs instantly. We don't consume client→server frames (sending goes through POST /api/messages); the receive loop only detects disconnect.""" - # A browser WebSocket can't send custom headers, so the per-tab account (see - # X-Siftlode-Account on HTTP) rides in the ?account= query param instead — honoured only for - # an account already in this browser's wallet. Falls back to the session default. - sess = ws.session if "session" in ws.scope else {} - uid = sess.get("user_id") - q = ws.query_params.get("account") - if q: - try: - qid = int(q) - except ValueError: - qid = None - if qid is not None and qid in (sess.get("account_ids") or []): - uid = qid + # Which signed-in account this socket acts as — the SHARED per-tab resolution (wallet-gated + # ?account= override, session default otherwise). A browser WebSocket can't send the + # X-Siftlode-Account header, so resolved_user_id falls through to the ?account= query param. + uid, _ = resolved_user_id(ws) if not uid: await ws.close(code=1008) return @@ -385,7 +376,7 @@ async def messages_ws(ws: WebSocket) -> None: # SA4: honour server-side session revocation on the live channel too — reject a cookie whose # recorded epoch is behind the account's current one (mirrors current_user). Without this a # copied cookie could keep receiving pushes after a password reset / "log out everywhere". - epochs = sess.get("epochs") or {} + epochs = (ws.session if "session" in ws.scope else {}).get("epochs") or {} epoch_ok = user is not None and int(epochs.get(str(uid), 0)) == (user.session_epoch or 0) ok = epoch_ok and is_messageable_user(user) finally: From d4402f47098c2c6067fef7a9b1e089af53bc3350 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 12 Jul 2026 04:17:02 +0200 Subject: [PATCH 2/6] chore(auth): drop now-dead session guard in messages_ws epoch read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolved_user_id already accesses ws.session unguarded just above (SessionMiddleware covers the WS scope), so the '"session" in ws.scope' fallback was dead code — read ws.session directly for consistency (review follow-up). --- backend/app/routes/messages.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/routes/messages.py b/backend/app/routes/messages.py index 5800220..6089550 100644 --- a/backend/app/routes/messages.py +++ b/backend/app/routes/messages.py @@ -376,7 +376,7 @@ async def messages_ws(ws: WebSocket) -> None: # SA4: honour server-side session revocation on the live channel too — reject a cookie whose # recorded epoch is behind the account's current one (mirrors current_user). Without this a # copied cookie could keep receiving pushes after a password reset / "log out everywhere". - epochs = (ws.session if "session" in ws.scope else {}).get("epochs") or {} + epochs = ws.session.get("epochs") or {} epoch_ok = user is not None and int(epochs.get(str(uid), 0)) == (user.session_epoch or 0) ok = epoch_ok and is_messageable_user(user) finally: From fa0d0bceaf1692d40e631c95dc8d56aef4aa7cdc Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 12 Jul 2026 04:34:07 +0200 Subject: [PATCH 3/6] fix(auth): preserve OAuth scopes across re-login + accurate multi-admin request emails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two user-reported signin bugs: 1) A plain re-login (or logout→login) wiped the user's YouTube grant: login requests only BASE_SCOPES and Google's returned `scope` can list just those, but _store_token wrote it verbatim — dropping a previously-granted youtube.readonly/youtube scope, so can_read flipped to false and the feed demanded a reconnect. The underlying grant (refresh token) survives such a login, so UNION the scopes instead of narrowing; they only shrink on full disconnect (purge deletes the token row). 2) Admin 'new access request' email: (a) it named the wrong menu ('Settings → Account' instead of Users → Access requests); (b) the requester address was invisible so the 'reply reaches them' note looked wrong (Reply-To is in fact set to the requester) — now spelled out + a deep-link straight to the approve view (?admin=access-requests, handled in App); (c) it emailed only the static env ADMIN_EMAILS — now notifies the ACTUAL admins (active role=admin users) unioned with env, so a UI-promoted admin (who CAN approve — the approve UI is role-gated) is notified too. --- backend/app/auth.py | 45 +++++++++++++++++++++++++++++++------------- backend/app/email.py | 11 +++++++++-- frontend/src/App.tsx | 13 +++++++++++++ 3 files changed, 54 insertions(+), 15 deletions(-) diff --git a/backend/app/auth.py b/backend/app/auth.py index a63afa6..e82fc30 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -132,6 +132,20 @@ def has_write_scope(user: User) -> bool: return WRITE_SCOPE in tok.scopes.split() +def admin_notify_emails(db: Session) -> list[str]: + """Who to email about admin events (e.g. a new access request). 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.""" + db_admins = db.execute( + select(User.email).where( + User.role == "admin", User.is_active.is_(True), User.is_suspended.is_(False) + ) + ).scalars() + return sorted({e.lower() for e in db_admins if e} | settings.admin_email_set) + + def is_allowed(db: Session, email: str) -> bool: """May this email sign in? An approved Invite is the source of truth; the env ALLOWED_EMAILS/ADMIN_EMAILS remain a bootstrap fallback (e.g. a fresh DB).""" @@ -282,12 +296,9 @@ async def callback( # land the user on a friendly "request received" screen instead of a raw 403. if email: inv = upsert_pending_invite(db, email) - if inv is not None and settings.admin_email_set: - background.add_task( - email_mod.send_admin_new_request, - sorted(settings.admin_email_set), - email, - ) + admins = admin_notify_emails(db) + if inv is not None and admins: + background.add_task(email_mod.send_admin_new_request, admins, email) return RedirectResponse(url="/?access=requested") return RedirectResponse(url="/?access=denied") @@ -383,7 +394,15 @@ def _store_token(db: Session, user: User, token: dict) -> None: 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 - tok.scopes = token.get("scope") or BASE_SCOPES + # UNION the scopes, never narrow them. A plain sign-in requests only BASE_SCOPES, and Google's + # returned `scope` can list just those rather than the union of everything granted — so writing it + # verbatim would DROP a previously-granted YouTube read/write scope on every re-login, wrongly + # flipping can_read to false (the feed asks to reconnect). The underlying grant (refresh token) + # survives such a login, so we keep the broadest set we've seen. Scopes only shrink when the whole + # grant is torn down (purge_user deletes the token row); an externally-revoked scope surfaces as a + # YouTube API 403 → the reconnect prompt, not via this field. + returned = (token.get("scope") or BASE_SCOPES).split() + tok.scopes = " ".join(sorted(set((tok.scopes or "").split()) | set(returned))) db.add(tok) @@ -495,10 +514,9 @@ async def request_access( if is_allowed(db, email): return {"status": "approved"} # already allowed — just sign in inv = upsert_pending_invite(db, email) - if inv is not None and settings.admin_email_set: - background.add_task( - email_mod.send_admin_new_request, sorted(settings.admin_email_set), email - ) + admins = admin_notify_emails(db) + if inv is not None and admins: + background.add_task(email_mod.send_admin_new_request, admins, email) return {"status": "pending"} @@ -629,8 +647,9 @@ def _register_account(email: str, password: str) -> None: raw = _issue_token(db, user, "verify", VERIFY_TTL) # Fragment (#), not a query token: keeps the token out of proxy/access logs + Referer (SB3). email_mod.send_verify_email(email, f"{_app_base()}/#verify={raw}") - if settings.admin_email_set: - email_mod.send_admin_new_request(sorted(settings.admin_email_set), email) + admins = admin_notify_emails(db) + if admins: + email_mod.send_admin_new_request(admins, email) @router.post("/verify") diff --git a/backend/app/email.py b/backend/app/email.py index da0eea4..4f5c108 100644 --- a/backend/app/email.py +++ b/backend/app/email.py @@ -142,10 +142,17 @@ def send_account_suspended(to: str, operator: str | None) -> bool: def send_admin_new_request(admins: list[str], requester: str) -> bool: + # The link deep-links straight to the admin Access-requests page (the SPA reads ?admin=…). The + # requester address is spelled out (mail clients auto-link both the URL and the address), and the + # reply note is accurate: _send sets Reply-To to the requester, so a reply reaches THEM — not the + # sending mailbox. (The old copy said "Settings → Account", which was the wrong menu.) + approve_url = f"{settings.app_base}/?admin=access-requests" body = ( f"{requester} just requested access to Siftlode.\n\n" - "Open Siftlode → Settings → Account → Access requests to approve or deny it.\n\n" - "(Reply to this email to reach the requester directly.)\n" + f"Approve or deny it here:\n{approve_url}\n" + f"(or in Siftlode: open Users → Access requests)\n\n" + f"Requester's email: {requester}\n" + f"Replying to this message goes straight to the requester (it's the Reply-To address).\n" ) return _send(admins, f"Siftlode access request from {requester}", body, reply_to=requester) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6d64895..68d1339 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -528,6 +528,19 @@ export default function App() { stripUrlParams(); }, []); // eslint-disable-line react-hooks/exhaustive-deps + // Deep-link from the "new access request" admin email (?admin=access-requests) → jump straight to + // the admin Users → Access requests view. Snapshot the param so the pre-login URL strip doesn't + // drop it; act once `me` is known, and only for an admin (others just land on their default page). + const [adminDeepLink] = useState(() => new URLSearchParams(window.location.search).get("admin")); + useEffect(() => { + if (adminDeepLink !== "access-requests" || !meQuery.data) return; + if (meQuery.data.role === "admin") { + focusAccessRequestsTab(); + setPage("users"); + } + stripUrlParams(); + }, [adminDeepLink, meQuery.data?.id]); // eslint-disable-line react-hooks/exhaustive-deps + // First-login onboarding: prompt the user to connect YouTube (and resume the flow after // each consent redirect). Derived from granted scopes + storage flags so it's stable // across the full-page OAuth round-trip; dismissible and reopenable from Settings. From 9e6c90bcafc3307e8564b0bf020359b18ce0de54 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 12 Jul 2026 04:44:37 +0200 Subject: [PATCH 4/6] fix(auth): don't let a re-sign-in clobber the YouTube grant (root cause) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correcting the earlier scope-union-only fix. The real damage from a logout→login isn't just the stored `scope` field — verified via a live token refresh that the stored refresh token itself had been REPLACED with a base-only one: a plain sign-in requests only BASE_SCOPES and Google handed back a fresh base-only access+refresh token, which _store_token adopted verbatim, destroying the read/write grant (the old refresh token stays valid on Google's side, so overwriting it is what loses access). _store_token now detects when an exchange would NARROW our YouTube scopes and, in that case, keeps the WHOLE existing grant (refresh token, access token, scopes) untouched. Broader-or-equal exchanges (first grant, read→write upgrade) adopt + union as before. Unit-verified across base-relogin / upgrade / new-user / base-user cases. --- backend/app/auth.py | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/backend/app/auth.py b/backend/app/auth.py index e82fc30..dcf0d88 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -73,6 +73,8 @@ READ_SCOPE = f"{WRITE_SCOPE}.readonly" BASE_SCOPES = "openid email profile" READ_SCOPES = f"{BASE_SCOPES} {READ_SCOPE}" WRITE_SCOPES = f"{BASE_SCOPES} {READ_SCOPE} {WRITE_SCOPE}" +# The YouTube grants (read ⊂ write). A sign-in must never silently drop one of these — see _store_token. +_YOUTUBE_SCOPES = frozenset({READ_SCOPE, WRITE_SCOPE}) log = logging.getLogger("siftlode.auth") @@ -387,6 +389,18 @@ def _store_token(db: Session, user: User, token: dict) -> None: 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 @@ -394,15 +408,8 @@ def _store_token(db: Session, user: User, token: dict) -> None: 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 the scopes, never narrow them. A plain sign-in requests only BASE_SCOPES, and Google's - # returned `scope` can list just those rather than the union of everything granted — so writing it - # verbatim would DROP a previously-granted YouTube read/write scope on every re-login, wrongly - # flipping can_read to false (the feed asks to reconnect). The underlying grant (refresh token) - # survives such a login, so we keep the broadest set we've seen. Scopes only shrink when the whole - # grant is torn down (purge_user deletes the token row); an externally-revoked scope surfaces as a - # YouTube API 403 → the reconnect prompt, not via this field. - returned = (token.get("scope") or BASE_SCOPES).split() - tok.scopes = " ".join(sorted(set((tok.scopes or "").split()) | set(returned))) + # 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) From 0850f6a13b3780be908e8bbe7b755bae5744585e Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 12 Jul 2026 04:52:31 +0200 Subject: [PATCH 5/6] fix(auth): decrypt the access-token fallback before revoking on account deletion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit purge_user's revoke used `decrypt(refresh) or tok.access_token`, but access_token is stored ENCRYPTED — so a token row without a refresh token would send ciphertext to Google's revoke endpoint and silently fail. Decrypt it. (Pre-existing; surfaced by the review of the OAuth-scope fix.) --- backend/app/auth.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/app/auth.py b/backend/app/auth.py index dcf0d88..5f814d6 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -440,7 +440,9 @@ def purge_user(db: Session, user: User, background: BackgroundTasks) -> None: email = user.email.lower() user_id = user.id tok = user.token - google_token = (decrypt(tok.refresh_token_enc) or tok.access_token) if tok else None + # Both are stored encrypted — decrypt for the revoke call (the access-token fallback was passing + # ciphertext, so a token row with no refresh token silently failed to revoke). + google_token = (decrypt(tok.refresh_token_enc) or decrypt(tok.access_token)) if tok else None # Raw delete so Postgres applies ON DELETE CASCADE / SET NULL on every dependent table. db.execute(delete(User).where(User.id == user_id)) db.execute(delete(Invite).where(Invite.email == email)) From 182dddf5ed9844fd10adae1bfff50b8d5ca50eda Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 12 Jul 2026 04:57:06 +0200 Subject: [PATCH 6/6] release: v0.39.1 OAuth scope-clobber fix (sign-out/in no longer drops YouTube access) + admin access-request email accuracy/multi-admin + auth loose-ends (register/reset timing, messages_ws dedup, revoke decrypt). --- VERSION | 2 +- frontend/src/lib/releaseNotes.ts | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 4ef2eb0..d2e2400 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.39.0 +0.39.1 diff --git a/frontend/src/lib/releaseNotes.ts b/frontend/src/lib/releaseNotes.ts index c6cf63c..daba6f9 100644 --- a/frontend/src/lib/releaseNotes.ts +++ b/frontend/src/lib/releaseNotes.ts @@ -14,6 +14,13 @@ export interface ReleaseEntry { } export const RELEASE_NOTES: ReleaseEntry[] = [ + { + version: "0.39.1", + date: "2026-07-12", + fixes: [ + "Signing out and back in no longer disconnects your YouTube access.", + ], + }, { version: "0.39.0", date: "2026-07-12",