music on atproto
plyr.fm
1# multi-account experience
2
3**status:** design draft
4**issue:** [#583](https://github.com/zzstoatzz/plyr.fm/issues/583)
5**date:** 2026-01-03
6
7## problem
8
9users with multiple ATProto identities (personal account, artist alias, band account) cannot easily switch between them. the current flow:
10
111. click logout
122. session destroyed, cookie cleared
133. navigate to login
144. enter new handle
155. redirected to PDS - but PDS auto-approves if "remember this account" was checked
166. no way to force account selection or fresh login
17
18the PDS remembers the client and auto-signs in, making multi-account workflows frustrating.
19
20## ATProto OAuth prompt parameter
21
22the AT Protocol OAuth spec supports a `prompt` parameter with three modes:
23
24| value | behavior |
25|-------|----------|
26| `login` | forces re-authentication, ignoring remembered session |
27| `select_account` | shows account selection instead of auto-selecting |
28| `consent` | forces consent screen even if previously approved |
29
30**prerequisite:** our atproto SDK fork needs to accept `prompt` in `start_authorization()`.
31
32**status:** PR opened ([zzstoatzz/atproto#8](https://github.com/zzstoatzz/atproto/pull/8))
33
34```python
35# new signature
36async def start_authorization(
37 self,
38 handle_or_did: str,
39 prompt: Literal["login", "select_account", "consent", "none"] | None = None
40) -> tuple[str, str]
41```
42
43## design options
44
45### option A: session stack (recommended)
46
47store multiple sessions server-side, switch by rotating which one is "active."
48
49**how it works:**
50
511. user logs in with account A - session created, cookie set
522. user clicks "add account" - redirected with `prompt=login`
533. user logs in with account B - second session created
544. both sessions stored in database, linked by a "session group" or stored as array in encrypted cookie
555. user menu shows both accounts, click to switch active
56
57**session storage approaches:**
58
59| approach | pros | cons |
60|----------|------|------|
61| **encrypted cookie array** | no db schema change, stateless switching | cookie size limits (~4KB), complex encryption |
62| **session groups table** | clean relational model, unlimited accounts | db schema migration, additional queries |
63| **localStorage + session_id** | simple to implement | XSS-vulnerable, breaks HttpOnly security model |
64
65**recommendation:** session groups table - maintains security model, clean data relationships.
66
67**schema sketch:**
68
69```sql
70-- new table: links multiple sessions as a group
71CREATE TABLE session_groups (
72 group_id UUID PRIMARY KEY,
73 created_at TIMESTAMP DEFAULT NOW()
74);
75
76-- modify user_sessions to reference group
77ALTER TABLE user_sessions ADD COLUMN group_id UUID REFERENCES session_groups(group_id);
78ALTER TABLE user_sessions ADD COLUMN is_active BOOLEAN DEFAULT true;
79
80-- index for fast group lookups
81CREATE INDEX idx_sessions_group ON user_sessions(group_id);
82```
83
84**backend changes:**
85
86- `POST /auth/add-account` - starts OAuth with `prompt=login`, links to existing session group
87- `POST /auth/switch-account` - sets `is_active=false` on current, `is_active=true` on target
88- `GET /auth/me` - returns active session info + list of other accounts in group
89- cookie still holds single `session_id` - backend looks up group from it
90
91**frontend changes:**
92
93- user menu shows current account + "add account" option
94- if multiple accounts in group, show account switcher
95- clicking different account calls `/auth/switch-account`
96
97### option B: browser-managed (simpler, less ideal)
98
99don't store multiple sessions - just make switching easier.
100
101**how it works:**
102
1031. "switch account" button triggers OAuth with `prompt=select_account`
1042. current session destroyed before redirect
1053. PDS shows account picker
1064. user picks account, new session created
107
108**pros:** minimal backend changes, no schema migration
109**cons:** loses previous session entirely, can't "quick switch" back
110
111### option C: parallel windows (no code changes)
112
113educate users to use private/incognito windows for different accounts.
114
115**pros:** zero implementation effort
116**cons:** poor UX, not a real solution
117
118## UX flows
119
120### when logged in (single account)
121
122```
123┌─────────────────────────────┐
124│ @artist.bsky.social ▼ │
125├─────────────────────────────┤
126│ ⬚ portal │
127│ ⚙ settings │
128│ ───────────────────────── │
129│ + add account │ ← new
130│ ───────────────────────── │
131│ ⎋ logout │
132└─────────────────────────────┘
133```
134
135### when logged in (multiple accounts)
136
137```
138┌─────────────────────────────┐
139│ @artist.bsky.social ▼ │ ← active account
140├─────────────────────────────┤
141│ ⬚ portal │
142│ ⚙ settings │
143│ ───────────────────────── │
144│ ○ @personal.bsky.social │ ← switch to this
145│ ○ @band.music │ ← switch to this
146│ + add account │
147│ ───────────────────────── │
148│ ⎋ logout │ ← logs out active only
149│ ⎋ logout all │ ← clears entire group
150└─────────────────────────────┘
151```
152
153### logout behavior
154
155**question:** what should "logout" do with multiple accounts?
156
157| option | behavior |
158|--------|----------|
159| **logout active only** | removes current session, auto-switches to next account in group |
160| **logout all** | destroys entire session group, back to login page |
161
162**recommendation:** default to logout active, provide "logout all" as separate option.
163
164### edge cases
165
1661. **session expires for one account** - remove from group, notify if it was active
1672. **scope upgrade needed** - only affects the active session, not others in group
1683. **cross-tab sync** - BroadcastChannel already exists; extend to broadcast account switches
1694. **queue state** - queue is global, not per-account (music keeps playing during switch)
1705. **mobile (ProfileMenu)** - same UX, adapted for touch
171
172## implementation phases
173
174### phase 1: prompt parameter support
175
1761. fork update: add `prompt` param to `start_authorization()`
1772. backend: pass prompt to SDK in `/auth/start`
1783. frontend: "sign in with different account" uses `prompt=login`
179
180**outcome:** users can force re-auth, but still single-session.
181
182### phase 2: session groups
183
1841. database migration for session groups
1852. `/auth/add-account` endpoint
1863. `/auth/switch-account` endpoint
1874. modify `/auth/me` to return account list
1885. frontend account switcher UI
189
190**outcome:** full multi-account experience.
191
192### phase 3: polish
193
1941. account avatars in switcher
1952. keyboard shortcut for quick-switch (Cmd+Shift+A?)
1963. "switch to" option in artist page when viewing own other account
1974. notification badge per account (future)
198
199## security considerations
200
201- **no localStorage for session IDs** - maintains HttpOnly security model
202- **session group isolation** - groups are per-browser, not per-user (different devices = different groups)
203- **cookie still single value** - one active session_id, backend resolves group membership
204- **logout clears cookie regardless** - even with session groups, logout destroys the cookie
205
206## open questions
207
2081. **should we limit accounts per group?** (suggest: 5 max)
2092. **what about developer tokens?** - probably exclude from session groups, they're standalone
2103. **how to handle account picker on login page?** - show known accounts if cookie exists but session expired?
2114. **mobile app (future)** - will need equivalent session group storage in secure keychain
212
213## bluesky implementation study
214
215studied bluesky's open-source client ([social-app](https://github.com/bluesky-social/social-app)) to inform our design.
216
217### their architecture
218
219**persistence layer** (`src/state/persisted/`):
220- uses AsyncStorage with single key `'BSKY_STORAGE'`
221- `Schema` type defines all persisted state
222- `currentAccount` stores only DID (lightweight reference)
223- full account data lives in `accounts[]` array
224
225**session state shape:**
226```typescript
227interface SessionState {
228 accounts: SessionAccount[] // all accounts, even expired ones
229 currentAccount: SessionAccount // active account reference (DID only)
230 hasSession: boolean
231}
232
233interface SessionAccount {
234 did: string
235 handle: string
236 service: string // PDS URL
237 accessJwt?: string // may be empty/expired
238 refreshJwt?: string // may be empty/expired
239 email?: string
240 emailConfirmed?: boolean
241 emailAuthFactor?: boolean
242 pdsUrl?: string
243 active?: boolean
244 status?: 'takendown' | 'suspended' | 'deactivated'
245}
246```
247
248**reducer actions** (`src/state/session/reducer.ts`):
249```typescript
250type Action =
251 | { type: 'switched-to-account'; agent: BskyAgent; did: string }
252 | { type: 'removed-account'; did: string }
253 | { type: 'logged-out-current-account' }
254 | { type: 'logged-out-every-account' }
255 | { type: 'synced-accounts'; accounts: SessionAccount[]; currentAccount?: SessionAccount }
256 | { type: 'received-agent-event'; event: SessionEvent }
257 | { type: 'partial-refresh-session'; patch: Partial<SessionAccount> }
258```
259
260**key files:**
261- `src/state/persisted/schema.ts` - persistence schema with account fields
262- `src/state/session/reducer.ts` - state transitions
263- `src/state/session/agent.ts` - agent creation and token refresh
264- `src/components/dialogs/SwitchAccount.tsx` - switcher UI
265- `src/lib/hooks/useAccountSwitcher.ts` - switching logic
266- `src/components/AccountList.tsx` - account list rendering
267
268### their UX patterns
269
2701. **account list items:**
271 - 48x48 avatar
272 - display name + @handle
273 - green checkmark (not chevron) on current account
274 - "logged out" italic label for expired sessions
275
2762. **switching flow:**
277 - if tokens valid: `resumeSession()` silently
278 - if tokens expired: show login form for that specific account
279 - race condition protection via `pendingDid` state
280
2813. **logout distinction:**
282 - `logoutCurrentAccount`: clears tokens, account stays in list
283 - `logoutEveryAccount`: clears everything, back to login
284
2854. **cross-tab sync:**
286 - `synced-accounts` action handles changes from other tabs
287 - `needsPersist` flag prevents sync cycles
288
289### what we can adopt
290
291| pattern | bluesky | plyr.fm adaptation |
292|---------|---------|-------------------|
293| account list with avatars | 48x48 + name + handle | same, with our design tokens |
294| checkmark on active | green circle-check | use `var(--success)` |
295| expired session label | "logged out" italic | same |
296| logout vs logout-all | two distinct actions | same approach |
297| token-based resume | client-side jwt check | server-side via session group |
298| cross-tab sync | BroadcastChannel | extend existing player sync |
299
300### key difference
301
302bluesky stores tokens client-side (react native app). we store sessions server-side (HttpOnly cookies). our session group approach achieves the same UX with better web security.
303
304### token refresh strategy
305
306bluesky's `createAgentAndResume()` flow:
3071. check if `refreshJwt` exists and is not expired
3082. if valid: restore session, attempt background refresh
3093. if expired: show login form for that account (no silent refresh possible)
310
311for plyr.fm, our server-side sessions handle refresh differently:
312- refresh happens server-side via atproto SDK
313- client never sees tokens
314- "expired" means session row deleted or OAuth refresh failed
315
316### account list UI details
317
318from `AccountList.tsx`:
319- uses `useProfilesQuery` to batch-fetch profile data for all accounts
320- `isJwtExpired()` helper determines "logged out" state
321- `pendingDid` disables interaction during switch (`pointerEvents: 'none'`)
322- profiles fetched by DID array, matched back to accounts
323
324## references
325
326- [ATProto OAuth spec](https://github.com/bluesky-social/atproto/blob/main/packages/oauth/oauth-provider/src/oauth-provider.ts) - prompt parameter handling
327- [bluesky social-app](https://github.com/bluesky-social/social-app) - multi-account reference implementation
328 - [persisted/schema.ts](https://github.com/bluesky-social/social-app/blob/main/src/state/persisted/schema.ts) - account schema
329 - [session/reducer.ts](https://github.com/bluesky-social/social-app/blob/main/src/state/session/reducer.ts) - state machine
330 - [AccountList.tsx](https://github.com/bluesky-social/social-app/blob/main/src/components/AccountList.tsx) - UI component
331- [issue #583](https://github.com/zzstoatzz/plyr.fm/issues/583) - original feature request
332- [PRs #578, #582](https://github.com/zzstoatzz/plyr.fm/pull/578) - confidential OAuth client context
333- [atproto SDK fork](https://github.com/zzstoatzz/atproto) - prompt parameter support (merged)