audio streaming app plyr.fm

fix: handle include: scope expansion in check_scope_coverage (#957)

PDS servers expand include:ns.permSet into granular repo:/rpc: scopes,
so the granted scope never contains the literal include: token. Check
namespace authority instead of exact string match.

This was causing 403 scope_upgrade_required for all sessions on staging
where resolved_scope uses permission sets (include:fm.plyr.stg.authFullApp).

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

authored by zzstoatzz.io

Claude Opus 4.6 and committed by
GitHub
85a125da b84b1f21

+51 -2
+32 -2
backend/src/backend/_internal/auth/scopes.py
··· 1 1 """OAuth scope parsing and validation using atproto_oauth.scopes.""" 2 2 3 - from atproto_oauth.scopes import RepoPermission, ScopesSet 3 + from atproto_oauth.scopes import IncludeScope, RepoPermission, ScopesSet 4 + 5 + 6 + def _include_covered_by_granted(inc: IncludeScope, granted: ScopesSet) -> bool: 7 + """check if an include: scope is covered by the granted set. 8 + 9 + PDS servers expand ``include:ns.permSet`` into granular ``repo:``/``rpc:`` 10 + scopes, so the granted set will never contain the literal ``include:`` token. 11 + instead, check that the granted set has at least one repo scope whose 12 + collection is in the same namespace authority as the include scope. 13 + """ 14 + for scope_str in granted: 15 + if (rp := RepoPermission.from_string(scope_str)) is not None: 16 + if any(inc.is_parent_authority_of(c) for c in rp.collection if c != "*"): 17 + return True 18 + return False 4 19 5 20 6 21 def check_scope_coverage(granted_scope: str, required_scope: str) -> bool: ··· 29 44 return False 30 45 continue 31 46 32 - # for other scopes (blob, include, transition, etc.), check exact presence 47 + # include: scopes get expanded by the PDS into repo:/rpc: scopes, 48 + # so the granted set won't contain the literal include: token. 49 + # check namespace authority instead. 50 + if token.startswith("include:"): 51 + if (inc := IncludeScope.from_string(token)) is not None: 52 + if _include_covered_by_granted(inc, granted): 53 + continue 54 + return False 55 + 56 + # for other scopes (blob, transition, etc.), check exact presence 33 57 if not granted.has(token): 34 58 return False 35 59 ··· 51 75 for action in req.action: 52 76 if not granted.matches("repo", collection=coll, action=action): 53 77 missing.add(token) 78 + continue 79 + 80 + if token.startswith("include:"): 81 + if (inc := IncludeScope.from_string(token)) is not None: 82 + if not _include_covered_by_granted(inc, granted): 83 + missing.add(token) 54 84 continue 55 85 56 86 if not granted.has(token):
+19
backend/tests/test_scope_validation.py
··· 55 55 required = "atproto blob:*/*" 56 56 assert check_scope_coverage(granted, required) is True 57 57 58 + def test_include_covered_by_expanded_repo_scopes(self): 59 + """PDS expands include: into repo: scopes — should still pass.""" 60 + granted = "atproto blob:*/* repo:fm.plyr.stg.track repo:fm.plyr.stg.like repo:fm.plyr.stg.comment" 61 + required = "atproto blob:*/* include:fm.plyr.stg.authFullApp" 62 + assert check_scope_coverage(granted, required) is True 63 + 64 + def test_include_not_covered_by_wrong_namespace(self): 65 + """include: should fail if granted scopes are from a different namespace.""" 66 + granted = "atproto blob:*/* repo:fm.other.track" 67 + required = "atproto blob:*/* include:fm.plyr.stg.authFullApp" 68 + assert check_scope_coverage(granted, required) is False 69 + 70 + def test_include_missing_shows_in_get_missing(self): 71 + """get_missing_scopes should report include: as missing when not covered.""" 72 + granted = "atproto blob:*/* repo:fm.other.track" 73 + required = "atproto blob:*/* include:fm.plyr.stg.authFullApp" 74 + result = get_missing_scopes(granted, required) 75 + assert result == {"include:fm.plyr.stg.authFullApp"} 76 + 58 77 59 78 class TestGetMissingScopes: 60 79 """tests for get_missing_scopes."""