research: ATProto OAuth permission sets#

date: 2026-01-01 question: how do ATProto OAuth permission sets work, and how could plyr.fm adopt them?

summary#

ATProto permission sets are lexicon schemas (type: "permission-set") that bundle OAuth permissions under human-readable titles. they're published to com.atproto.lexicon.schema in an authority's ATProto repo and resolved by the PDS during OAuth authorization. plyr.fm currently uses granular repo: scopes directly; adopting permission sets would provide better UX and enable per-feature authorization (e.g., separate scopes for developer tokens).

findings#

how permission sets work#

permission sets are lexicon documents with type: "permission-set" in defs.main. they're published to the com.atproto.lexicon.schema collection in an ATProto repo and resolved via the NSID's authority domain.

resolution flow:

  1. app requests include:fm.plyr.authBasicFeatures?aud=did:web:api.plyr.fm%23svc_appview in OAuth scope
  2. PDS extracts NSID fm.plyr.authBasicFeatures
  3. reverses authority: fm.plyrplyr.fm
  4. resolves plyr.fm to a DID via DNS TXT record
  5. fetches lexicon from that DID's repo at com.atproto.lexicon.schema/fm.plyr.authBasicFeatures
  6. displays title and permissions to user in authorization UI

real example from Bailey Townsend's repo (did:plc:rnpkyqnmsw4ipey6eotbdnnf on selfhosted.social):

{
  "id": "dev.baileytownsend.demo.authBasicFeatures",
  "lexicon": 1,
  "$type": "com.atproto.lexicon.schema",
  "defs": {
    "main": {
      "type": "permission-set",
      "title": "Basic App Functionality",
      "description": "An example simple permission set",
      "permissions": [
        {
          "type": "permission",
          "resource": "repo",
          "action": ["create"],
          "collection": ["dev.baileytownsend.demo.example"]
        }
      ]
    }
  }
}

plyr.fm's current OAuth implementation#

