feat: secure browser auth with HttpOnly cookies (#244)

* feat: implement cookie-based authentication for browser requests

- Update /auth/exchange to set HttpOnly cookies for browser requests
- Update require_auth to check cookies first, then fall back to Authorization header
- Remove localStorage usage for session_id across frontend
- Update all fetch calls to use credentials: 'include' instead of Authorization headers
- Only set cookies when frontend URL is on .plyr.fm domain (production/staging)
- Maintain bearer token support for SDK/CLI clients

* fix: resolve cookie auth issues from review (#243)

* fix: resolve cookie auth issues from review

fixes from #239 review:
- fix cookie parameter name mismatch (session_id_cookie → session_id with alias)
- add cookie fallback to optional auth endpoints (tracks list, track detail, album detail)
- remove explicit cookie domain to prevent cross-environment leakage
- change SameSite from 'none' to 'lax' (same-site cookies)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: enable cookies for localhost and remove hardcoded domain checks

- set cookies for localhost with secure=False (http)
- set cookies for production domains with secure=True (https)
- remove hardcoded .plyr.fm checks - just use settings.frontend.url

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>

authored by zzstoatzz.io Claude and committed by GitHub b9b095cd af64fa89

+3 -1
README.md
··· 108 108 ## links 109 109 110 110 - **production**: https://plyr.fm 111 - - **backend API**: https://api.plyr.fm 111 + - **production API**: https://api.plyr.fm 112 + - **staging**: https://stg.plyr.fm 113 + - **staging API**: https://api-stg.plyr.fm 112 114 - **repository**: https://github.com/zzstoatzz/plyr.fm 113 115 114 116 ## documentation
+44 -1
docs/backend/atproto-identity.md
··· 1 - # DID PLC and PDS resolution 1 + # ATProto identity and OAuth 2 2 3 3 ## what is DID PLC? 4 4 ··· 22 22 23 23 plyr.fm caches resolved PDS URLs in `artists.pds_url` to avoid repeated lookups. 24 24 25 + ## OAuth client registration 26 + 27 + ATProto OAuth uses **client metadata discovery** - there is no central registry to register with. 28 + 29 + ### how it works 30 + 31 + 1. **client ID is a URL**: your `ATPROTO_CLIENT_ID` must be a publicly accessible HTTPS URL that serves client metadata JSON 32 + 2. **backend serves metadata**: plyr.fm serves this at `/client-metadata.json` on the API domain 33 + 3. **automatic discovery**: when users authenticate, their PDS fetches the client metadata from your client ID URL 34 + 35 + ### configuration per environment 36 + 37 + **production**: 38 + - `ATPROTO_CLIENT_ID=https://api.plyr.fm/client-metadata.json` 39 + - `ATPROTO_REDIRECT_URI=https://api.plyr.fm/auth/callback` 40 + 41 + **staging**: 42 + - `ATPROTO_CLIENT_ID=https://api-stg.plyr.fm/client-metadata.json` 43 + - `ATPROTO_REDIRECT_URI=https://api-stg.plyr.fm/auth/callback` 44 + 45 + **local development**: 46 + - `ATPROTO_CLIENT_ID=http://localhost:8001/client-metadata.json` 47 + - `ATPROTO_REDIRECT_URI=http://localhost:8001/auth/callback` 48 + 49 + ### important notes 50 + 51 + - **no pre-registration needed**: unlike traditional OAuth, you don't register with a central service 52 + - **no client secret**: ATProto OAuth uses PKCE (Proof Key for Code Exchange) instead 53 + - **URL must be publicly accessible**: the client ID URL must be reachable by any PDS on the network 54 + - **metadata is cached**: PDSs may cache your client metadata, so changes can take time to propagate 55 + 56 + ### verifying your setup 57 + 58 + check that your client metadata is accessible: 59 + 60 + ```bash 61 + curl https://api.plyr.fm/client-metadata.json 62 + ``` 63 + 64 + should return JSON with your OAuth configuration including redirect URIs and scopes. 65 + 25 66 ## references 26 67 27 68 - [PLC directory](https://plc.directory) 28 69 - [ATProto identity spec](https://atproto.com/specs/did-plc) 70 + - [ATProto OAuth spec](https://atproto.com/specs/oauth) 71 + - [OAuth client metadata draft](https://datatracker.ietf.org/doc/html/draft-parecki-oauth-client-id-metadata-document)
+42 -13
docs/deployment/environments.md
··· 7 7 | environment | trigger | backend URL | database | frontend | storage | 8 8 |-------------|---------|-------------|----------|----------|---------| 9 9 | **development** | local | localhost:8001 | plyr-dev (neon) | localhost:5173 | audio-dev, images-dev (r2) | 10 - | **staging** | push to main | relay-api-staging.fly.dev | plyr-staging (neon) | cloudflare pages preview (main) | audio-staging, images-staging (r2) | 10 + | **staging** | push to main | api-stg.plyr.fm | plyr-staging (neon) | stg.plyr.fm (main branch) | audio-staging, images-staging (r2) | 11 11 | **production** | github release | api.plyr.fm | plyr-prod (neon) | plyr.fm (production-fe branch) | audio-prod, images-prod (r2) | 12 12 13 13 ## workflow ··· 34 34 **backend**: 35 35 1. github actions runs `.github/workflows/deploy-staging.yml` 36 36 2. runs `alembic upgrade head` via `release_command` 37 - 3. backend available at `https://relay-api-staging.fly.dev` 37 + 3. backend available at `https://api-stg.plyr.fm` (custom domain) and `https://relay-api-staging.fly.dev` (fly.dev domain) 38 38 39 39 **frontend**: 40 - - cloudflare pages automatically creates preview builds from `main` branch 41 - - uses preview environment with `PUBLIC_API_URL=https://relay-api-staging.fly.dev` 40 + - cloudflare pages project `plyr-fm-stg` tracks `main` branch 41 + - uses production environment with `PUBLIC_API_URL=https://api-stg.plyr.fm` 42 + - available at `https://stg.plyr.fm` (custom domain) 42 43 43 44 **testing**: 44 - - backend: `https://relay-api-staging.fly.dev/docs` 45 + - frontend: `https://stg.plyr.fm` 46 + - backend: `https://api-stg.plyr.fm/docs` 45 47 - database: `plyr-staging` (neon) 46 48 - storage: `audio-staging`, `images-staging` (r2) 47 49 ··· 86 88 87 89 ### frontend 88 90 89 - **cloudflare pages**: 91 + **cloudflare pages** (two separate projects): 92 + 93 + **plyr-fm** (production): 90 94 - framework: sveltekit 91 95 - build command: `cd frontend && bun run build` 92 96 - build output: `frontend/build` 93 97 - production branch: `production-fe` 94 - - preview branch: `main` 95 - - environment variables: 96 - - preview: `PUBLIC_API_URL=https://relay-api-staging.fly.dev` 97 - - production: `PUBLIC_API_URL=https://api.plyr.fm` 98 + - production environment: `PUBLIC_API_URL=https://api.plyr.fm` 99 + - custom domain: `plyr.fm` 100 + 101 + **plyr-fm-stg** (staging): 102 + - framework: sveltekit 103 + - build command: `cd frontend && bun run build` 104 + - build output: `frontend/build` 105 + - production branch: `main` 106 + - production environment: `PUBLIC_API_URL=https://api-stg.plyr.fm` 107 + - custom domain: `stg.plyr.fm` 98 108 99 109 ### secrets management 100 110 101 111 all secrets configured via `flyctl secrets set`. key environment variables: 102 112 - `DATABASE_URL` → neon connection string (env-specific) 103 - - `ATPROTO_CLIENT_ID`, `ATPROTO_REDIRECT_URI` → oauth config (env-specific URLs) 113 + - `FRONTEND_URL` → frontend URL for CORS (production: `https://plyr.fm`, staging: `https://stg.plyr.fm`) 114 + - `ATPROTO_CLIENT_ID`, `ATPROTO_REDIRECT_URI` → oauth config (env-specific, must use custom domains for cookie-based auth) 115 + - production: `https://api.plyr.fm/client-metadata.json` and `https://api.plyr.fm/auth/callback` 116 + - staging: `https://api-stg.plyr.fm/client-metadata.json` and `https://api-stg.plyr.fm/auth/callback` 104 117 - `OAUTH_ENCRYPTION_KEY` → unique per environment 105 118 - `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` → r2 credentials 106 119 - `LOGFIRE_WRITE_TOKEN`, `LOGFIRE_ENVIRONMENT` → observability config ··· 144 157 145 158 ## workflow summary 146 159 147 - - **merge PR to main**: deploys staging backend, creates frontend preview 148 - - **run `just release`**: deploys production backend + production frontend together 160 + - **merge PR to main**: deploys staging backend + staging frontend to `stg.plyr.fm` 161 + - **run `just release`**: deploys production backend + production frontend to `plyr.fm` 149 162 - **database migrations**: run automatically before deploy completes 150 163 - **rollback**: revert github release or restore database from neon backup 164 + 165 + ## custom domain architecture 166 + 167 + both environments use custom domains on the same eTLD+1 (`plyr.fm`) to enable secure cookie-based authentication: 168 + 169 + **staging**: 170 + - frontend: `stg.plyr.fm` → cloudflare pages project `plyr-fm-stg` 171 + - backend: `api-stg.plyr.fm` → fly.io app `relay-api-staging` 172 + - same eTLD+1 allows HttpOnly cookies with `Domain=.plyr.fm` 173 + 174 + **production**: 175 + - frontend: `plyr.fm` → cloudflare pages project `plyr-fm` 176 + - backend: `api.plyr.fm` → fly.io app `relay-api` 177 + - same eTLD+1 allows HttpOnly cookies with `Domain=.plyr.fm` 178 + 179 + this architecture prevents XSS attacks by storing session tokens in HttpOnly cookies instead of localStorage.
+1
frontend/.node-version
··· 1 + 20
+8 -38
frontend/src/lib/auth.svelte.ts
··· 20 20 return; 21 21 } 22 22 23 - const sessionId = this.getSessionId(); 24 - if (!sessionId) { 25 - this.loading = false; 26 - return; 27 - } 28 - 29 23 try { 30 24 const response = await fetch(`${API_URL}/auth/me`, { 31 - headers: { 32 - 'Authorization': `Bearer ${sessionId}` 33 - } 25 + credentials: 'include' 34 26 }); 35 27 36 28 if (response.ok) { ··· 47 39 } 48 40 } 49 41 50 - getSessionId(): string | null { 51 - if (!browser) return null; 52 - return localStorage.getItem('session_id'); 53 - } 54 - 55 - setSessionId(sessionId: string): void { 56 - if (!browser) return; 57 - localStorage.setItem('session_id', sessionId); 58 - } 59 - 60 42 clearSession(): void { 61 43 if (!browser) return; 62 - localStorage.removeItem('session_id'); 63 - localStorage.removeItem('exchange_token'); 64 44 this.user = null; 65 45 this.isAuthenticated = false; 66 46 } 67 47 68 48 async logout(): Promise<void> { 69 - const sessionId = this.getSessionId(); 70 - if (sessionId) { 71 - try { 72 - await fetch(`${API_URL}/auth/logout`, { 73 - method: 'POST', 74 - headers: { 75 - 'Authorization': `Bearer ${sessionId}` 76 - } 77 - }); 78 - } catch (e) { 79 - console.error('logout failed:', e); 80 - } 49 + try { 50 + await fetch(`${API_URL}/auth/logout`, { 51 + method: 'POST', 52 + credentials: 'include' 53 + }); 54 + } catch (e) { 55 + console.error('logout failed:', e); 81 56 } 82 57 this.clearSession(); 83 58 } 84 59 85 - // helper to get auth headers 86 60 getAuthHeaders(): Record<string, string> { 87 - const sessionId = this.getSessionId(); 88 - if (sessionId) { 89 - return { 'Authorization': `Bearer ${sessionId}` }; 90 - } 91 61 return {}; 92 62 } 93 63 }
+3 -12
frontend/src/lib/components/BrokenTracks.svelte
··· 16 16 17 17 async function loadBrokenTracks() { 18 18 loading = true; 19 - const sessionId = localStorage.getItem('session_id'); 20 19 try { 21 20 const response = await fetch(`${API_URL}/tracks/me/broken`, { 22 - headers: { 23 - 'Authorization': `Bearer ${sessionId}` 24 - } 21 + credentials: 'include' 25 22 }); 26 23 if (response.ok) { 27 24 const data = await response.json(); ··· 42 39 } 43 40 44 41 restoringTrackId = trackId; 45 - const sessionId = localStorage.getItem('session_id'); 46 42 47 43 try { 48 44 const response = await fetch(`${API_URL}/tracks/${trackId}/restore-record`, { 49 45 method: 'POST', 50 - headers: { 51 - 'Authorization': `Bearer ${sessionId}` 52 - } 46 + credentials: 'include' 53 47 }); 54 48 55 49 if (response.ok) { ··· 86 80 } 87 81 88 82 restoringAll = true; 89 - const sessionId = localStorage.getItem('session_id'); 90 83 const trackCount = brokenTracks.length; 91 84 92 85 try { ··· 94 87 brokenTracks.map(track => 95 88 fetch(`${API_URL}/tracks/${track.id}/restore-record`, { 96 89 method: 'POST', 97 - headers: { 98 - 'Authorization': `Bearer ${sessionId}` 99 - } 90 + credentials: 'include' 100 91 }).then(async response => { 101 92 if (!response.ok) { 102 93 let errorMsg = `failed to restore ${track.title}`;
+3 -7
frontend/src/lib/components/ColorSettings.svelte
··· 17 17 onMount(async () => { 18 18 // try to fetch from backend if authenticated 19 19 try { 20 - const sessionId = localStorage.getItem('session_id'); 21 20 const response = await fetch(`${API_URL}/preferences/`, { 22 - headers: { 23 - 'Authorization': `Bearer ${sessionId}` 24 - } 21 + credentials: 'include' 25 22 }); 26 23 if (response.ok) { 27 24 const data = await response.json(); ··· 61 58 62 59 // save to backend if authenticated 63 60 try { 64 - const sessionId = localStorage.getItem('session_id'); 65 61 await fetch(`${API_URL}/preferences/`, { 66 62 method: 'POST', 67 63 headers: { 68 - 'Content-Type': 'application/json', 69 - 'Authorization': `Bearer ${sessionId}` 64 + 'Content-Type': 'application/json' 70 65 }, 66 + credentials: 'include', 71 67 body: JSON.stringify({ accent_color: color }) 72 68 }); 73 69 } catch (e) {
+5 -22
frontend/src/lib/components/MigrationBanner.svelte
··· 19 19 20 20 // check if migration is needed 21 21 export async function checkMigrationStatus(): Promise<void> { 22 - const sessionId = localStorage.getItem('session_id'); 23 - if (!sessionId) return; 24 - 25 - // check if user already dismissed this 26 - const dismissedKey = `migration_dismissed_${sessionId}`; 22 + // check if user already dismissed this (using a generic key since we don't have session_id) 23 + const dismissedKey = 'migration_dismissed'; 27 24 if (localStorage.getItem(dismissedKey) === 'true') { 28 25 dismissed = true; 29 26 return; ··· 31 28 32 29 try { 33 30 const response = await fetch(`${API_URL}/migration/check`, { 34 - headers: { 35 - 'Authorization': `Bearer ${sessionId}` 36 - } 31 + credentials: 'include' 37 32 }); 38 33 39 34 if (response.ok) { ··· 53 48 migrating = true; 54 49 error = ''; 55 50 56 - const sessionId = localStorage.getItem('session_id'); 57 - if (!sessionId) { 58 - error = 'not authenticated'; 59 - migrating = false; 60 - return; 61 - } 62 - 63 51 try { 64 52 const response = await fetch(`${API_URL}/migration/migrate`, { 65 53 method: 'POST', 66 - headers: { 67 - 'Authorization': `Bearer ${sessionId}` 68 - } 54 + credentials: 'include' 69 55 }); 70 56 71 57 if (response.ok) { ··· 95 81 needsMigration = false; 96 82 97 83 // remember dismissal 98 - const sessionId = localStorage.getItem('session_id'); 99 - if (sessionId) { 100 - localStorage.setItem(`migration_dismissed_${sessionId}`, 'true'); 101 - } 84 + localStorage.setItem('migration_dismissed', 'true'); 102 85 } 103 86 </script> 104 87
+3 -11
frontend/src/lib/components/SettingsMenu.svelte
··· 28 28 queue.setAutoAdvance(autoAdvance); 29 29 30 30 try { 31 - const sessionId = localStorage.getItem('session_id'); 32 - if (!sessionId) return; 33 - 34 31 const response = await fetch(`${API_URL}/preferences/`, { 35 - headers: { 36 - Authorization: `Bearer ${sessionId}` 37 - } 32 + credentials: 'include' 38 33 }); 39 34 40 35 if (!response.ok) return; ··· 70 65 71 66 async function savePreferences(update: Record<string, unknown>) { 72 67 try { 73 - const sessionId = localStorage.getItem('session_id'); 74 - if (!sessionId) return; 75 - 76 68 await fetch(`${API_URL}/preferences/`, { 77 69 method: 'POST', 78 70 headers: { 79 - 'Content-Type': 'application/json', 80 - Authorization: `Bearer ${sessionId}` 71 + 'Content-Type': 'application/json' 81 72 }, 73 + credentials: 'include', 82 74 body: JSON.stringify(update) 83 75 }); 84 76 } catch (error) {
+6 -13
frontend/src/lib/queue.svelte.ts
··· 159 159 try { 160 160 this.hydrating = true; 161 161 162 - const sessionId = localStorage.getItem('session_id'); 163 162 const headers: HeadersInit = {}; 164 - 165 - if (sessionId) { 166 - headers['Authorization'] = `Bearer ${sessionId}`; 167 - } 168 163 169 164 if (this.etag && !force) { 170 165 headers['If-None-Match'] = this.etag; 171 166 } 172 167 173 - const response = await fetch(`${API_URL}/queue/`, { headers }); 168 + const response = await fetch(`${API_URL}/queue/`, { 169 + headers, 170 + credentials: 'include' 171 + }); 174 172 175 173 if (response.status === 304) { 176 174 return; ··· 314 312 this.pendingSync = false; 315 313 316 314 try { 317 - const sessionId = localStorage.getItem('session_id'); 318 315 const state: QueueState = { 319 316 track_ids: this.tracks.map((t) => t.file_id), 320 317 current_index: this.currentIndex, ··· 327 324 const headers: HeadersInit = { 328 325 'Content-Type': 'application/json' 329 326 }; 330 - 331 - if (sessionId) { 332 - headers['Authorization'] = `Bearer ${sessionId}`; 333 - } 334 327 335 328 if (this.revision !== null) { 336 329 headers['If-Match'] = `"${this.revision}"`; 337 330 } 338 331 339 332 const response = await fetch(`${API_URL}/queue/`, { 333 + credentials: 'include', 340 334 method: 'PUT', 341 335 headers, 342 336 body: JSON.stringify({ state }) 343 337 }); 344 338 345 339 if (response.status === 401) { 346 - // session expired or invalid, clear it and stop trying to sync 347 - localStorage.removeItem('session_id'); 340 + // session expired or invalid, stop trying to sync 348 341 return false; 349 342 } 350 343
+6 -20
frontend/src/lib/tracks.svelte.ts
··· 35 35 36 36 this.loading = true; 37 37 try { 38 - // include auth header if available to get like status 39 - const headers: Record<string, string> = {}; 40 - const sessionId = localStorage.getItem('session_id'); 41 - if (sessionId) { 42 - headers['Authorization'] = `Bearer ${sessionId}`; 43 - } 44 - 45 - const response = await fetch(`${API_URL}/tracks/`, { headers }); 38 + const response = await fetch(`${API_URL}/tracks/`, { 39 + credentials: 'include' 40 + }); 46 41 const data = await response.json(); 47 42 this.tracks = data.tracks; 48 43 ··· 76 71 // like/unlike track functions 77 72 export async function likeTrack(trackId: number): Promise<boolean> { 78 73 try { 79 - const sessionId = localStorage.getItem('session_id'); 80 74 const response = await fetch(`${API_URL}/tracks/${trackId}/like`, { 81 75 method: 'POST', 82 - headers: { 83 - 'Authorization': `Bearer ${sessionId}` 84 - } 76 + credentials: 'include' 85 77 }); 86 78 87 79 if (!response.ok) { ··· 100 92 101 93 export async function unlikeTrack(trackId: number): Promise<boolean> { 102 94 try { 103 - const sessionId = localStorage.getItem('session_id'); 104 95 const response = await fetch(`${API_URL}/tracks/${trackId}/like`, { 105 96 method: 'DELETE', 106 - headers: { 107 - 'Authorization': `Bearer ${sessionId}` 108 - } 97 + credentials: 'include' 109 98 }); 110 99 111 100 if (!response.ok) { ··· 124 113 125 114 export async function fetchLikedTracks(): Promise<Track[]> { 126 115 try { 127 - const sessionId = localStorage.getItem('session_id'); 128 116 const response = await fetch(`${API_URL}/tracks/liked`, { 129 - headers: { 130 - 'Authorization': `Bearer ${sessionId}` 131 - } 117 + credentials: 'include' 132 118 }); 133 119 134 120 if (!response.ok) {
+1 -2
frontend/src/lib/uploader.svelte.ts
··· 43 43 const toastId = toast.info(uploadMessage, 30000); 44 44 45 45 if (!browser) return; 46 - const sessionId = localStorage.getItem('session_id'); 47 46 const formData = new FormData(); 48 47 formData.append('file', file); 49 48 formData.append('title', title); ··· 58 57 59 58 const xhr = new XMLHttpRequest(); 60 59 xhr.open('POST', `${API_URL}/tracks/`); 61 - xhr.setRequestHeader('Authorization', `Bearer ${sessionId}`); 60 + xhr.withCredentials = true; 62 61 63 62 let uploadComplete = false; 64 63
+1 -11
frontend/src/routes/+layout.ts
··· 16 16 }; 17 17 } 18 18 19 - const sessionId = localStorage.getItem('session_id'); 20 - if (!sessionId) { 21 - return { 22 - user: null, 23 - isAuthenticated: false 24 - }; 25 - } 26 - 27 19 try { 28 20 const response = await fetch(`${API_URL}/auth/me`, { 29 - headers: { 30 - 'Authorization': `Bearer ${sessionId}` 31 - } 21 + credentials: 'include' 32 22 }); 33 23 34 24 if (response.ok) {
+9 -10
frontend/src/routes/portal/+page.svelte
··· 64 64 const exchangeToken = params.get('exchange_token'); 65 65 66 66 if (exchangeToken) { 67 - // exchange token for session_id 67 + // exchange token for session_id (cookie is set automatically by backend) 68 68 try { 69 69 const exchangeResponse = await fetch(`${API_URL}/auth/exchange`, { 70 70 method: 'POST', 71 71 headers: { 'Content-Type': 'application/json' }, 72 + credentials: 'include', 72 73 body: JSON.stringify({ exchange_token: exchangeToken }) 73 74 }); 74 75 75 76 if (exchangeResponse.ok) { 76 - const data = await exchangeResponse.json(); 77 - auth.setSessionId(data.session_id); 78 77 await auth.initialize(); 79 78 } 80 79 } catch (_e) { ··· 106 105 loadingTracks = true; 107 106 try { 108 107 const response = await fetch(`${API_URL}/tracks/me`, { 109 - headers: auth.getAuthHeaders() 108 + credentials: 'include' 110 109 }); 111 110 if (response.ok) { 112 111 const data = await response.json(); ··· 122 121 async function loadArtistProfile() { 123 122 try { 124 123 const response = await fetch(`${API_URL}/artists/me`, { 125 - headers: auth.getAuthHeaders() 124 + credentials: 'include' 126 125 }); 127 126 if (response.ok) { 128 127 const artist = await response.json(); ··· 163 162 try { 164 163 const response = await fetch(`${API_URL}/albums/${albumId}/cover`, { 165 164 method: 'POST', 166 - headers: auth.getAuthHeaders(), 165 + credentials: 'include', 167 166 body: formData 168 167 }); 169 168 ··· 202 201 const response = await fetch(`${API_URL}/artists/me`, { 203 202 method: 'PUT', 204 203 headers: { 205 - 'Content-Type': 'application/json', 206 - ...auth.getAuthHeaders() 204 + 'Content-Type': 'application/json' 207 205 }, 206 + credentials: 'include', 208 207 body: JSON.stringify({ 209 208 display_name: displayName, 210 209 bio: bio || null, ··· 279 278 try { 280 279 const response = await fetch(`${API_URL}/tracks/${trackId}`, { 281 280 method: 'DELETE', 282 - headers: auth.getAuthHeaders() 281 + credentials: 'include' 283 282 }); 284 283 285 284 if (response.ok) { ··· 328 327 const response = await fetch(`${API_URL}/tracks/${trackId}`, { 329 328 method: 'PATCH', 330 329 body: formData, 331 - headers: auth.getAuthHeaders() 330 + credentials: 'include' 332 331 }); 333 332 334 333 if (response.ok) {
+5 -6
frontend/src/routes/profile/setup/+page.svelte
··· 22 22 const exchangeToken = params.get('exchange_token'); 23 23 24 24 if (exchangeToken) { 25 - // exchange token for session_id 25 + // exchange token for session_id (cookie is set automatically by backend) 26 26 try { 27 27 const exchangeResponse = await fetch(`${API_URL}/auth/exchange`, { 28 28 method: 'POST', 29 29 headers: { 'Content-Type': 'application/json' }, 30 + credentials: 'include', 30 31 body: JSON.stringify({ exchange_token: exchangeToken }) 31 32 }); 32 33 33 34 if (exchangeResponse.ok) { 34 - const data = await exchangeResponse.json(); 35 - auth.setSessionId(data.session_id); 36 35 await auth.initialize(); 37 36 } 38 37 } catch (_e) { ··· 70 69 try { 71 70 // call our backend which will use the Bluesky API 72 71 const response = await fetch(`${API_URL}/artists/${auth.user.did}`, { 73 - headers: auth.getAuthHeaders() 72 + credentials: 'include' 74 73 }); 75 74 76 75 // if artist profile already exists, redirect to portal ··· 96 95 const response = await fetch(`${API_URL}/artists/`, { 97 96 method: 'POST', 98 97 headers: { 99 - 'Content-Type': 'application/json', 100 - ...auth.getAuthHeaders() 98 + 'Content-Type': 'application/json' 101 99 }, 100 + credentials: 'include', 102 101 body: JSON.stringify({ 103 102 display_name: displayName, 104 103 bio: bio || null,
+1 -6
frontend/src/routes/track/[id]/+page.svelte
··· 22 22 ); 23 23 24 24 async function loadLikedState() { 25 - const sessionId = auth.getSessionId(); 26 - if (!sessionId) return; 27 - 28 25 try { 29 26 const response = await fetch(`${API_URL}/tracks/${track.id}`, { 30 - headers: { 31 - 'Authorization': `Bearer ${sessionId}` 32 - } 27 + credentials: 'include' 33 28 }); 34 29 35 30 if (response.ok) {
+1 -3
frontend/src/routes/u/[handle]/album/[slug]/+page.ts
··· 1 1 import type { PageLoad } from './$types'; 2 2 import type { AlbumResponse } from '$lib/types'; 3 3 import { API_URL } from '$lib/config'; 4 - import { auth } from '$lib/auth.svelte'; 5 4 6 5 export const load: PageLoad = async ({ params, fetch }) => { 7 6 const response = await fetch(`${API_URL}/albums/${params.handle}/${params.slug}`, { 8 - credentials: 'include', 9 - headers: auth.getAuthHeaders() 7 + credentials: 'include' 10 8 }); 11 9 12 10 if (!response.ok) {
+16 -7
src/backend/_internal/auth.py
··· 9 9 from atproto_oauth import OAuthClient 10 10 from atproto_oauth.stores.memory import MemorySessionStore 11 11 from cryptography.fernet import Fernet 12 - from fastapi import Header, HTTPException 12 + from fastapi import Cookie, Header, HTTPException 13 13 from sqlalchemy import select 14 14 15 15 from backend._internal.oauth_stores import PostgresStateStore ··· 279 279 280 280 async def require_auth( 281 281 authorization: Annotated[str | None, Header()] = None, 282 + session_id: Annotated[str | None, Cookie(alias="session_id")] = None, 282 283 ) -> Session: 283 284 """fastapi dependency to require authentication with expiration validation. 284 285 285 - requires Authorization header with Bearer token containing session_id. 286 + checks cookie first (for browser requests), then falls back to Authorization 287 + header (for SDK/CLI clients). this enables secure HttpOnly cookies for browsers 288 + while maintaining bearer token support for API clients. 286 289 """ 287 - if not authorization or not authorization.startswith("Bearer "): 290 + session_id_value = None 291 + 292 + if session_id: 293 + session_id_value = session_id 294 + elif authorization and authorization.startswith("Bearer "): 295 + session_id_value = authorization.removeprefix("Bearer ") 296 + 297 + if not session_id_value: 288 298 raise HTTPException( 289 299 status_code=401, 290 300 detail="not authenticated - login required", 291 301 ) 292 302 293 - session_id = authorization.removeprefix("Bearer ") 294 - 295 - session = await get_session(session_id) 303 + session = await get_session(session_id_value) 296 304 if not session: 297 305 raise HTTPException( 298 306 status_code=401, ··· 304 312 305 313 async def require_artist_profile( 306 314 authorization: Annotated[str | None, Header()] = None, 315 + session_id: Annotated[str | None, Cookie(alias="session_id")] = None, 307 316 ) -> Session: 308 317 """fastapi dependency to require authentication AND complete artist profile. 309 318 310 319 Returns 403 with specific message if artist profile doesn't exist, 311 320 prompting frontend to redirect to profile setup. 312 321 """ 313 - session = await require_auth(authorization) 322 + session = await require_auth(authorization, session_id) 314 323 315 324 # check if artist profile exists 316 325 if not await check_artist_profile_exists(session.did):
+6 -4
src/backend/api/albums.py
··· 6 6 from pathlib import Path 7 7 from typing import Annotated 8 8 9 - from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile 9 + from fastapi import APIRouter, Cookie, Depends, File, HTTPException, Request, UploadFile 10 10 from pydantic import BaseModel 11 11 from sqlalchemy import func, select 12 12 from sqlalchemy.ext.asyncio import AsyncSession ··· 237 237 slug: str, 238 238 db: Annotated[AsyncSession, Depends(get_db)], 239 239 request: Request, 240 + session_id_cookie: Annotated[str | None, Cookie(alias="session_id")] = None, 240 241 ) -> AlbumResponse: 241 242 """get album details with all tracks for a specific artist.""" 242 243 # look up artist + album ··· 265 266 266 267 # get authenticated user's likes for this album's tracks only 267 268 liked_track_ids: set[int] | None = None 268 - if ( 269 - session_id := request.headers.get("authorization", "").replace("Bearer ", "") 270 - ) and (auth_session := await get_session(session_id)): 269 + session_id = session_id_cookie or request.headers.get("authorization", "").replace( 270 + "Bearer ", "" 271 + ) 272 + if session_id and (auth_session := await get_session(session_id)): 271 273 if track_ids: 272 274 liked_result = await db.execute( 273 275 select(TrackLike.track_id).where(
+45 -4
src/backend/api/auth.py
··· 3 3 from typing import Annotated 4 4 5 5 from fastapi import APIRouter, Depends, HTTPException, Query 6 - from fastapi.responses import RedirectResponse 6 + from fastapi.responses import JSONResponse, RedirectResponse 7 7 from pydantic import BaseModel 8 + from starlette.requests import Request 9 + from starlette.responses import Response 8 10 9 11 from backend._internal import ( 10 12 Session, ··· 78 80 79 81 80 82 @router.post("/exchange") 81 - async def exchange_token(request: ExchangeTokenRequest) -> ExchangeTokenResponse: 83 + async def exchange_token( 84 + request: ExchangeTokenRequest, 85 + http_request: Request, 86 + response: Response, 87 + ) -> ExchangeTokenResponse: 82 88 """exchange one-time token for session_id. 83 89 84 90 frontend calls this immediately after OAuth callback to securely 85 91 exchange the short-lived token for the actual session_id. 92 + 93 + for browser requests: sets HttpOnly cookie and still returns session_id in response 94 + for SDK/CLI clients: only returns session_id in response (no cookie) 86 95 """ 87 96 session_id = await consume_exchange_token(request.exchange_token) 88 97 ··· 92 101 detail="invalid, expired, or already used exchange token", 93 102 ) 94 103 104 + user_agent = http_request.headers.get("user-agent", "").lower() 105 + is_browser = any( 106 + browser in user_agent 107 + for browser in ["mozilla", "chrome", "safari", "firefox", "edge", "opera"] 108 + ) 109 + 110 + if is_browser and settings.frontend.url: 111 + is_localhost = settings.frontend.url.startswith("http://localhost") 112 + 113 + response.set_cookie( 114 + key="session_id", 115 + value=session_id, 116 + httponly=True, 117 + secure=not is_localhost, # secure cookies require HTTPS 118 + samesite="lax", 119 + max_age=14 * 24 * 60 * 60, 120 + ) 121 + 95 122 return ExchangeTokenResponse(session_id=session_id) 96 123 97 124 98 125 @router.post("/logout") 99 - async def logout(session: Session = Depends(require_auth)) -> dict: 126 + async def logout( 127 + session: Session = Depends(require_auth), 128 + ) -> JSONResponse: 100 129 """logout current user.""" 101 130 await delete_session(session.session_id) 102 - return {"message": "logged out successfully"} 131 + response = JSONResponse(content={"message": "logged out successfully"}) 132 + 133 + if settings.frontend.url: 134 + is_localhost = settings.frontend.url.startswith("http://localhost") 135 + 136 + response.delete_cookie( 137 + key="session_id", 138 + httponly=True, 139 + secure=not is_localhost, 140 + samesite="lax", 141 + ) 142 + 143 + return response 103 144 104 145 105 146 @router.get("/me")
+16 -7
src/backend/api/tracks.py
··· 13 13 from fastapi import ( 14 14 APIRouter, 15 15 BackgroundTasks, 16 + Cookie, 16 17 Depends, 17 18 File, 18 19 Form, ··· 612 613 db: Annotated[AsyncSession, Depends(get_db)], 613 614 request: Request, 614 615 artist_did: str | None = None, 616 + session_id_cookie: Annotated[str | None, Cookie(alias="session_id")] = None, 615 617 ) -> dict: 616 618 """list all tracks, optionally filtered by artist DID.""" 617 619 from atproto_identity.did.resolver import AsyncDidResolver 618 620 619 - # get authenticated user if auth header present 621 + # get authenticated user if cookie or auth header present 620 622 liked_track_ids: set[int] | None = None 621 - if ( 622 - session_id := request.headers.get("authorization", "").replace("Bearer ", "") 623 - ) and (auth_session := await get_session(session_id)): 623 + session_id = session_id_cookie or request.headers.get("authorization", "").replace( 624 + "Bearer ", "" 625 + ) 626 + if session_id and (auth_session := await get_session(session_id)): 624 627 liked_result = await db.execute( 625 628 select(TrackLike.track_id).where(TrackLike.user_did == auth_session.did) 626 629 ) ··· 1310 1313 1311 1314 @router.get("/{track_id}") 1312 1315 async def get_track( 1313 - track_id: int, db: Annotated[AsyncSession, Depends(get_db)], request: Request 1316 + track_id: int, 1317 + db: Annotated[AsyncSession, Depends(get_db)], 1318 + request: Request, 1319 + session_id_cookie: Annotated[str | None, Cookie(alias="session_id")] = None, 1314 1320 ) -> dict: 1315 1321 """get a specific track.""" 1316 - # get authenticated user if auth header present 1322 + # get authenticated user if cookie or auth header present 1317 1323 liked_track_ids: set[int] | None = None 1324 + session_id = session_id_cookie or request.headers.get("authorization", "").replace( 1325 + "Bearer ", "" 1326 + ) 1318 1327 if ( 1319 - (session_id := request.headers.get("authorization", "").replace("Bearer ", "")) 1328 + session_id 1320 1329 and (auth_session := await get_session(session_id)) 1321 1330 and await db.scalar( 1322 1331 select(TrackLike.track_id).where(