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 """OAuth 2.1 authentication and session management.""" 2 3 import json 4 import secrets 5 from dataclasses import dataclass 6 from datetime import UTC, datetime, timedelta ··· 16 from backend.config import settings 17 from backend.models import ExchangeToken, UserSession 18 from backend.utilities.database import db_session 19 20 21 @dataclass ··· 286 checks cookie first (for browser requests), then falls back to Authorization 287 header (for SDK/CLI clients). this enables secure HttpOnly cookies for browsers 288 while maintaining bearer token support for API clients. 289 """ 290 session_id_value = None 291 ··· 305 raise HTTPException( 306 status_code=401, 307 detail="invalid or expired session", 308 ) 309 310 return session
··· 1 """OAuth 2.1 authentication and session management.""" 2 3 import json 4 + import logging 5 import secrets 6 from dataclasses import dataclass 7 from datetime import UTC, datetime, timedelta ··· 17 from backend.config import settings 18 from backend.models import ExchangeToken, UserSession 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 50 51 52 @dataclass ··· 317 checks cookie first (for browser requests), then falls back to Authorization 318 header (for SDK/CLI clients). this enables secure HttpOnly cookies for browsers 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. 323 """ 324 session_id_value = None 325 ··· 339 raise HTTPException( 340 status_code=401, 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", 356 ) 357 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 // auth state management using Svelte 5 runes 2 import { browser } from '$app/environment'; 3 import { API_URL } from '$lib/config'; 4 import type { User } from '$lib/types'; 5 6 export interface AuthState { 7 user: User | null; 8 isAuthenticated: boolean; 9 loading: boolean; 10 } 11 12 class AuthManager { 13 user = $state<User | null>(null); 14 isAuthenticated = $state(false); 15 loading = $state(true); 16 17 async initialize(): Promise<void> { 18 if (!browser) { ··· 28 if (response.ok) { 29 this.user = await response.json(); 30 this.isAuthenticated = true; 31 } else { 32 this.clearSession(); 33 }
··· 1 // auth state management using Svelte 5 runes 2 import { browser } from '$app/environment'; 3 import { API_URL } from '$lib/config'; 4 + import { toast } from '$lib/toast.svelte'; 5 import type { User } from '$lib/types'; 6 7 export interface AuthState { 8 user: User | null; 9 isAuthenticated: boolean; 10 loading: boolean; 11 + scopeUpgradeRequired: boolean; 12 } 13 14 class AuthManager { 15 user = $state<User | null>(null); 16 isAuthenticated = $state(false); 17 loading = $state(true); 18 + scopeUpgradeRequired = $state(false); 19 20 async initialize(): Promise<void> { 21 if (!browser) { ··· 31 if (response.ok) { 32 this.user = await response.json(); 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 + } 49 } else { 50 this.clearSession(); 51 }
+19 -1
frontend/src/lib/components/Toast.svelte
··· 22 <span class="toast-icon" aria-hidden="true"> 23 {icons[item.type]} 24 </span> 25 - <span class="toast-message">{item.message}</span> 26 </div> 27 {/each} 28 </div> ··· 69 word-wrap: break-word; 70 overflow-wrap: break-word; 71 hyphens: auto; 72 } 73 74 .toast-success .toast-icon {
··· 22 <span class="toast-icon" aria-hidden="true"> 23 {icons[item.type]} 24 </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> 33 </div> 34 {/each} 35 </div> ··· 76 word-wrap: break-word; 77 overflow-wrap: break-word; 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; 90 } 91 92 .toast-success .toast-icon {
+11 -4
frontend/src/lib/toast.svelte.ts
··· 2 3 export type ToastType = 'success' | 'error' | 'info' | 'warning'; 4 5 export interface Toast { 6 id: string; 7 message: string; 8 type: ToastType; 9 duration: number; 10 dismissible: boolean; 11 } 12 13 class ToastState { 14 toasts = $state<Toast[]>([]); 15 16 - add(message: string, type: ToastType = 'info', duration = 3000): string { 17 const id = crypto.randomUUID(); 18 const toast: Toast = { 19 id, 20 message, 21 type, 22 duration, 23 - dismissible: true 24 }; 25 26 this.toasts = [toast, ...this.toasts]; ··· 54 return this.add(message, 'error', duration); 55 } 56 57 - info(message: string, duration = 3000): string { 58 - return this.add(message, 'info', duration); 59 } 60 61 warning(message: string, duration = 4000): string {
··· 2 3 export type ToastType = 'success' | 'error' | 'info' | 'warning'; 4 5 + export interface ToastAction { 6 + label: string; 7 + href: string; 8 + } 9 + 10 export interface Toast { 11 id: string; 12 message: string; 13 type: ToastType; 14 duration: number; 15 dismissible: boolean; 16 + action?: ToastAction; 17 } 18 19 class ToastState { 20 toasts = $state<Toast[]>([]); 21 22 + add(message: string, type: ToastType = 'info', duration = 3000, action?: ToastAction): string { 23 const id = crypto.randomUUID(); 24 const toast: Toast = { 25 id, 26 message, 27 type, 28 duration, 29 + dismissible: true, 30 + action 31 }; 32 33 this.toasts = [toast, ...this.toasts]; ··· 61 return this.add(message, 'error', duration); 62 } 63 64 + info(message: string, duration = 3000, action?: ToastAction): string { 65 + return this.add(message, 'info', duration, action); 66 } 67 68 warning(message: string, duration = 4000): string {