feat(auth): split base sign-in from YouTube scopes for incremental onboarding

Base login now requests only openid/email/profile (non-sensitive), so a new user
gets a clean Google consent with no "unverified app" warning and no 7-day refresh
token expiry. YouTube read (youtube.readonly) and write (youtube) are granted later
by the onboarding wizard via a parameterized /auth/upgrade?access=read|write.

Security fixes folded in from the baseline audit:
- config: refuse to boot in production (https OAUTH_REDIRECT_URL) with the
  placeholder/short SECRET_KEY or a missing TOKEN_ENCRYPTION_KEY, closing a
  session-forgery / admin-impersonation hole.
- main: mark the session cookie Secure when served over HTTPS.
- me: expose can_read; sync/subscriptions returns a friendly 403 (not a 500)
  until YouTube read access is granted.
This commit is contained in:
npeter83 2026-06-13 23:56:34 +02:00
parent 9163a5f68e
commit 4765db89de
6 changed files with 81 additions and 17 deletions

View file

@ -16,12 +16,19 @@ from app.security import encrypt
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
# YouTube's full scope (read + write: unsubscribe, playlist export) and its read-only
# counterpart. Default login asks for read-only; write is an explicit opt-in via
# /auth/upgrade (incremental consent), so friends who won't grant write can still browse.
# Base sign-in requests only the non-sensitive OpenID scopes (profile/email). These do
# NOT trigger Google's "unverified app" warning and do NOT expire after 7 days, so a new
# user gets a clean, familiar consent. YouTube access is granted later, one step at a
# time, by the onboarding wizard via /auth/upgrade (incremental authorization):
# - read -> youtube.readonly (read subscriptions, build the feed)
# - write -> youtube (full: unsubscribe / playlist export)
# Both YouTube scopes are "sensitive"; the wizard explains the Google warning before
# sending the user there, so the extra permission never feels like a surprise.
WRITE_SCOPE = "https://www.googleapis.com/auth/youtube"
READ_SCOPES = f"openid email profile {WRITE_SCOPE}.readonly"
WRITE_SCOPES = f"openid email profile {WRITE_SCOPE}"
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}"
log = logging.getLogger("subfeed.auth")
@ -33,10 +40,20 @@ oauth.register(
client_id=settings.google_client_id,
client_secret=settings.google_client_secret,
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
client_kwargs={"scope": READ_SCOPES},
client_kwargs={"scope": BASE_SCOPES},
)
def has_read_scope(user: User) -> bool:
"""Whether the user's stored grant lets us read their YouTube data (build the feed).
The full write scope is a superset, so it implies read."""
tok = user.token
if tok is None or not tok.scopes:
return False
granted = tok.scopes.split()
return READ_SCOPE in granted or WRITE_SCOPE in granted
def has_write_scope(user: User) -> bool:
"""Whether the user's stored grant includes YouTube write (unsubscribe / export)."""
tok = user.token
@ -91,7 +108,7 @@ async def login(request: Request):
access_type="offline",
prompt="select_account",
include_granted_scopes="true",
scope=READ_SCOPES,
scope=BASE_SCOPES,
)
@ -145,7 +162,9 @@ async def callback(
tok.expiry = (
datetime.fromtimestamp(expires_at, tz=timezone.utc) if expires_at else None
)
tok.scopes = token.get("scope") or READ_SCOPES
# include_granted_scopes=true means Google returns the union of all scopes the user
# has ever granted this app, so this correctly reflects read/write upgrades too.
tok.scopes = token.get("scope") or BASE_SCOPES
db.add(tok)
db.commit()
@ -193,16 +212,21 @@ def current_user(request: Request, db: Session = Depends(get_db)) -> User:
@router.get("/upgrade")
async def upgrade(request: Request, user: User = Depends(current_user)):
"""Incremental consent: re-authorize with the full YouTube (write) scope. The shared
callback stores the new scope set, so `can_write` flips on once Google grants it."""
async def upgrade(
request: Request, access: str = "write", user: User = Depends(current_user)
):
"""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."""
scope = READ_SCOPES if access == "read" else WRITE_SCOPES
return await oauth.google.authorize_redirect(
request,
settings.oauth_redirect_url,
access_type="offline",
prompt="consent",
include_granted_scopes="true",
scope=WRITE_SCOPES,
scope=scope,
)