plyr.fm uses a custom fork of the atproto SDK (git+https://github.com/zzstoatzz/atproto@main) with OAuth 2.1 support.

current scope construction (backend/src/backend/config.py:420-441):

@computed_field
@property
def resolved_scope(self) -> str:
    scopes = [
        f"repo:{self.track_collection}",      # repo:fm.plyr.track
        f"repo:{self.like_collection}",       # repo:fm.plyr.like
        f"repo:{self.comment_collection}",    # repo:fm.plyr.comment
        f"repo:{self.list_collection}",       # repo:fm.plyr.list
        f"repo:{self.profile_collection}",    # repo:fm.plyr.actor.profile
    ]
    return f"atproto {' '.join(scopes)}"

optional teal.fm scopes (config.py:443-452):

def resolved_scope_with_teal(self, teal_play: str, teal_status: str) -> str:
    base = self.resolved_scope
    teal_scopes = [f"repo:{teal_play}", f"repo:{teal_status}"]
    return f"{base} {' '.join(teal_scopes)}"

resulting scope string:

atproto repo:fm.plyr.track repo:fm.plyr.like repo:fm.plyr.comment repo:fm.plyr.list repo:fm.plyr.actor.profile

developer tokens#

developer tokens are independent OAuth sessions for API/CLI access (backend/src/backend/api/auth.py:333-374).

key differences from regular sessions:

  • separate OAuth grant with independent refresh tokens
  • configurable expiration (default 90 days, max 365)
  • stored with is_developer_token=True flag
  • don't set browser cookies on exchange

current behavior: dev tokens request the same scopes as regular sessions. with permission sets, we could:

  1. define fm.plyr.authFullApp for browser sessions (all collections)
  2. define fm.plyr.authDeveloper for dev tokens (possibly read-heavy, limited write)
  3. define fm.plyr.authReadOnly for third-party apps (read-only access)

namespace constraints#

permission sets can only reference resources in the same NSID namespace. fm.plyr.authBasicFeatures can only grant permissions to fm.plyr.* collections.

this means:

  • teal.fm scopes (fm.teal.alpha.*) cannot be bundled in our permission sets
  • we'd still need to request teal scopes separately: include:fm.plyr.authBasicFeatures repo:fm.teal.alpha.feed.play repo:fm.teal.alpha.actor.status

publishing permission sets#

to publish a permission set, write it to com.atproto.lexicon.schema collection:

# pseudocode
await client.com.atproto.repo.putRecord(
    repo=our_did,
    collection="com.atproto.lexicon.schema",
    rkey="fm.plyr.authBasicFeatures",
    record={
        "$type": "com.atproto.lexicon.schema",
        "lexicon": 1,
        "id": "fm.plyr.authBasicFeatures",
        "defs": {
            "main": {
                "type": "permission-set",
                "title": "plyr.fm Music Library",
                "description": "Create and manage your music library",
                "permissions": [...]
            }
        }
    }
)

DNS requirement: lexicon resolution uses _lexicon prefix (distinct from _atproto for handles):

  • _lexicon.plyr.fmdid=did:plc:vs3hnzq2daqbszxlysywzy54 (production)
  • _lexicon.stg.plyr.fmdid=did:plc:vs3hnzq2daqbszxlysywzy54 (staging)

official bluesky permission sets#

bluesky defines several permission sets in their lexicons (lexicons/app/bsky/):

  • app.bsky.authFullApp - full Bluesky app permissions
  • app.bsky.authCreatePosts - create posts only (no update/delete)
  • app.bsky.authViewAll - read-only access
  • app.bsky.authManageProfile - profile management only
  • app.bsky.authManageNotifications - notification management

these demonstrate the pattern of offering tiered permission levels.

code references#

  • backend/src/backend/config.py:420-452 - current scope construction
  • backend/src/backend/_internal/auth.py:165-194 - OAuth client creation with scopes
  • backend/src/backend/api/auth.py:333-374 - developer token flow
  • backend/src/backend/models/session.py - session model with is_developer_token flag
  • docs/lexicons/overview.md - current lexicon documentation
  • docs/authentication.md - OAuth flow documentation

permission set for plyr.fm#

fm.plyr.authFullApp#

full access for the main web app:

{
  "permissions": [
    {
      "type": "permission",
      "resource": "repo",
      "action": ["create", "update", "delete"],
      "collection": [
        "fm.plyr.track",
        "fm.plyr.like",
        "fm.plyr.comment",
        "fm.plyr.list",
        "fm.plyr.actor.profile"
      ]
    }
  ]
}

additional permission sets (e.g., listener-only, read-only) can be added when there's a concrete use case.

resolved questions#

  1. DNS setup: lexicon resolution requires _lexicon TXT records (not _atproto which is for handles):

    • production: _lexicon.plyr.fmdid=did:plc:vs3hnzq2daqbszxlysywzy54
    • staging: _lexicon.stg.plyr.fmdid=did:plc:vs3hnzq2daqbszxlysywzy54
  2. which repo?: the plyr.fm account (did:plc:vs3hnzq2daqbszxlysywzy54) on bsky.network - just publish to com.atproto.lexicon.schema collection

  3. SDK support: the SDK fork at zzstoatzz/atproto just passes scope strings to the PDS - permission set resolution is server-side. any PDS supporting OAuth 2.1 should resolve include: scopes.

open questions#

  1. teal.fm integration: since teal scopes can't be in our permission sets (different namespace), keep as granular repo: scopes for teal

  2. developer token differentiation: should dev tokens get different permission sets than browser sessions?

next steps#

  1. draft permission set lexicons in /lexicons/ as JSON files
  2. publish to com.atproto.lexicon.schema collection on the plyr.fm account
  3. update OAuth client to use include:fm.plyr.authFullApp scope
  4. test with staging environment first