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:
parent
9163a5f68e
commit
4765db89de
6 changed files with 81 additions and 17 deletions
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue