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
01ebdee8bc
commit
580d7f0e0d
6 changed files with 81 additions and 17 deletions
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue