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 01ebdee8bc
commit 580d7f0e0d
6 changed files with 81 additions and 17 deletions

View file

@ -1,5 +1,9 @@
from pydantic import model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
# Shipped placeholder; production must override it. Used by the startup guard below.
_DEFAULT_SECRET_KEY = "change-me-session-key"
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
@ -9,7 +13,7 @@ class Settings(BaseSettings):
database_url: str = "postgresql+psycopg://subfeed:subfeed@db:5432/subfeed"
# Session cookie signing key.
secret_key: str = "change-me-session-key"
secret_key: str = _DEFAULT_SECRET_KEY
# Fernet key (urlsafe base64, 32 bytes) for encrypting stored refresh tokens.
token_encryption_key: str = ""
@ -77,5 +81,32 @@ class Settings(BaseSettings):
def admin_email_set(self) -> set[str]:
return {e.strip().lower() for e in self.admin_emails.split(",") if e.strip()}
@property
def session_https_only(self) -> bool:
"""Mark the session cookie Secure when we're served over HTTPS. We treat an
https OAUTH_REDIRECT_URL as the signal for 'real deployment' vs local dev."""
return self.oauth_redirect_url.lower().startswith("https://")
@model_validator(mode="after")
def _enforce_production_secrets(self) -> "Settings":
"""Fail fast rather than boot a public instance with the shipped placeholder
signing key (which would let anyone forge an admin session) or without token
encryption. Local dev (http redirect URL) keeps the convenient defaults."""
if not self.session_https_only:
return self # local/dev: allow the placeholder defaults
if self.secret_key == _DEFAULT_SECRET_KEY or len(self.secret_key) < 32:
raise ValueError(
"SECRET_KEY must be a unique random value of at least 32 chars in "
"production (an https OAUTH_REDIRECT_URL was detected). Generate one "
'with: python -c "import secrets; print(secrets.token_urlsafe(48))"'
)
if not self.token_encryption_key:
raise ValueError(
"TOKEN_ENCRYPTION_KEY must be set in production so refresh tokens are "
"encrypted at rest. Generate one with: "
'python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"'
)
return self
settings = Settings()