about things
1# auth
2
3atproto uses OAuth 2.0 for application authorization.
4
5## the flow
6
71. user visits application
82. application redirects to user's PDS for authorization
93. user approves requested scopes
104. PDS redirects back with authorization code
115. application exchanges code for tokens
126. application uses tokens to act on user's behalf
13
14standard OAuth, but the authorization server is the user's PDS, not a central service.
15
16## scopes
17
18scopes define what an application can do:
19
20```
21atproto # full access (legacy)
22repo:fm.plyr.track # read/write fm.plyr.track collection
23repo:fm.plyr.like # read/write fm.plyr.like collection
24repo:read # read-only access to repo
25```
26
27granular scopes let users grant minimal permissions. an app that only needs to read your profile shouldn't have write access to your posts.
28
29## permission sets
30
31listing individual scopes is noisy. permission sets bundle them under human-readable names:
32
33```
34include:fm.plyr.authFullApp # "plyr.fm Music Library"
35```
36
37instead of seeing `fm.plyr.track, fm.plyr.like, fm.plyr.comment, ...`, users see a single permission with a description.
38
39permission sets are lexicons published to `com.atproto.lexicon.schema` on your authority repo.
40
41from [plyr.fm permission sets](https://github.com/zzstoatzz/plyr.fm/blob/main/docs/lexicons/overview.md#permission-sets)
42
43## session management
44
45tokens expire. applications need refresh logic:
46
47```python
48class SessionManager:
49 def __init__(self, session_path: Path):
50 self.session_path = session_path
51 self._client: AsyncClient | None = None
52
53 async def get_client(self) -> AsyncClient:
54 if self._client:
55 return self._client
56
57 # try loading saved session
58 if self.session_path.exists():
59 session_str = self.session_path.read_text()
60 self._client = AsyncClient()
61 await self._client.login(session_string=session_str)
62 self._client.on_session_change(self._save_session)
63 return self._client
64
65 # fall back to fresh login
66 self._client = AsyncClient()
67 await self._client.login(handle, password)
68 self._save_session(None, None)
69 return self._client
70
71 def _save_session(self, event, session):
72 self.session_path.write_text(self._client.export_session_string())
73```
74
75from [bot](https://github.com/zzstoatzz/bot) - persists sessions to disk, refreshes automatically.
76
77## per-request credentials
78
79for multi-tenant applications (one backend serving many users), credentials come per-request:
80
81```python
82# middleware extracts from headers
83x-atproto-handle: user.handle
84x-atproto-password: app-password
85
86# or from OAuth session
87authorization: Bearer <token>
88```
89
90from [pdsx MCP server](https://github.com/zzstoatzz/pdsx) - accepts credentials via HTTP headers for multi-tenant deployment.
91
92## app passwords
93
94for bots and automated tools, app passwords are simpler than full OAuth:
95
961. user creates app password in their PDS settings
972. bot uses handle + app password to authenticate
983. no redirect flow needed
99
100app passwords have full account access. use OAuth with scopes when you need granular permissions.
101
102## why this matters
103
104OAuth at the protocol level means:
105
106- users authorize apps, not the other way around
107- applications can't lock in users by controlling auth
108- the same identity works across all atmospheric applications
109- granular scopes enable minimal-permission applications