audio streaming app plyr.fm
38
fork

Configure Feed

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

Merge pull request #37 from zzstoatzz/feat/oauth-session-hardening

OAuth session hardening with exchange tokens and encryption

authored by

nate nowack and committed by
GitHub
cf3fb858 83306630

+608 -193
+1
CLAUDE.md
··· 6 6 7 7 - **issues**: tracked in GitHub, not Linear 8 8 - **pull requests**: always create a PR for review before merging to main - we will have users soon 9 + - **PR review comments**: to get inline review comments, use `gh api repos/{owner}/{repo}/pulls/{pr}/reviews/{review_id}/comments` (get review_id first with `gh api repos/{owner}/{repo}/pulls/{pr}/reviews -q '.[0].id'`) 9 10 - **testing**: empirical first - run code and prove it works before writing tests 10 11 - **testing async**: NEVER use `@pytest.mark.asyncio` - pytest is configured with `asyncio_mode = "auto"` in pyproject.toml 11 12 - **auth**: OAuth 2.1 implementation from fork (`git+https://github.com/zzstoatzz/atproto@main`)
+61
alembic/versions/ec40ac6453bc_add_exchange_tokens_table_for_secure_.py
··· 1 + """add exchange_tokens table for secure OAuth callback 2 + 3 + Revision ID: ec40ac6453bc 4 + Revises: 846cc6b867b8 5 + Create Date: 2025-11-03 10:15:32.856846 6 + 7 + """ 8 + 9 + from collections.abc import Sequence 10 + 11 + # revision identifiers, used by Alembic. 12 + revision: str = "ec40ac6453bc" 13 + down_revision: str | Sequence[str] | None = "846cc6b867b8" 14 + branch_labels: str | Sequence[str] | None = None 15 + depends_on: str | Sequence[str] | None = None 16 + 17 + 18 + def upgrade() -> None: 19 + """Upgrade schema.""" 20 + import sqlalchemy as sa 21 + 22 + from alembic import op 23 + 24 + op.create_table( 25 + "exchange_tokens", 26 + sa.Column("token", sa.String(length=64), nullable=False), 27 + sa.Column("session_id", sa.String(length=64), nullable=False), 28 + sa.Column( 29 + "created_at", 30 + sa.DateTime(timezone=True), 31 + nullable=False, 32 + server_default=sa.text("now()"), 33 + ), 34 + sa.Column( 35 + "expires_at", 36 + sa.DateTime(timezone=True), 37 + nullable=False, 38 + ), 39 + sa.Column( 40 + "used", sa.Boolean(), nullable=False, server_default=sa.text("false") 41 + ), 42 + sa.PrimaryKeyConstraint("token"), 43 + ) 44 + op.create_index( 45 + op.f("ix_exchange_tokens_token"), "exchange_tokens", ["token"], unique=False 46 + ) 47 + op.create_index( 48 + op.f("ix_exchange_tokens_session_id"), 49 + "exchange_tokens", 50 + ["session_id"], 51 + unique=False, 52 + ) 53 + 54 + 55 + def downgrade() -> None: 56 + """Downgrade schema.""" 57 + from alembic import op 58 + 59 + op.drop_index(op.f("ix_exchange_tokens_session_id"), table_name="exchange_tokens") 60 + op.drop_index(op.f("ix_exchange_tokens_token"), table_name="exchange_tokens") 61 + op.drop_table("exchange_tokens")
+1
fly.toml
··· 38 38 # - AWS_SECRET_ACCESS_KEY 39 39 # - ATPROTO_CLIENT_ID (will be https://relay-api.fly.dev/client-metadata.json after deployment) 40 40 # - ATPROTO_REDIRECT_URI (will be https://relay-api.fly.dev/auth/callback after deployment) 41 + # - OAUTH_ENCRYPTION_KEY (44-character base64 Fernet key, generate with: python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())')
+22 -30
frontend/src/lib/components/ColorSettings.svelte
··· 16 16 17 17 onMount(async () => { 18 18 // try to fetch from backend if authenticated 19 - const sessionId = localStorage.getItem('session_id'); 20 - if (sessionId) { 21 - try { 22 - const response = await fetch(`${API_URL}/preferences/`, { 23 - headers: { 24 - 'Authorization': `Bearer ${sessionId}` 25 - } 26 - }); 27 - if (response.ok) { 28 - const data = await response.json(); 29 - currentColor = data.accent_color; 30 - applyColorLocally(data.accent_color); 31 - return; 32 - } 33 - } catch (e) { 34 - console.error('failed to fetch preferences:', e); 19 + try { 20 + const response = await fetch(`${API_URL}/preferences/`, { 21 + credentials: 'include' 22 + }); 23 + if (response.ok) { 24 + const data = await response.json(); 25 + currentColor = data.accent_color; 26 + applyColorLocally(data.accent_color); 27 + return; 35 28 } 29 + } catch (e) { 30 + console.error('failed to fetch preferences:', e); 36 31 } 37 32 38 33 // fallback to localStorage ··· 62 57 localStorage.setItem('accentColor', color); 63 58 64 59 // save to backend if authenticated 65 - const sessionId = localStorage.getItem('session_id'); 66 - if (sessionId) { 67 - try { 68 - await fetch(`${API_URL}/preferences/`, { 69 - method: 'POST', 70 - headers: { 71 - 'Authorization': `Bearer ${sessionId}`, 72 - 'Content-Type': 'application/json' 73 - }, 74 - body: JSON.stringify({ accent_color: color }) 75 - }); 76 - } catch (e) { 77 - console.error('failed to save preferences:', e); 78 - } 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) { 70 + console.error('failed to save preferences:', e); 79 71 } 80 72 } 81 73
+15 -36
frontend/src/routes/+page.svelte
··· 7 7 import { player } from '$lib/player.svelte'; 8 8 import { tracksCache } from '$lib/tracks.svelte'; 9 9 10 - // optimistically check for session at init to prevent flash of logged-out state 11 - const hasSession = typeof window !== 'undefined' && !!localStorage.getItem('session_id'); 12 - 13 10 let user = $state<User | null>(null); 14 - let isAuthenticated = $state(hasSession); 11 + let isAuthenticated = $state(false); 15 12 16 13 // use cached tracks 17 14 let tracks = $derived(tracksCache.tracks); ··· 20 17 21 18 onMount(async () => { 22 19 // check authentication (non-blocking) 23 - const sessionId = localStorage.getItem('session_id'); 24 - if (sessionId) { 25 - try { 26 - const authResponse = await fetch(`${API_URL}/auth/me`, { 27 - headers: { 28 - 'Authorization': `Bearer ${sessionId}` 29 - } 30 - }); 31 - if (authResponse.ok) { 32 - user = await authResponse.json(); 33 - isAuthenticated = true; 34 - } else if (authResponse.status === 401) { 35 - // only clear session on explicit 401 (unauthorized) 36 - localStorage.removeItem('session_id'); 37 - isAuthenticated = false; 38 - } 39 - // ignore other errors (network issues, 500s, etc.) - keep optimistic auth state 40 - } catch (e) { 41 - // network error - don't clear session or change auth state 42 - console.warn('failed to check auth status:', e); 20 + try { 21 + const authResponse = await fetch(`${API_URL}/auth/me`, { 22 + credentials: 'include' 23 + }); 24 + if (authResponse.ok) { 25 + user = await authResponse.json(); 26 + isAuthenticated = true; 43 27 } 44 - } else { 45 - isAuthenticated = false; 28 + } catch (e) { 29 + // network error or not authenticated - continue as guest 30 + console.warn('failed to check auth status:', e); 46 31 } 47 32 48 33 // fetch tracks from cache (will use cached data if recent) ··· 50 35 }); 51 36 52 37 async function logout() { 53 - const sessionId = localStorage.getItem('session_id'); 54 - if (sessionId) { 55 - await fetch(`${API_URL}/auth/logout`, { 56 - method: 'POST', 57 - headers: { 58 - 'Authorization': `Bearer ${sessionId}` 59 - } 60 - }); 61 - } 62 - localStorage.removeItem('session_id'); 38 + await fetch(`${API_URL}/auth/logout`, { 39 + method: 'POST', 40 + credentials: 'include' 41 + }); 63 42 user = null; 64 43 isAuthenticated = false; 65 44 }
+14 -38
frontend/src/routes/portal/+page.svelte
··· 26 26 let editFeaturedArtists: FeaturedArtist[] = []; 27 27 28 28 onMount(async () => { 29 - // check if session_id is in URL (from OAuth callback) 29 + // check if exchange_token is in URL (from OAuth callback) and remove it 30 + // the HttpOnly cookie is already set by the backend 30 31 const params = new URLSearchParams(window.location.search); 31 - const sessionId = params.get('session_id'); 32 + const exchangeToken = params.get('exchange_token'); 32 33 33 - if (sessionId) { 34 - // store session_id in localStorage 35 - localStorage.setItem('session_id', sessionId); 36 - // remove from URL 34 + if (exchangeToken) { 35 + // remove exchange_token from URL (cookie is already set) 37 36 window.history.replaceState({}, '', '/portal'); 38 37 } 39 38 40 - // get session_id from localStorage 41 - const storedSessionId = localStorage.getItem('session_id'); 42 - 43 - if (!storedSessionId) { 44 - window.location.href = '/login'; 45 - return; 46 - } 47 - 39 + // check authentication using HttpOnly cookie 48 40 try { 49 41 const response = await fetch(`${API_URL}/auth/me`, { 50 - headers: { 51 - 'Authorization': `Bearer ${storedSessionId}` 52 - } 42 + credentials: 'include' // send HttpOnly cookie 53 43 }); 54 44 if (response.ok) { 55 45 user = await response.json(); 56 46 await loadMyTracks(); 57 47 } else if (response.status === 401) { 58 - // only clear session on explicit 401 (unauthorized) 59 - localStorage.removeItem('session_id'); 48 + // not authenticated - redirect to login 60 49 window.location.href = '/login'; 61 50 } else { 62 - // other error (500, etc.) - show error but don't clear session 51 + // other error (500, etc.) - show error 63 52 console.error('failed to check auth status:', response.status); 64 53 error = 'server error - please try again later'; 65 54 } 66 55 } catch (e) { 67 - // network error - show error but keep session 56 + // network error - show error 68 57 console.error('network error checking auth:', e); 69 58 error = 'network error - please check your connection'; 70 59 } finally { ··· 74 63 75 64 async function loadMyTracks() { 76 65 loadingTracks = true; 77 - const sessionId = localStorage.getItem('session_id'); 78 66 try { 79 67 const response = await fetch(`${API_URL}/tracks/me`, { 80 - headers: { 81 - 'Authorization': `Bearer ${sessionId}` 82 - } 68 + credentials: 'include' // send HttpOnly cookie 83 69 }); 84 70 if (response.ok) { 85 71 const data = await response.json(); ··· 123 109 async function deleteTrack(trackId: number, trackTitle: string) { 124 110 if (!confirm(`delete "${trackTitle}"?`)) return; 125 111 126 - const sessionId = localStorage.getItem('session_id'); 127 112 try { 128 113 const response = await fetch(`${API_URL}/tracks/${trackId}`, { 129 114 method: 'DELETE', 130 - headers: { 131 - 'Authorization': `Bearer ${sessionId}` 132 - } 115 + credentials: 'include' 133 116 }); 134 117 135 118 if (response.ok) { ··· 158 141 } 159 142 160 143 async function saveTrackEdit(trackId: number) { 161 - const sessionId = localStorage.getItem('session_id'); 162 144 const formData = new FormData(); 163 145 formData.append('title', editTitle); 164 146 formData.append('album', editAlbum); ··· 174 156 const response = await fetch(`${API_URL}/tracks/${trackId}`, { 175 157 method: 'PATCH', 176 158 body: formData, 177 - headers: { 178 - 'Authorization': `Bearer ${sessionId}` 179 - } 159 + credentials: 'include' 180 160 }); 181 161 182 162 if (response.ok) { ··· 199 179 } 200 180 201 181 async function logout() { 202 - const sessionId = localStorage.getItem('session_id'); 203 182 await fetch(`${API_URL}/auth/logout`, { 204 183 method: 'POST', 205 - headers: { 206 - 'Authorization': `Bearer ${sessionId}` 207 - } 184 + credentials: 'include' 208 185 }); 209 - localStorage.removeItem('session_id'); 210 186 window.location.href = '/'; 211 187 } 212 188 </script>
+4 -22
frontend/src/routes/profile/+page.svelte
··· 16 16 let avatarUrl = ''; 17 17 18 18 onMount(async () => { 19 - const sessionId = localStorage.getItem('session_id'); 20 - 21 - if (!sessionId) { 22 - window.location.href = '/login'; 23 - return; 24 - } 25 - 26 19 try { 27 20 // get current user 28 21 const userResponse = await fetch(`${API_URL}/auth/me`, { 29 - headers: { 30 - 'Authorization': `Bearer ${sessionId}` 31 - } 22 + credentials: 'include' 32 23 }); 33 24 34 25 if (!userResponse.ok) { 35 - localStorage.removeItem('session_id'); 36 26 window.location.href = '/login'; 37 27 return; 38 28 } ··· 41 31 42 32 // get artist profile 43 33 const artistResponse = await fetch(`${API_URL}/artists/me`, { 44 - headers: { 45 - 'Authorization': `Bearer ${sessionId}` 46 - } 34 + credentials: 'include' 47 35 }); 48 36 49 37 if (artistResponse.ok) { ··· 70 58 error = ''; 71 59 success = ''; 72 60 73 - const sessionId = localStorage.getItem('session_id'); 74 - 75 61 try { 76 62 const response = await fetch(`${API_URL}/artists/me`, { 77 63 method: 'PUT', 78 64 headers: { 79 - 'Authorization': `Bearer ${sessionId}`, 80 65 'Content-Type': 'application/json' 81 66 }, 67 + credentials: 'include', 82 68 body: JSON.stringify({ 83 69 display_name: displayName, 84 70 bio: bio || null, ··· 100 86 } 101 87 102 88 async function logout() { 103 - const sessionId = localStorage.getItem('session_id'); 104 89 await fetch(`${API_URL}/auth/logout`, { 105 90 method: 'POST', 106 - headers: { 107 - 'Authorization': `Bearer ${sessionId}` 108 - } 91 + credentials: 'include' 109 92 }); 110 - localStorage.removeItem('session_id'); 111 93 window.location.href = '/'; 112 94 } 113 95 </script>
+16 -30
frontend/src/routes/profile/setup/+page.svelte
··· 15 15 let avatarUrl = ''; 16 16 17 17 onMount(async () => { 18 - // check if session_id is in URL (from OAuth callback) 18 + // check if exchange_token is in URL (from OAuth callback) and remove it 19 + // the HttpOnly cookie is already set by the backend 19 20 const params = new URLSearchParams(window.location.search); 20 - const sessionId = params.get('session_id'); 21 + const exchangeToken = params.get('exchange_token'); 21 22 22 - if (sessionId) { 23 - // store session_id in localStorage 24 - localStorage.setItem('session_id', sessionId); 25 - // remove from URL 23 + if (exchangeToken) { 24 + // remove exchange_token from URL (cookie is already set) 26 25 window.history.replaceState({}, '', '/profile/setup'); 27 26 } 28 27 29 - // get session_id from localStorage 30 - const storedSessionId = localStorage.getItem('session_id'); 31 - 32 - if (!storedSessionId) { 33 - window.location.href = '/login'; 34 - return; 35 - } 36 - 28 + // check authentication using HttpOnly cookie 37 29 try { 38 - // get current user 39 30 const response = await fetch(`${API_URL}/auth/me`, { 40 - headers: { 41 - 'Authorization': `Bearer ${storedSessionId}` 42 - } 31 + credentials: 'include' 43 32 }); 44 33 45 34 if (response.ok) { ··· 49 38 50 39 // try to fetch avatar from bluesky 51 40 await fetchAvatar(); 52 - } else { 53 - // session invalid, clear and redirect 54 - localStorage.removeItem('session_id'); 41 + } else if (response.status === 401) { 42 + // not authenticated - redirect to login 55 43 window.location.href = '/login'; 44 + } else { 45 + // other error (500, etc.) - show error 46 + error = 'server error - please try again later'; 56 47 } 57 48 } catch (e) { 58 - localStorage.removeItem('session_id'); 59 - window.location.href = '/login'; 49 + // network error - show error 50 + error = 'network error - please check your connection'; 60 51 } finally { 61 52 loading = false; 62 53 } ··· 66 57 if (!user) return; 67 58 68 59 fetchingAvatar = true; 69 - const sessionId = localStorage.getItem('session_id'); 70 60 71 61 try { 72 62 // call our backend which will use the Bluesky API 73 63 const response = await fetch(`${API_URL}/artists/${user.did}`, { 74 - headers: { 75 - 'Authorization': `Bearer ${sessionId}` 76 - } 64 + credentials: 'include' 77 65 }); 78 66 79 67 // if artist profile already exists, redirect to portal ··· 95 83 saving = true; 96 84 error = ''; 97 85 98 - const sessionId = localStorage.getItem('session_id'); 99 - 100 86 try { 101 87 const response = await fetch(`${API_URL}/artists/`, { 102 88 method: 'POST', 103 89 headers: { 104 - 'Authorization': `Bearer ${sessionId}`, 105 90 'Content-Type': 'application/json' 106 91 }, 92 + credentials: 'include', 107 93 body: JSON.stringify({ 108 94 display_name: displayName, 109 95 bio: bio || null,
+12 -25
frontend/src/routes/track/[id]/+page.svelte
··· 31 31 32 32 onMount(async () => { 33 33 // check authentication 34 - const sessionId = localStorage.getItem('session_id'); 35 - if (sessionId) { 36 - try { 37 - const authResponse = await fetch(`${API_URL}/auth/me`, { 38 - headers: { 39 - 'Authorization': `Bearer ${sessionId}` 40 - } 41 - }); 42 - if (authResponse.ok) { 43 - user = await authResponse.json(); 44 - } else { 45 - localStorage.removeItem('session_id'); 46 - } 47 - } catch (e) { 48 - localStorage.removeItem('session_id'); 34 + try { 35 + const authResponse = await fetch(`${API_URL}/auth/me`, { 36 + credentials: 'include' 37 + }); 38 + if (authResponse.ok) { 39 + user = await authResponse.json(); 49 40 } 41 + } catch (e) { 42 + // not authenticated or network error - continue as guest 50 43 } 51 44 52 45 // load tracks ··· 107 100 } 108 101 109 102 async function logout() { 110 - const sessionId = localStorage.getItem('session_id'); 111 - if (sessionId) { 112 - await fetch(`${API_URL}/auth/logout`, { 113 - method: 'POST', 114 - headers: { 115 - 'Authorization': `Bearer ${sessionId}` 116 - } 117 - }); 118 - } 119 - localStorage.removeItem('session_id'); 103 + await fetch(`${API_URL}/auth/logout`, { 104 + method: 'POST', 105 + credentials: 'include' 106 + }); 120 107 user = null; 121 108 } 122 109 </script>
+1
pyproject.toml
··· 75 75 timeout = 10 76 76 env = [ 77 77 "RELAY_TEST_MODE=1", 78 + "OAUTH_ENCRYPTION_KEY=hnSkDmgbbuK0rt7Ab3eJHAktb18gmebsdwKdTmq9mes=", 78 79 ] 79 80 markers = [ 80 81 "integration: marks tests as integration tests (deselect with '-m \"not integration\"')",
+4
src/relay/_internal/__init__.py
··· 3 3 from relay._internal.auth import ( 4 4 Session, 5 5 check_artist_profile_exists, 6 + consume_exchange_token, 7 + create_exchange_token, 6 8 create_session, 7 9 delete_session, 8 10 handle_oauth_callback, ··· 17 19 __all__ = [ 18 20 "Session", 19 21 "check_artist_profile_exists", 22 + "consume_exchange_token", 23 + "create_exchange_token", 20 24 "create_session", 21 25 "delete_session", 22 26 "handle_oauth_callback",
+119 -8
src/relay/_internal/auth.py
··· 3 3 import json 4 4 import secrets 5 5 from dataclasses import dataclass 6 + from datetime import UTC, datetime, timedelta 6 7 from typing import Annotated, Any 7 8 8 9 from atproto_oauth import OAuthClient 9 10 from atproto_oauth.stores.memory import MemorySessionStore 11 + from cryptography.fernet import Fernet 10 12 from fastapi import Cookie, Header, HTTPException 11 13 from sqlalchemy import select 12 14 13 15 from relay.config import settings 14 - from relay.models import UserSession 16 + from relay.models import ExchangeToken, UserSession 15 17 from relay.stores import PostgresStateStore 16 18 from relay.utilities.database import db_session 17 19 ··· 45 47 session_store=_session_store, 46 48 ) 47 49 50 + # encryption for sensitive OAuth data at rest 51 + # CRITICAL: encryption key must be configured and stable across restarts 52 + # otherwise all sessions become undecipherable after restart 53 + if not settings.oauth_encryption_key: 54 + raise RuntimeError( 55 + "oauth_encryption_key must be configured in settings. " 56 + "generate one with: python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())'" 57 + ) 58 + 59 + _encryption_key = settings.oauth_encryption_key.encode() 60 + _fernet = Fernet(_encryption_key) 61 + 62 + 63 + def _encrypt_data(data: str) -> str: 64 + """encrypt sensitive data for storage.""" 65 + return _fernet.encrypt(data.encode()).decode() 66 + 67 + 68 + def _decrypt_data(encrypted: str) -> str | None: 69 + """decrypt sensitive data from storage. 70 + 71 + returns None if decryption fails (e.g., key changed, data corrupted). 72 + """ 73 + try: 74 + return _fernet.decrypt(encrypted.encode()).decode() 75 + except Exception: 76 + # decryption failed - likely key mismatch or corrupted data 77 + return None 78 + 48 79 49 80 async def create_session(did: str, handle: str, oauth_session: dict[str, Any]) -> str: 50 - """create a new session for authenticated user.""" 81 + """create a new session for authenticated user with encrypted OAuth data.""" 51 82 session_id = secrets.token_urlsafe(32) 52 83 53 - # store in database 84 + # encrypt sensitive OAuth session data before storing 85 + encrypted_data = _encrypt_data(json.dumps(oauth_session)) 86 + 87 + # store in database with expiration (2 weeks from now per OAuth 2.1 requirements) 88 + expires_at = datetime.now(UTC) + timedelta(days=14) 89 + 54 90 async with db_session() as db: 55 91 user_session = UserSession( 56 92 session_id=session_id, 57 93 did=did, 58 94 handle=handle, 59 - oauth_session_data=json.dumps(oauth_session), 95 + oauth_session_data=encrypted_data, 96 + expires_at=expires_at, 60 97 ) 61 98 db.add(user_session) 62 99 await db.commit() ··· 65 102 66 103 67 104 async def get_session(session_id: str) -> Session | None: 68 - """retrieve session by id.""" 105 + """retrieve session by id, decrypt OAuth data, and validate expiration.""" 69 106 async with db_session() as db: 70 107 result = await db.execute( 71 108 select(UserSession).where(UserSession.session_id == session_id) ··· 73 110 if not (user_session := result.scalar_one_or_none()): 74 111 return None 75 112 113 + # check if session is expired 114 + if user_session.expires_at and datetime.now(UTC) > user_session.expires_at: 115 + # session expired - delete it and return None 116 + await delete_session(session_id) 117 + return None 118 + 119 + # decrypt OAuth session data 120 + decrypted_data = _decrypt_data(user_session.oauth_session_data) 121 + if decrypted_data is None: 122 + # decryption failed - session is invalid (key changed or data corrupted) 123 + # delete the corrupted session 124 + await delete_session(session_id) 125 + return None 126 + 76 127 return Session( 77 128 session_id=user_session.session_id, 78 129 did=user_session.did, 79 130 handle=user_session.handle, 80 - oauth_session=json.loads(user_session.oauth_session_data), 131 + oauth_session=json.loads(decrypted_data), 81 132 ) 82 133 83 134 ··· 90 141 select(UserSession).where(UserSession.session_id == session_id) 91 142 ) 92 143 if user_session := result.scalar_one_or_none(): 93 - user_session.oauth_session_data = json.dumps(oauth_session_data) 144 + # encrypt updated OAuth session data 145 + encrypted_data = _encrypt_data(json.dumps(oauth_session_data)) 146 + user_session.oauth_session_data = encrypted_data 94 147 await db.commit() 95 148 96 149 ··· 168 221 return artist is not None 169 222 170 223 224 + async def create_exchange_token(session_id: str) -> str: 225 + """create a one-time use exchange token for secure OAuth callback. 226 + 227 + exchange tokens expire after 60 seconds and can only be used once, 228 + preventing session_id exposure in browser history/referrers. 229 + """ 230 + token = secrets.token_urlsafe(32) 231 + 232 + async with db_session() as db: 233 + exchange_token = ExchangeToken( 234 + token=token, 235 + session_id=session_id, 236 + ) 237 + db.add(exchange_token) 238 + await db.commit() 239 + 240 + return token 241 + 242 + 243 + async def consume_exchange_token(token: str) -> str | None: 244 + """consume an exchange token and return the associated session_id. 245 + 246 + returns None if token is invalid, expired, or already used. 247 + uses atomic UPDATE to prevent race conditions (token can only be used once). 248 + """ 249 + from sqlalchemy import update 250 + 251 + async with db_session() as db: 252 + # first, check if token exists and is not expired 253 + result = await db.execute( 254 + select(ExchangeToken).where(ExchangeToken.token == token) 255 + ) 256 + exchange_token = result.scalar_one_or_none() 257 + 258 + if not exchange_token: 259 + return None 260 + 261 + # check if expired 262 + if datetime.now(UTC) > exchange_token.expires_at: 263 + return None 264 + 265 + # atomically mark as used ONLY if not already used 266 + # this prevents race conditions where two requests try to use the same token 267 + result = await db.execute( 268 + update(ExchangeToken) 269 + .where(ExchangeToken.token == token, ExchangeToken.used == False) # noqa: E712 270 + .values(used=True) 271 + .returning(ExchangeToken.session_id) 272 + ) 273 + await db.commit() 274 + 275 + # if no rows were updated, token was already used 276 + session_id = result.scalar_one_or_none() 277 + return session_id 278 + 279 + 171 280 async def require_auth( 172 281 session_id_cookie: Annotated[str | None, Cookie(alias="session_id")] = None, 173 282 authorization: Annotated[str | None, Header()] = None, 174 283 ) -> Session: 175 - """fastapi dependency to require authentication. 284 + """fastapi dependency to require authentication with expiration validation. 176 285 177 286 Accepts session_id from either: 178 287 - Cookie (for same-domain requests) 179 288 - Authorization header as Bearer token (for cross-domain requests) 289 + 290 + validates session expiration and returns 401 if expired. 180 291 """ 181 292 session_id = None 182 293
+55 -4
src/relay/api/auth.py
··· 2 2 3 3 from typing import Annotated 4 4 5 - from fastapi import APIRouter, Depends, Query 5 + from fastapi import APIRouter, Depends, HTTPException, Query 6 6 from fastapi.responses import RedirectResponse 7 7 from pydantic import BaseModel 8 8 9 9 from relay._internal import ( 10 10 Session, 11 11 check_artist_profile_exists, 12 + consume_exchange_token, 13 + create_exchange_token, 12 14 create_session, 13 15 delete_session, 14 16 handle_oauth_callback, ··· 40 42 state: Annotated[str, Query()], 41 43 iss: Annotated[str, Query()], 42 44 ) -> RedirectResponse: 43 - """handle OAuth callback and create session.""" 45 + """handle OAuth callback and create session with HttpOnly cookie. 46 + 47 + sets secure HttpOnly cookie to prevent XSS attacks. also provides 48 + exchange token in URL as fallback for cross-domain scenarios. 49 + """ 44 50 did, handle, oauth_session = await handle_oauth_callback(code, state, iss) 45 51 session_id = await create_session(did, handle, oauth_session) 46 52 53 + # create one-time exchange token as fallback for cross-domain (expires in 60 seconds) 54 + exchange_token = await create_exchange_token(session_id) 55 + 47 56 # check if artist profile exists 48 57 has_profile = await check_artist_profile_exists(did) 49 58 50 59 # redirect to profile setup if needed, otherwise to portal 51 60 redirect_path = "/portal" if has_profile else "/profile/setup" 52 61 53 - # pass session_id as URL parameter for cross-domain auth 62 + # create response with exchange_token (fallback for cross-domain) 54 63 response = RedirectResponse( 55 - url=f"{settings.frontend_url}{redirect_path}?session_id={session_id}", 64 + url=f"{settings.frontend_url}{redirect_path}?exchange_token={exchange_token}", 56 65 status_code=303, 57 66 ) 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 + 58 79 return response 80 + 81 + 82 + class ExchangeTokenRequest(BaseModel): 83 + """request model for exchanging token for session_id.""" 84 + 85 + exchange_token: str 86 + 87 + 88 + class ExchangeTokenResponse(BaseModel): 89 + """response model for exchange token endpoint.""" 90 + 91 + session_id: str 92 + 93 + 94 + @router.post("/exchange") 95 + async def exchange_token(request: ExchangeTokenRequest) -> ExchangeTokenResponse: 96 + """exchange one-time token for session_id. 97 + 98 + frontend calls this immediately after OAuth callback to securely 99 + exchange the short-lived token for the actual session_id. 100 + """ 101 + session_id = await consume_exchange_token(request.exchange_token) 102 + 103 + if not session_id: 104 + raise HTTPException( 105 + status_code=401, 106 + detail="invalid, expired, or already used exchange token", 107 + ) 108 + 109 + return ExchangeTokenResponse(session_id=session_id) 59 110 60 111 61 112 @router.post("/logout")
+4
src/relay/config.py
··· 105 105 default="atproto repo:app.relay.track", 106 106 description="OAuth scope", 107 107 ) 108 + oauth_encryption_key: str = Field( 109 + default="", 110 + description="Fernet encryption key for OAuth data at rest (base64-encoded 32-byte key, 44 characters including padding)", 111 + ) 108 112 109 113 # observability 110 114 logfire_enabled: bool = Field(default=False, description="Enable Logfire OTEL")
+2
src/relay/models/__init__.py
··· 3 3 from relay.models.artist import Artist 4 4 from relay.models.audio import AudioFormat 5 5 from relay.models.database import Base 6 + from relay.models.exchange_token import ExchangeToken 6 7 from relay.models.oauth_state import OAuthStateModel 7 8 from relay.models.preferences import UserPreferences 8 9 from relay.models.session import UserSession ··· 13 14 "Artist", 14 15 "AudioFormat", 15 16 "Base", 17 + "ExchangeToken", 16 18 "OAuthStateModel", 17 19 "Track", 18 20 "UserPreferences",
+32
src/relay/models/exchange_token.py
··· 1 + """exchange token model for secure OAuth callback flow.""" 2 + 3 + from datetime import UTC, datetime, timedelta 4 + 5 + from sqlalchemy import DateTime, String 6 + from sqlalchemy.orm import Mapped, mapped_column 7 + 8 + from relay.models.database import Base 9 + 10 + 11 + class ExchangeToken(Base): 12 + """temporary one-time use token for exchanging OAuth callback for session. 13 + 14 + prevents exposing session_id in URL by using short-lived exchange tokens instead. 15 + tokens expire after 60 seconds and can only be used once. 16 + """ 17 + 18 + __tablename__ = "exchange_tokens" 19 + 20 + token: Mapped[str] = mapped_column(String(64), primary_key=True, index=True) 21 + session_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True) 22 + created_at: Mapped[datetime] = mapped_column( 23 + DateTime(timezone=True), 24 + default=lambda: datetime.now(UTC), 25 + nullable=False, 26 + ) 27 + expires_at: Mapped[datetime] = mapped_column( 28 + DateTime(timezone=True), 29 + default=lambda: datetime.now(UTC) + timedelta(seconds=60), 30 + nullable=False, 31 + ) 32 + used: Mapped[bool] = mapped_column(default=False, nullable=False)
+245
tests/test_auth.py
··· 1 + """test OAuth authentication and session management.""" 2 + 3 + import json 4 + from datetime import UTC, datetime, timedelta 5 + 6 + from sqlalchemy import select 7 + from sqlalchemy.ext.asyncio import AsyncSession 8 + 9 + from relay._internal.auth import ( 10 + _decrypt_data, 11 + _encrypt_data, 12 + consume_exchange_token, 13 + create_exchange_token, 14 + create_session, 15 + delete_session, 16 + get_session, 17 + update_session_tokens, 18 + ) 19 + from relay.models import ExchangeToken, UserSession 20 + 21 + 22 + def test_encryption_roundtrip(): 23 + """verify encryption and decryption work correctly.""" 24 + original_data = "sensitive oauth data" 25 + 26 + encrypted = _encrypt_data(original_data) 27 + decrypted = _decrypt_data(encrypted) 28 + 29 + assert decrypted == original_data 30 + assert encrypted != original_data # ensure it's actually encrypted 31 + 32 + 33 + def test_encryption_of_json_data(): 34 + """verify encryption works with json-serialized data.""" 35 + oauth_data = { 36 + "did": "did:plc:test123", 37 + "handle": "test.bsky.social", 38 + "access_token": "secret_token_123", 39 + "refresh_token": "secret_refresh_456", 40 + "dpop_private_key_pem": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBg==\n-----END PRIVATE KEY-----", 41 + } 42 + 43 + json_str = json.dumps(oauth_data) 44 + encrypted = _encrypt_data(json_str) 45 + decrypted = _decrypt_data(encrypted) 46 + 47 + assert decrypted is not None 48 + assert json.loads(decrypted) == oauth_data 49 + 50 + 51 + async def test_create_session_with_encryption(db_session: AsyncSession): 52 + """verify session creation encrypts OAuth data.""" 53 + did = "did:plc:session123" 54 + handle = "session.bsky.social" 55 + oauth_data = { 56 + "did": did, 57 + "handle": handle, 58 + "access_token": "secret_token", 59 + "refresh_token": "secret_refresh", 60 + "dpop_private_key_pem": "secret_key", 61 + } 62 + 63 + session_id = await create_session(did, handle, oauth_data) 64 + 65 + # retrieve session and verify it was created correctly 66 + session = await get_session(session_id) 67 + assert session is not None 68 + assert session.did == did 69 + assert session.handle == handle 70 + assert session.oauth_session["access_token"] == "secret_token" 71 + assert session.oauth_session["refresh_token"] == "secret_refresh" 72 + 73 + 74 + async def test_get_session_decrypts_data(db_session: AsyncSession): 75 + """verify get_session correctly decrypts OAuth data.""" 76 + did = "did:plc:decrypt123" 77 + handle = "decrypt.bsky.social" 78 + oauth_data = { 79 + "did": did, 80 + "handle": handle, 81 + "access_token": "secret_token_xyz", 82 + "refresh_token": "secret_refresh_xyz", 83 + } 84 + 85 + session_id = await create_session(did, handle, oauth_data) 86 + 87 + # retrieve and verify decryption 88 + session = await get_session(session_id) 89 + 90 + assert session is not None 91 + assert session.did == did 92 + assert session.handle == handle 93 + assert session.oauth_session["access_token"] == "secret_token_xyz" 94 + assert session.oauth_session["refresh_token"] == "secret_refresh_xyz" 95 + 96 + 97 + async def test_get_session_returns_none_for_invalid_id(db_session: AsyncSession): 98 + """verify get_session returns None for non-existent session.""" 99 + session = await get_session("invalid_session_id_that_does_not_exist") 100 + assert session is None 101 + 102 + 103 + async def test_update_session_tokens(db_session: AsyncSession): 104 + """verify session token update encrypts new data.""" 105 + did = "did:plc:update123" 106 + handle = "update.bsky.social" 107 + original_oauth_data = { 108 + "access_token": "original_token", 109 + "refresh_token": "original_refresh", 110 + } 111 + 112 + session_id = await create_session(did, handle, original_oauth_data) 113 + 114 + # update with new tokens 115 + updated_oauth_data = {"access_token": "new_token", "refresh_token": "new_refresh"} 116 + await update_session_tokens(session_id, updated_oauth_data) 117 + 118 + # verify tokens were updated 119 + session = await get_session(session_id) 120 + assert session is not None 121 + assert session.oauth_session["access_token"] == "new_token" 122 + assert session.oauth_session["refresh_token"] == "new_refresh" 123 + 124 + 125 + async def test_delete_session(db_session: AsyncSession): 126 + """verify session deletion works.""" 127 + did = "did:plc:delete123" 128 + handle = "delete.bsky.social" 129 + oauth_data = {"access_token": "token"} 130 + 131 + session_id = await create_session(did, handle, oauth_data) 132 + 133 + # verify session exists 134 + session = await get_session(session_id) 135 + assert session is not None 136 + 137 + # delete session 138 + await delete_session(session_id) 139 + 140 + # verify session is gone 141 + session = await get_session(session_id) 142 + assert session is None 143 + 144 + 145 + async def test_create_exchange_token(db_session: AsyncSession): 146 + """verify exchange token creation.""" 147 + did = "did:plc:exchange123" 148 + handle = "exchange.bsky.social" 149 + oauth_data = {"access_token": "token"} 150 + 151 + session_id = await create_session(did, handle, oauth_data) 152 + 153 + # create exchange token 154 + token = await create_exchange_token(session_id) 155 + 156 + # verify token can be consumed (proves it was created correctly) 157 + returned_session_id = await consume_exchange_token(token) 158 + assert returned_session_id == session_id 159 + 160 + # verify token can't be reused 161 + second_attempt = await consume_exchange_token(token) 162 + assert second_attempt is None 163 + 164 + 165 + async def test_consume_exchange_token(db_session: AsyncSession): 166 + """verify exchange token consumption works.""" 167 + did = "did:plc:consume123" 168 + handle = "consume.bsky.social" 169 + oauth_data = {"access_token": "token"} 170 + 171 + session_id = await create_session(did, handle, oauth_data) 172 + token = await create_exchange_token(session_id) 173 + 174 + # consume token 175 + returned_session_id = await consume_exchange_token(token) 176 + assert returned_session_id == session_id 177 + 178 + # verify token can't be consumed again (proves it was marked as used) 179 + second_attempt = await consume_exchange_token(token) 180 + assert second_attempt is None 181 + 182 + 183 + async def test_exchange_token_cannot_be_reused(db_session: AsyncSession): 184 + """verify exchange token can only be used once.""" 185 + did = "did:plc:reuse123" 186 + handle = "reuse.bsky.social" 187 + oauth_data = {"access_token": "token"} 188 + 189 + session_id = await create_session(did, handle, oauth_data) 190 + token = await create_exchange_token(session_id) 191 + 192 + # consume token first time 193 + first_result = await consume_exchange_token(token) 194 + assert first_result == session_id 195 + 196 + # try to consume again - should return None 197 + second_result = await consume_exchange_token(token) 198 + assert second_result is None 199 + 200 + 201 + async def test_exchange_token_returns_none_for_invalid_token(db_session: AsyncSession): 202 + """verify consume_exchange_token returns None for invalid token.""" 203 + result = await consume_exchange_token("invalid_token_that_does_not_exist") 204 + assert result is None 205 + 206 + 207 + async def test_exchange_token_expires(db_session: AsyncSession): 208 + """verify expired exchange token returns None.""" 209 + # use a separate database session to manually expire the token 210 + from relay.utilities.database import db_session as get_db_session 211 + 212 + did = "did:plc:expire123" 213 + handle = "expire.bsky.social" 214 + oauth_data = {"access_token": "token"} 215 + 216 + session_id = await create_session(did, handle, oauth_data) 217 + token = await create_exchange_token(session_id) 218 + 219 + # manually expire the token by updating its expiration 220 + async with get_db_session() as db: 221 + result = await db.execute( 222 + select(ExchangeToken).where(ExchangeToken.token == token) 223 + ) 224 + exchange_token = result.scalar_one_or_none() 225 + assert exchange_token is not None 226 + 227 + # set expiration to past 228 + exchange_token.expires_at = datetime.now(UTC) - timedelta(seconds=1) 229 + await db.commit() 230 + 231 + # try to consume expired token - should return None 232 + consumed = await consume_exchange_token(token) 233 + assert consumed is None 234 + 235 + 236 + async def test_session_isolation(db_session: AsyncSession): 237 + """verify each test starts with clean database.""" 238 + # this should not see sessions from other tests 239 + result = await db_session.execute(select(UserSession)) 240 + sessions = result.scalars().all() 241 + assert len(sessions) == 0 242 + 243 + result = await db_session.execute(select(ExchangeToken)) 244 + tokens = result.scalars().all() 245 + assert len(tokens) == 0