feat(m5b): optional YouTube write scope via incremental OAuth
Default login now requests read-only (youtube.readonly); write (unsubscribe,
later playlist export) is an explicit opt-in.
- auth.py: split READ_SCOPES / WRITE_SCOPES; new GET /auth/upgrade (incremental
consent, prompt=consent); has_write_scope() helper
- /api/me exposes can_write
- youtube/client.py: delete_subscription (50 units, OAuth-only)
- DELETE /api/channels/{id}/subscription, gated on write scope (403 otherwise)
- UI: Settings - Account 'Playlist editing & YouTube export' enable button;
per-channel 'Unsubscribe on YouTube' (with confirm) shown only when can_write
Browser-facing; develop/test locally until the public HTTPS login lands. Needs a
one-time Console step: add youtube.readonly to the OAuth consent screen scopes.
This commit is contained in:
parent
c90f5d43b4
commit
eb88d793f8
8 changed files with 152 additions and 11 deletions
|
|
@ -11,9 +11,12 @@ from app.db import get_db
|
|||
from app.models import OAuthToken, User
|
||||
from app.security import encrypt
|
||||
|
||||
# Single YouTube scope that allows reading subscriptions/playlists AND writing
|
||||
# (unsubscribe, playlist export). openid/email/profile give us the account identity.
|
||||
SCOPES = "openid email profile https://www.googleapis.com/auth/youtube"
|
||||
# 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.
|
||||
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}"
|
||||
|
||||
log = logging.getLogger("subfeed.auth")
|
||||
|
||||
|
|
@ -25,10 +28,18 @@ 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": SCOPES},
|
||||
client_kwargs={"scope": READ_SCOPES},
|
||||
)
|
||||
|
||||
|
||||
def has_write_scope(user: User) -> bool:
|
||||
"""Whether the user's stored grant includes YouTube write (unsubscribe / export)."""
|
||||
tok = user.token
|
||||
if tok is None or not tok.scopes:
|
||||
return False
|
||||
return WRITE_SCOPE in tok.scopes.split()
|
||||
|
||||
|
||||
@router.get("/login")
|
||||
async def login(request: Request):
|
||||
# access_type=offline ensures a refresh_token on first authorization. We avoid
|
||||
|
|
@ -40,6 +51,7 @@ async def login(request: Request):
|
|||
access_type="offline",
|
||||
prompt="select_account",
|
||||
include_granted_scopes="true",
|
||||
scope=READ_SCOPES,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -80,7 +92,7 @@ async def callback(request: Request, db: Session = Depends(get_db)):
|
|||
tok.expiry = (
|
||||
datetime.fromtimestamp(expires_at, tz=timezone.utc) if expires_at else None
|
||||
)
|
||||
tok.scopes = token.get("scope") or SCOPES
|
||||
tok.scopes = token.get("scope") or READ_SCOPES
|
||||
db.add(tok)
|
||||
db.commit()
|
||||
|
||||
|
|
@ -106,6 +118,20 @@ def current_user(request: Request, db: Session = Depends(get_db)) -> User:
|
|||
return 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."""
|
||||
return await oauth.google.authorize_redirect(
|
||||
request,
|
||||
settings.oauth_redirect_url,
|
||||
access_type="offline",
|
||||
prompt="consent",
|
||||
include_granted_scopes="true",
|
||||
scope=WRITE_SCOPES,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def me(user: User = Depends(current_user)) -> dict:
|
||||
return {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue