audio streaming app plyr.fm
38
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #38 from zzstoatzz/fix/restore-cross-domain-auth

fix: restore cross-domain auth by removing HttpOnly cookies

authored by

nate nowack and committed by
GitHub
4b93e701 cf3fb858

+143 -77
+7 -3
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'); 20 21 const response = await fetch(`${API_URL}/preferences/`, { 21 - credentials: 'include' 22 + headers: { 23 + 'Authorization': `Bearer ${sessionId}` 24 + } 22 25 }); 23 26 if (response.ok) { 24 27 const data = await response.json(); ··· 58 61 59 62 // save to backend if authenticated 60 63 try { 64 + const sessionId = localStorage.getItem('session_id'); 61 65 await fetch(`${API_URL}/preferences/`, { 62 66 method: 'POST', 63 67 headers: { 64 - 'Content-Type': 'application/json' 68 + 'Content-Type': 'application/json', 69 + 'Authorization': `Bearer ${sessionId}` 65 70 }, 66 - credentials: 'include', 67 71 body: JSON.stringify({ accent_color: color }) 68 72 }); 69 73 } catch (e) {
+8 -2
frontend/src/routes/+page.svelte
··· 18 18 onMount(async () => { 19 19 // check authentication (non-blocking) 20 20 try { 21 + const sessionId = localStorage.getItem('session_id'); 21 22 const authResponse = await fetch(`${API_URL}/auth/me`, { 22 - credentials: 'include' 23 + headers: { 24 + 'Authorization': `Bearer ${sessionId}` 25 + } 23 26 }); 24 27 if (authResponse.ok) { 25 28 user = await authResponse.json(); ··· 35 38 }); 36 39 37 40 async function logout() { 41 + const sessionId = localStorage.getItem('session_id'); 38 42 await fetch(`${API_URL}/auth/logout`, { 39 43 method: 'POST', 40 - credentials: 'include' 44 + headers: { 45 + 'Authorization': `Bearer ${sessionId}` 46 + } 41 47 }); 42 48 user = null; 43 49 isAuthenticated = false;
+51 -12
frontend/src/routes/portal/+page.svelte
··· 26 26 let editFeaturedArtists: FeaturedArtist[] = []; 27 27 28 28 onMount(async () => { 29 - // check if exchange_token is in URL (from OAuth callback) and remove it 30 - // the HttpOnly cookie is already set by the backend 29 + // check if exchange_token is in URL (from OAuth callback) 31 30 const params = new URLSearchParams(window.location.search); 32 31 const exchangeToken = params.get('exchange_token'); 33 32 34 33 if (exchangeToken) { 35 - // remove exchange_token from URL (cookie is already set) 34 + // exchange token for session_id 35 + try { 36 + const exchangeResponse = await fetch(`${API_URL}/auth/exchange`, { 37 + method: 'POST', 38 + headers: { 'Content-Type': 'application/json' }, 39 + body: JSON.stringify({ exchange_token: exchangeToken }) 40 + }); 41 + 42 + if (exchangeResponse.ok) { 43 + const data = await exchangeResponse.json(); 44 + // store session_id in localStorage 45 + localStorage.setItem('session_id', data.session_id); 46 + } 47 + } catch (e) { 48 + console.error('failed to exchange token:', e); 49 + } 50 + 51 + // remove exchange_token from URL 36 52 window.history.replaceState({}, '', '/portal'); 37 53 } 38 54 39 - // check authentication using HttpOnly cookie 55 + // get session_id from localStorage 56 + const storedSessionId = localStorage.getItem('session_id'); 57 + 58 + if (!storedSessionId) { 59 + window.location.href = '/login'; 60 + return; 61 + } 62 + 40 63 try { 41 64 const response = await fetch(`${API_URL}/auth/me`, { 42 - credentials: 'include' // send HttpOnly cookie 65 + headers: { 66 + 'Authorization': `Bearer ${storedSessionId}` 67 + } 43 68 }); 44 69 if (response.ok) { 45 70 user = await response.json(); 46 71 await loadMyTracks(); 47 72 } else if (response.status === 401) { 48 - // not authenticated - redirect to login 73 + // only clear session on explicit 401 (unauthorized) 74 + localStorage.removeItem('session_id'); 49 75 window.location.href = '/login'; 50 76 } else { 51 - // other error (500, etc.) - show error 77 + // other error (500, etc.) - show error but don't clear session 52 78 console.error('failed to check auth status:', response.status); 53 79 error = 'server error - please try again later'; 54 80 } 55 81 } catch (e) { 56 - // network error - show error 82 + // network error - show error but keep session 57 83 console.error('network error checking auth:', e); 58 84 error = 'network error - please check your connection'; 59 85 } finally { ··· 63 89 64 90 async function loadMyTracks() { 65 91 loadingTracks = true; 92 + const sessionId = localStorage.getItem('session_id'); 66 93 try { 67 94 const response = await fetch(`${API_URL}/tracks/me`, { 68 - credentials: 'include' // send HttpOnly cookie 95 + headers: { 96 + 'Authorization': `Bearer ${sessionId}` 97 + } 69 98 }); 70 99 if (response.ok) { 71 100 const data = await response.json(); ··· 109 138 async function deleteTrack(trackId: number, trackTitle: string) { 110 139 if (!confirm(`delete "${trackTitle}"?`)) return; 111 140 141 + const sessionId = localStorage.getItem('session_id'); 112 142 try { 113 143 const response = await fetch(`${API_URL}/tracks/${trackId}`, { 114 144 method: 'DELETE', 115 - credentials: 'include' 145 + headers: { 146 + 'Authorization': `Bearer ${sessionId}` 147 + } 116 148 }); 117 149 118 150 if (response.ok) { ··· 152 184 formData.append('features', JSON.stringify([])); 153 185 } 154 186 187 + const sessionId = localStorage.getItem('session_id'); 155 188 try { 156 189 const response = await fetch(`${API_URL}/tracks/${trackId}`, { 157 190 method: 'PATCH', 158 191 body: formData, 159 - credentials: 'include' 192 + headers: { 193 + 'Authorization': `Bearer ${sessionId}` 194 + } 160 195 }); 161 196 162 197 if (response.ok) { ··· 179 214 } 180 215 181 216 async function logout() { 217 + const sessionId = localStorage.getItem('session_id'); 182 218 await fetch(`${API_URL}/auth/logout`, { 183 219 method: 'POST', 184 - credentials: 'include' 220 + headers: { 221 + 'Authorization': `Bearer ${sessionId}` 222 + } 185 223 }); 224 + localStorage.removeItem('session_id'); 186 225 window.location.href = '/'; 187 226 } 188 227 </script>
+15 -5
frontend/src/routes/profile/+page.svelte
··· 17 17 18 18 onMount(async () => { 19 19 try { 20 + const sessionId = localStorage.getItem('session_id'); 21 + 20 22 // get current user 21 23 const userResponse = await fetch(`${API_URL}/auth/me`, { 22 - credentials: 'include' 24 + headers: { 25 + 'Authorization': `Bearer ${sessionId}` 26 + } 23 27 }); 24 28 25 29 if (!userResponse.ok) { ··· 31 35 32 36 // get artist profile 33 37 const artistResponse = await fetch(`${API_URL}/artists/me`, { 34 - credentials: 'include' 38 + headers: { 39 + 'Authorization': `Bearer ${sessionId}` 40 + } 35 41 }); 36 42 37 43 if (artistResponse.ok) { ··· 59 65 success = ''; 60 66 61 67 try { 68 + const sessionId = localStorage.getItem('session_id'); 62 69 const response = await fetch(`${API_URL}/artists/me`, { 63 70 method: 'PUT', 64 71 headers: { 65 - 'Content-Type': 'application/json' 72 + 'Content-Type': 'application/json', 73 + 'Authorization': `Bearer ${sessionId}` 66 74 }, 67 - credentials: 'include', 68 75 body: JSON.stringify({ 69 76 display_name: displayName, 70 77 bio: bio || null, ··· 86 93 } 87 94 88 95 async function logout() { 96 + const sessionId = localStorage.getItem('session_id'); 89 97 await fetch(`${API_URL}/auth/logout`, { 90 98 method: 'POST', 91 - credentials: 'include' 99 + headers: { 100 + 'Authorization': `Bearer ${sessionId}` 101 + } 92 102 }); 93 103 window.location.href = '/'; 94 104 }
+43 -15
frontend/src/routes/profile/setup/+page.svelte
··· 15 15 let avatarUrl = ''; 16 16 17 17 onMount(async () => { 18 - // check if exchange_token is in URL (from OAuth callback) and remove it 19 - // the HttpOnly cookie is already set by the backend 18 + // check if exchange_token is in URL (from OAuth callback) 20 19 const params = new URLSearchParams(window.location.search); 21 20 const exchangeToken = params.get('exchange_token'); 22 21 23 22 if (exchangeToken) { 24 - // remove exchange_token from URL (cookie is already set) 23 + // exchange token for session_id 24 + try { 25 + const exchangeResponse = await fetch(`${API_URL}/auth/exchange`, { 26 + method: 'POST', 27 + headers: { 'Content-Type': 'application/json' }, 28 + body: JSON.stringify({ exchange_token: exchangeToken }) 29 + }); 30 + 31 + if (exchangeResponse.ok) { 32 + const data = await exchangeResponse.json(); 33 + // store session_id in localStorage 34 + localStorage.setItem('session_id', data.session_id); 35 + } 36 + } catch (e) { 37 + console.error('failed to exchange token:', e); 38 + } 39 + 40 + // remove exchange_token from URL 25 41 window.history.replaceState({}, '', '/profile/setup'); 26 42 } 27 43 28 - // check authentication using HttpOnly cookie 44 + // get session_id from localStorage 45 + const storedSessionId = localStorage.getItem('session_id'); 46 + 47 + if (!storedSessionId) { 48 + window.location.href = '/login'; 49 + return; 50 + } 51 + 29 52 try { 53 + // get current user 30 54 const response = await fetch(`${API_URL}/auth/me`, { 31 - credentials: 'include' 55 + headers: { 56 + 'Authorization': `Bearer ${storedSessionId}` 57 + } 32 58 }); 33 59 34 60 if (response.ok) { ··· 38 64 39 65 // try to fetch avatar from bluesky 40 66 await fetchAvatar(); 41 - } else if (response.status === 401) { 42 - // not authenticated - redirect to login 67 + } else { 68 + // session invalid, clear and redirect 69 + localStorage.removeItem('session_id'); 43 70 window.location.href = '/login'; 44 - } else { 45 - // other error (500, etc.) - show error 46 - error = 'server error - please try again later'; 47 71 } 48 72 } catch (e) { 49 - // network error - show error 50 - error = 'network error - please check your connection'; 73 + localStorage.removeItem('session_id'); 74 + window.location.href = '/login'; 51 75 } finally { 52 76 loading = false; 53 77 } ··· 57 81 if (!user) return; 58 82 59 83 fetchingAvatar = true; 84 + const sessionId = localStorage.getItem('session_id'); 60 85 61 86 try { 62 87 // call our backend which will use the Bluesky API 63 88 const response = await fetch(`${API_URL}/artists/${user.did}`, { 64 - credentials: 'include' 89 + headers: { 90 + 'Authorization': `Bearer ${sessionId}` 91 + } 65 92 }); 66 93 67 94 // if artist profile already exists, redirect to portal ··· 83 110 saving = true; 84 111 error = ''; 85 112 113 + const sessionId = localStorage.getItem('session_id'); 86 114 try { 87 115 const response = await fetch(`${API_URL}/artists/`, { 88 116 method: 'POST', 89 117 headers: { 90 - 'Content-Type': 'application/json' 118 + 'Content-Type': 'application/json', 119 + 'Authorization': `Bearer ${sessionId}` 91 120 }, 92 - credentials: 'include', 93 121 body: JSON.stringify({ 94 122 display_name: displayName, 95 123 bio: bio || null,
+8 -2
frontend/src/routes/track/[id]/+page.svelte
··· 32 32 onMount(async () => { 33 33 // check authentication 34 34 try { 35 + const sessionId = localStorage.getItem('session_id'); 35 36 const authResponse = await fetch(`${API_URL}/auth/me`, { 36 - credentials: 'include' 37 + headers: { 38 + 'Authorization': `Bearer ${sessionId}` 39 + } 37 40 }); 38 41 if (authResponse.ok) { 39 42 user = await authResponse.json(); ··· 100 103 } 101 104 102 105 async function logout() { 106 + const sessionId = localStorage.getItem('session_id'); 103 107 await fetch(`${API_URL}/auth/logout`, { 104 108 method: 'POST', 105 - credentials: 'include' 109 + headers: { 110 + 'Authorization': `Bearer ${sessionId}` 111 + } 106 112 }); 107 113 user = null; 108 114 }
+6 -19
src/relay/_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 Cookie, Header, HTTPException 12 + from fastapi import Header, HTTPException 13 13 from sqlalchemy import select 14 14 15 15 from relay.config import settings ··· 278 278 279 279 280 280 async def require_auth( 281 - session_id_cookie: Annotated[str | None, Cookie(alias="session_id")] = None, 282 281 authorization: Annotated[str | None, Header()] = None, 283 282 ) -> Session: 284 283 """fastapi dependency to require authentication with expiration validation. 285 284 286 - Accepts session_id from either: 287 - - Cookie (for same-domain requests) 288 - - Authorization header as Bearer token (for cross-domain requests) 289 - 290 - validates session expiration and returns 401 if expired. 285 + requires Authorization header with Bearer token containing session_id. 291 286 """ 292 - session_id = None 293 - 294 - # try cookie first (for localhost/same-domain) 295 - if session_id_cookie: 296 - session_id = session_id_cookie 297 - # try authorization header (for cross-domain) 298 - elif authorization and authorization.startswith("Bearer "): 299 - session_id = authorization.removeprefix("Bearer ") 300 - 301 - if not session_id: 287 + if not authorization or not authorization.startswith("Bearer "): 302 288 raise HTTPException( 303 289 status_code=401, 304 290 detail="not authenticated - login required", 305 291 ) 306 292 293 + session_id = authorization.removeprefix("Bearer ") 294 + 307 295 session = await get_session(session_id) 308 296 if not session: 309 297 raise HTTPException( ··· 315 303 316 304 317 305 async def require_artist_profile( 318 - session_id_cookie: Annotated[str | None, Cookie(alias="session_id")] = None, 319 306 authorization: Annotated[str | None, Header()] = None, 320 307 ) -> Session: 321 308 """fastapi dependency to require authentication AND complete artist profile. ··· 323 310 Returns 403 with specific message if artist profile doesn't exist, 324 311 prompting frontend to redirect to profile setup. 325 312 """ 326 - session = await require_auth(session_id_cookie, authorization) 313 + session = await require_auth(authorization) 327 314 328 315 # check if artist profile exists 329 316 if not await check_artist_profile_exists(session.did):
+5 -19
src/relay/api/auth.py
··· 42 42 state: Annotated[str, Query()], 43 43 iss: Annotated[str, Query()], 44 44 ) -> RedirectResponse: 45 - """handle OAuth callback and create session with HttpOnly cookie. 45 + """handle OAuth callback and create session. 46 46 47 - sets secure HttpOnly cookie to prevent XSS attacks. also provides 48 - exchange token in URL as fallback for cross-domain scenarios. 47 + returns exchange token in URL which frontend will exchange for session_id. 48 + exchange token is short-lived (60s) and one-time use for security. 49 49 """ 50 50 did, handle, oauth_session = await handle_oauth_callback(code, state, iss) 51 51 session_id = await create_session(did, handle, oauth_session) 52 52 53 - # create one-time exchange token as fallback for cross-domain (expires in 60 seconds) 53 + # create one-time exchange token (expires in 60 seconds) 54 54 exchange_token = await create_exchange_token(session_id) 55 55 56 56 # check if artist profile exists ··· 59 59 # redirect to profile setup if needed, otherwise to portal 60 60 redirect_path = "/portal" if has_profile else "/profile/setup" 61 61 62 - # create response with exchange_token (fallback for cross-domain) 63 - response = RedirectResponse( 62 + return RedirectResponse( 64 63 url=f"{settings.frontend_url}{redirect_path}?exchange_token={exchange_token}", 65 64 status_code=303, 66 65 ) 67 - 68 - # set HttpOnly cookie (primary auth mechanism for same-domain) 69 - response.set_cookie( 70 - key="session_id", 71 - value=session_id, 72 - httponly=True, 73 - secure=True, # only send over HTTPS 74 - samesite="lax", # CSRF protection 75 - max_age=1209600, # 2 weeks (matches session expiration) 76 - path="/", 77 - ) 78 - 79 - return response 80 66 81 67 82 68 class ExchangeTokenRequest(BaseModel):