fix: detect stale OAuth scopes and prompt re-login (#361)

* fix: detect stale OAuth scopes and prompt re-login

when new features require additional scopes (like comments), sessions
with old scopes now get a friendly prompt to log in again instead of
cryptic errors.

backend:
- add scope validation helpers to parse and compare OAuth scopes
- require_auth now checks if session has all required scopes
- returns 403 with "scope_upgrade_required" when scopes are stale

frontend:
- detect scope_upgrade_required in auth initialization
- show friendly toast: "we added new features! log in again to use them"
- clears stale session so user sees logged-out state

includes 12 unit tests for scope validation logic.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: improve scope change message and add release link

- neutral wording: "permissions have changed" (not "new capabilities")
- adds clickable "see changes" link to latest release
- extends toast system to support action links

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>

authored by zzstoatzz.io Claude and committed by GitHub 226a2b08 7ece78a8

Changed files
+192 -5
backend
src
backend
_internal
tests
frontend
+48
backend/src/backend/_internal/auth.py
··· 1 1 """OAuth 2.1 authentication and session management.""" 2 2 3 3 import json 4 + import logging 4 5 import secrets 5 6 from dataclasses import dataclass 6 7 from datetime import UTC, datetime, timedelta ··· 16 17 from backend.config import settings 17 18 from backend.models import ExchangeToken, UserSession 18 19 from backend.utilities.database import db_session 20 + 21 + logger = logging.getLogger(__name__) 22 + 23 + 24 + def _parse_scopes(scope_string: str) -> set[str]: 25 + """parse an OAuth scope string into a set of individual scopes. 26 + 27 + handles format like: "atproto repo:fm.plyr.track repo:fm.plyr.like" 28 + returns: {"repo:fm.plyr.track", "repo:fm.plyr.like"} 29 + """ 30 + parts = scope_string.split() 31 + # filter out the "atproto" prefix and keep just the repo: scopes 32 + return {p for p in parts if p.startswith("repo:")} 33 + 34 + 35 + def _check_scope_coverage(granted_scope: str, required_scope: str) -> bool: 36 + """check if granted scope covers all required scopes. 37 + 38 + returns True if the session has all required permissions. 39 + """ 40 + granted = _parse_scopes(granted_scope) 41 + required = _parse_scopes(required_scope) 42 + return required.issubset(granted) 43 + 44 + 45 + def _get_missing_scopes(granted_scope: str, required_scope: str) -> set[str]: 46 + """get the scopes that are required but not granted.""" 47 + granted = _parse_scopes(granted_scope) 48 + required = _parse_scopes(required_scope) 49 + return required - granted 19 50 20 51 21 52 @dataclass ··· 286 317 checks cookie first (for browser requests), then falls back to Authorization 287 318 header (for SDK/CLI clients). this enables secure HttpOnly cookies for browsers 288 319 while maintaining bearer token support for API clients. 320 + 321 + also validates that the session's granted scopes cover all currently required 322 + scopes. if not, returns 403 with "scope_upgrade_required" to prompt re-login. 289 323 """ 290 324 session_id_value = None 291 325 ··· 305 339 raise HTTPException( 306 340 status_code=401, 307 341 detail="invalid or expired session", 342 + ) 343 + 344 + # check if session has all required scopes 345 + granted_scope = session.oauth_session.get("scope", "") 346 + required_scope = settings.atproto.resolved_scope 347 + 348 + if not _check_scope_coverage(granted_scope, required_scope): 349 + missing = _get_missing_scopes(granted_scope, required_scope) 350 + logger.info( 351 + f"session {session.did} missing scopes: {missing}, prompting re-auth" 352 + ) 353 + raise HTTPException( 354 + status_code=403, 355 + detail="scope_upgrade_required", 308 356 ) 309 357 310 358 return session
+96
backend/tests/test_scope_validation.py
··· 1 + """tests for OAuth scope validation functions.""" 2 + 3 + from backend._internal.auth import ( 4 + _check_scope_coverage, 5 + _get_missing_scopes, 6 + _parse_scopes, 7 + ) 8 + 9 + 10 + class TestParseScopes: 11 + """tests for _parse_scopes.""" 12 + 13 + def test_parses_standard_scope_string(self): 14 + """should extract repo: scopes from atproto scope string.""" 15 + scope = "atproto repo:fm.plyr.track repo:fm.plyr.like" 16 + result = _parse_scopes(scope) 17 + assert result == {"repo:fm.plyr.track", "repo:fm.plyr.like"} 18 + 19 + def test_ignores_atproto_prefix(self): 20 + """should not include 'atproto' in parsed scopes.""" 21 + scope = "atproto repo:fm.plyr.track" 22 + result = _parse_scopes(scope) 23 + assert "atproto" not in result 24 + assert result == {"repo:fm.plyr.track"} 25 + 26 + def test_handles_empty_string(self): 27 + """should return empty set for empty scope.""" 28 + result = _parse_scopes("") 29 + assert result == set() 30 + 31 + def test_handles_multiple_scopes(self): 32 + """should handle three or more scopes.""" 33 + scope = "atproto repo:fm.plyr.track repo:fm.plyr.like repo:fm.plyr.comment" 34 + result = _parse_scopes(scope) 35 + assert result == { 36 + "repo:fm.plyr.track", 37 + "repo:fm.plyr.like", 38 + "repo:fm.plyr.comment", 39 + } 40 + 41 + 42 + class TestCheckScopeCoverage: 43 + """tests for _check_scope_coverage.""" 44 + 45 + def test_returns_true_when_all_scopes_granted(self): 46 + """should return True when granted scope covers all required.""" 47 + granted = "atproto repo:fm.plyr.track repo:fm.plyr.like repo:fm.plyr.comment" 48 + required = "atproto repo:fm.plyr.track repo:fm.plyr.like repo:fm.plyr.comment" 49 + assert _check_scope_coverage(granted, required) is True 50 + 51 + def test_returns_true_when_extra_scopes_granted(self): 52 + """should return True when granted has more than required.""" 53 + granted = "atproto repo:fm.plyr.track repo:fm.plyr.like repo:fm.plyr.comment repo:fm.plyr.extra" 54 + required = "atproto repo:fm.plyr.track repo:fm.plyr.like" 55 + assert _check_scope_coverage(granted, required) is True 56 + 57 + def test_returns_false_when_missing_scope(self): 58 + """should return False when granted is missing required scope.""" 59 + granted = "atproto repo:fm.plyr.track repo:fm.plyr.like" 60 + required = "atproto repo:fm.plyr.track repo:fm.plyr.like repo:fm.plyr.comment" 61 + assert _check_scope_coverage(granted, required) is False 62 + 63 + def test_returns_false_when_granted_empty(self): 64 + """should return False when granted scope is empty.""" 65 + granted = "" 66 + required = "atproto repo:fm.plyr.track" 67 + assert _check_scope_coverage(granted, required) is False 68 + 69 + def test_returns_true_when_both_empty(self): 70 + """should return True when both are empty.""" 71 + assert _check_scope_coverage("", "") is True 72 + 73 + 74 + class TestGetMissingScopes: 75 + """tests for _get_missing_scopes.""" 76 + 77 + def test_returns_empty_when_all_covered(self): 78 + """should return empty set when all scopes are granted.""" 79 + granted = "atproto repo:fm.plyr.track repo:fm.plyr.like repo:fm.plyr.comment" 80 + required = "atproto repo:fm.plyr.track repo:fm.plyr.like" 81 + result = _get_missing_scopes(granted, required) 82 + assert result == set() 83 + 84 + def test_returns_missing_scope(self): 85 + """should return the missing scope.""" 86 + granted = "atproto repo:fm.plyr.track repo:fm.plyr.like" 87 + required = "atproto repo:fm.plyr.track repo:fm.plyr.like repo:fm.plyr.comment" 88 + result = _get_missing_scopes(granted, required) 89 + assert result == {"repo:fm.plyr.comment"} 90 + 91 + def test_returns_multiple_missing_scopes(self): 92 + """should return all missing scopes.""" 93 + granted = "atproto repo:fm.plyr.track" 94 + required = "atproto repo:fm.plyr.track repo:fm.plyr.like repo:fm.plyr.comment" 95 + result = _get_missing_scopes(granted, required) 96 + assert result == {"repo:fm.plyr.like", "repo:fm.plyr.comment"}
+18
frontend/src/lib/auth.svelte.ts
··· 1 1 // auth state management using Svelte 5 runes 2 2 import { browser } from '$app/environment'; 3 3 import { API_URL } from '$lib/config'; 4 + import { toast } from '$lib/toast.svelte'; 4 5 import type { User } from '$lib/types'; 5 6 6 7 export interface AuthState { 7 8 user: User | null; 8 9 isAuthenticated: boolean; 9 10 loading: boolean; 11 + scopeUpgradeRequired: boolean; 10 12 } 11 13 12 14 class AuthManager { 13 15 user = $state<User | null>(null); 14 16 isAuthenticated = $state(false); 15 17 loading = $state(true); 18 + scopeUpgradeRequired = $state(false); 16 19 17 20 async initialize(): Promise<void> { 18 21 if (!browser) { ··· 28 31 if (response.ok) { 29 32 this.user = await response.json(); 30 33 this.isAuthenticated = true; 34 + this.scopeUpgradeRequired = false; 35 + } else if (response.status === 403) { 36 + // check if this is a scope upgrade requirement 37 + const data = await response.json().catch(() => ({})); 38 + if (data.detail === 'scope_upgrade_required') { 39 + this.scopeUpgradeRequired = true; 40 + this.clearSession(); 41 + toast.info( 42 + "plyr.fm's permissions have changed since you logged in. please log in again", 43 + 5000, 44 + { label: 'see changes', href: 'https://github.com/zzstoatzz/plyr.fm/releases/latest' } 45 + ); 46 + } else { 47 + this.clearSession(); 48 + } 31 49 } else { 32 50 this.clearSession(); 33 51 }
+19 -1
frontend/src/lib/components/Toast.svelte
··· 22 22 <span class="toast-icon" aria-hidden="true"> 23 23 {icons[item.type]} 24 24 </span> 25 - <span class="toast-message">{item.message}</span> 25 + <span class="toast-message"> 26 + {item.message} 27 + {#if item.action} 28 + <a href={item.action.href} target="_blank" rel="noopener noreferrer" class="toast-action"> 29 + {item.action.label} 30 + </a> 31 + {/if} 32 + </span> 26 33 </div> 27 34 {/each} 28 35 </div> ··· 69 76 word-wrap: break-word; 70 77 overflow-wrap: break-word; 71 78 hyphens: auto; 79 + } 80 + 81 + .toast-action { 82 + color: var(--accent); 83 + text-decoration: underline; 84 + pointer-events: auto; 85 + margin-left: 0.5rem; 86 + } 87 + 88 + .toast-action:hover { 89 + opacity: 0.8; 72 90 } 73 91 74 92 .toast-success .toast-icon {
+11 -4
frontend/src/lib/toast.svelte.ts
··· 2 2 3 3 export type ToastType = 'success' | 'error' | 'info' | 'warning'; 4 4 5 + export interface ToastAction { 6 + label: string; 7 + href: string; 8 + } 9 + 5 10 export interface Toast { 6 11 id: string; 7 12 message: string; 8 13 type: ToastType; 9 14 duration: number; 10 15 dismissible: boolean; 16 + action?: ToastAction; 11 17 } 12 18 13 19 class ToastState { 14 20 toasts = $state<Toast[]>([]); 15 21 16 - add(message: string, type: ToastType = 'info', duration = 3000): string { 22 + add(message: string, type: ToastType = 'info', duration = 3000, action?: ToastAction): string { 17 23 const id = crypto.randomUUID(); 18 24 const toast: Toast = { 19 25 id, 20 26 message, 21 27 type, 22 28 duration, 23 - dismissible: true 29 + dismissible: true, 30 + action 24 31 }; 25 32 26 33 this.toasts = [toast, ...this.toasts]; ··· 54 61 return this.add(message, 'error', duration); 55 62 } 56 63 57 - info(message: string, duration = 3000): string { 58 - return this.add(message, 'info', duration); 64 + info(message: string, duration = 3000, action?: ToastAction): string { 65 + return this.add(message, 'info', duration, action); 59 66 } 60 67 61 68 warning(message: string, duration = 4000): string {