feat(stream): require auth on GET /stream/{id} via token query param
Docker Build & Publish / build (push) Has been cancelled
Docker Build & Publish / push (push) Has been cancelled
Docker Build & Publish / Prune old image versions (push) Has been cancelled

The audio stream endpoint was unauthenticated. Add a get_streaming_user
dependency that accepts the access token either as a ?token= query param
(the browser <audio> element can't send an Authorization header) or a
bearer header for native clients. Update streaming tests accordingly and
add a test asserting unauthenticated requests are rejected with 401.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Senko-san
2026-06-08 17:11:43 +03:00
parent 5c5df5d3cc
commit 4ade6939b6
3 changed files with 34 additions and 4 deletions
+20
View File
@@ -167,3 +167,23 @@ async def get_current_superuser(user: CurrentUser) -> User:
SuperUser = Annotated[User, Depends(get_current_superuser)] SuperUser = Annotated[User, Depends(get_current_superuser)]
async def get_streaming_user(
auth: AuthServiceDep,
credentials: BearerDep,
token: str | None = None,
) -> User:
"""Authenticate a stream request.
The browser ``<audio>`` element cannot send an ``Authorization`` header, so
the access token is accepted as a ``?token=`` query param; native clients may
still use a bearer header. Either way it's the same access token.
"""
raw = token or (credentials.credentials if credentials else None)
if not raw:
raise AuthenticationError("Missing access token.")
return await auth.authenticate_access(raw)
StreamUser = Annotated[User, Depends(get_streaming_user)]
+2 -1
View File
@@ -6,7 +6,7 @@ from typing import Annotated
from fastapi import APIRouter, Header from fastapi import APIRouter, Header
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from app.api.deps import StreamingServiceDep from app.api.deps import StreamingServiceDep, StreamUser
router = APIRouter(prefix="/stream", tags=["streaming"]) router = APIRouter(prefix="/stream", tags=["streaming"])
@@ -15,6 +15,7 @@ router = APIRouter(prefix="/stream", tags=["streaming"])
async def stream_track( async def stream_track(
track_id: uuid.UUID, track_id: uuid.UUID,
service: StreamingServiceDep, service: StreamingServiceDep,
_user: StreamUser,
range_header: Annotated[str | None, Header(alias="Range")] = None, range_header: Annotated[str | None, Header(alias="Range")] = None,
) -> StreamingResponse: ) -> StreamingResponse:
result = await service.open_stream(track_id, range_header) result = await service.open_stream(track_id, range_header)
+12 -3
View File
@@ -143,7 +143,8 @@ async def test_stream_full(api: AsyncClient) -> None:
assert up.status_code == 200 assert up.status_code == 200
track_id = up.json()["track_id"] track_id = up.json()["track_id"]
resp = await api.get(f"/api/v1/stream/{track_id}") # Browser <audio> can't send headers — auth rides on the ?token= query param.
resp = await api.get(f"/api/v1/stream/{track_id}?token={token}")
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.content == audio assert resp.content == audio
assert resp.headers["content-type"].startswith("audio/mpeg") assert resp.headers["content-type"].startswith("audio/mpeg")
@@ -164,7 +165,7 @@ async def test_stream_range(api: AsyncClient) -> None:
resp = await api.get( resp = await api.get(
f"/api/v1/stream/{track_id}", f"/api/v1/stream/{track_id}",
headers={"Range": "bytes=0-9"}, headers={"Range": "bytes=0-9", "Authorization": f"Bearer {token}"},
) )
assert resp.status_code == 206 assert resp.status_code == 206
assert resp.content == b"0123456789" assert resp.content == b"0123456789"
@@ -173,10 +174,18 @@ async def test_stream_range(api: AsyncClient) -> None:
async def test_stream_not_found(api: AsyncClient) -> None: async def test_stream_not_found(api: AsyncClient) -> None:
resp = await api.get("/api/v1/stream/00000000-0000-0000-0000-000000000000") token = await _login(api)
resp = await api.get(
f"/api/v1/stream/00000000-0000-0000-0000-000000000000?token={token}"
)
assert resp.status_code == 404 assert resp.status_code == 404
async def test_stream_requires_auth(api: AsyncClient) -> None:
resp = await api.get("/api/v1/stream/00000000-0000-0000-0000-000000000000")
assert resp.status_code == 401
async def test_upload_requires_auth(api: AsyncClient) -> None: async def test_upload_requires_auth(api: AsyncClient) -> None:
resp = await api.post( resp = await api.post(
"/api/v1/upload", "/api/v1/upload",