feat(server): HMAC stream token auth path (UPnP slice 1/6)
Adds SignStreamToken / VerifyStreamToken (HMAC-SHA256 over trackID|exp) and modifies handleGetStream to accept either the existing session cookie OR a valid signed token. Stream route moved out of the authed group so the handler's own auth check runs and the token bypass is reachable. Enables Sonos / UPnP speakers to fetch the stream URL without carrying the user's session cookie - they cannot. The token is short-lived (max 24h per the design); expiry checked at request time only, not per-byte, so long tracks play through. streamSecret field on handlers is nil for now; Task 2 wires the loader (env var with auto-generated fallback persisted in app_preferences). Adds auth.OptionalUser - the permissive sibling of RequireUser that attaches the user to context when a valid cookie / bearer is present but does NOT 401 on absence. The stream route is wrapped with it so the handler can fall through to the token path when no session is present. newLibraryRouter (test fixture) gets a synthetic-user middleware on the stream route so existing media_test tests keep passing without seeding a real session row - production traffic uses auth.OptionalUser, the test path uses auth.UserCtxKeyForTest(). Five tests cover round-trip, tampered token rejection, expiry, wrong-track-ID, and wrong-secret rejection. CI verifies.
This commit is contained in:
+22
-1
@@ -54,6 +54,17 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
api.Post("/auth/register", h.handleRegister)
|
||||
api.Post("/auth/forgot-password", h.handleForgotPassword)
|
||||
api.Post("/auth/reset-password", h.handleResetPassword)
|
||||
|
||||
// Stream lives outside authed.Group so it can accept EITHER a
|
||||
// session (resolved by the OptionalUser middleware) OR a signed
|
||||
// query token (UPnP / Sonos path; see streamAuthOk). The
|
||||
// middleware attaches user to context when a valid cookie /
|
||||
// bearer is present but does NOT 401 on absence; the handler's
|
||||
// own streamAuthOk performs the actual auth check. See the
|
||||
// design at
|
||||
// docs/superpowers/specs/2026-06-03-android-output-picker-upnp-design.md.
|
||||
api.With(auth.OptionalUser(pool, logger)).Get("/tracks/{id}/stream", h.handleGetStream)
|
||||
|
||||
api.Group(func(authed chi.Router) {
|
||||
authed.Use(auth.RequireUser(pool))
|
||||
authed.Post("/auth/logout", h.handleLogout)
|
||||
@@ -77,7 +88,8 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
authed.Get("/library/albums", h.handleListLibraryAlbums)
|
||||
authed.Get("/library/sync", h.handleLibrarySync)
|
||||
authed.Get("/tracks/{id}", h.handleGetTrack)
|
||||
authed.Get("/tracks/{id}/stream", h.handleGetStream)
|
||||
// /tracks/{id}/stream is mounted above with OptionalUser so
|
||||
// it can accept either a session or a signed token.
|
||||
authed.Get("/search", h.handleSearch)
|
||||
authed.Get("/radio", h.handleRadio)
|
||||
authed.Get("/discover/suggestions", h.handleListSuggestions)
|
||||
@@ -205,4 +217,13 @@ type handlers struct {
|
||||
mailer mailer.Sender
|
||||
eventbus *eventbus.Bus
|
||||
playlistScheduler *playlists.Scheduler
|
||||
// streamSecret is the HMAC key used by SignStreamToken /
|
||||
// VerifyStreamToken to authenticate the UPnP-speaker stream path
|
||||
// (see internal/api/stream_token.go and the design at
|
||||
// docs/superpowers/specs/2026-06-03-android-output-picker-upnp-design.md).
|
||||
// nil in slice 1; slice 2 wires the env-var-with-app_preferences-
|
||||
// fallback loader. A nil secret leaves the cookie path intact and
|
||||
// makes the token path unreachable (HMAC of empty key won't match
|
||||
// anything a client mints), which is the desired slice-1 default.
|
||||
streamSecret []byte
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user