feat(backend): file-scoped content tokens for media URLs

Opening an original by URL (?access_token=) baked in the 15-minute access
token, so a long video opened in a new tab stopped streaming once that token
expired mid-playback: the access token can't be refreshed in an already-opened
tab, and its next Range request 401'd.

Add a content token: a signed, single-file capability (typ=content, fid claim)
with its own longer TTL (CONTENT_TOKEN_TTL, default 6h) and — crucially — no
session id, so it survives refresh rotation and outlives the short access TTL.
POST /files/:id/content-token mints one after the same view-ACL check content
serving does; GET /files/:id/content now runs under content-aware auth that
accepts either a normal access token or a content token scoped to that file.
View permission is still enforced against the token's user, so the token only
changes when a file may be read by URL, never which files. It's a bearer
capability for that one file until expiry, hence the bounded, configurable TTL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 17:53:10 +03:00
parent b470782e97
commit 98de298e5b
9 changed files with 275 additions and 7 deletions
+50
View File
@@ -5,6 +5,7 @@ import (
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"tanabata/backend/internal/domain"
"tanabata/backend/internal/service"
@@ -50,6 +51,55 @@ func (m *AuthMiddleware) Handle() gin.HandlerFunc {
}
}
// HandleContent authenticates a file-content GET, accepting either a normal
// access token or a content token scoped (by its fid claim) to the :id in the
// path. The content token is what keeps a long media stream playing after the
// short access token would have expired. View permission is still enforced in
// the handler against the resolved user, so a content token only widens *when*
// a file may be read by URL, never *which* files.
func (m *AuthMiddleware) HandleContent() gin.HandlerFunc {
return func(c *gin.Context) {
token := bearerToken(c)
if token == "" {
contentUnauthorized(c)
return
}
// A regular access token grants access to everything as usual.
if claims, err := m.authSvc.ValidateAccessToken(c.Request.Context(), token); err == nil {
ctx := domain.WithUser(c.Request.Context(), claims.UserID, claims.IsAdmin, claims.SessionID)
c.Request = c.Request.WithContext(ctx)
c.Next()
return
}
// Otherwise accept a content token minted for exactly this file. Normalise
// the path id to canonical form so it matches the minted fid claim.
id, err := uuid.Parse(c.Param("id"))
if err != nil {
contentUnauthorized(c)
return
}
claims, err := m.authSvc.ValidateContentToken(token, id.String())
if err != nil {
contentUnauthorized(c)
return
}
// A content token carries no session (sid 0); it is session-independent.
ctx := domain.WithUser(c.Request.Context(), claims.UserID, claims.IsAdmin, claims.SessionID)
c.Request = c.Request.WithContext(ctx)
c.Next()
}
}
func contentUnauthorized(c *gin.Context) {
c.JSON(http.StatusUnauthorized, errorBody{
Code: domain.ErrUnauthorized.Code(),
Message: "invalid or expired token",
})
c.Abort()
}
// bearerToken extracts the access token from the Authorization header. As a
// fallback it accepts an ?access_token= query parameter, but only for GET
// requests — this lets the browser open media (e.g. /files/{id}/content) via a