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