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
@@ -0,0 +1,80 @@
package service
import (
"testing"
"time"
)
// newContentTokenService builds an AuthService for content-token tests. The
// content-token methods never touch the user/session repos, so nil is fine.
func newContentTokenService(contentTTL time.Duration) *AuthService {
return NewAuthService(nil, nil, "test-secret", 15*time.Minute, 720*time.Hour, contentTTL)
}
func TestContentTokenRoundTrip(t *testing.T) {
s := newContentTokenService(time.Hour)
const fid = "11111111-1111-1111-1111-111111111111"
tok, expiresIn, err := s.GenerateContentToken(fid, 7, true)
if err != nil {
t.Fatalf("GenerateContentToken: %v", err)
}
if expiresIn != int(time.Hour.Seconds()) {
t.Fatalf("expires_in = %d, want %d", expiresIn, int(time.Hour.Seconds()))
}
claims, err := s.ValidateContentToken(tok, fid)
if err != nil {
t.Fatalf("ValidateContentToken: %v", err)
}
if claims.UserID != 7 || !claims.IsAdmin {
t.Fatalf("claims user mismatch: uid=%d adm=%v", claims.UserID, claims.IsAdmin)
}
if claims.FileID != fid || claims.TokenType != tokenTypeContent {
t.Fatalf("claims scope mismatch: fid=%q typ=%q", claims.FileID, claims.TokenType)
}
}
func TestContentTokenRejectsOtherFile(t *testing.T) {
s := newContentTokenService(time.Hour)
tok, _, err := s.GenerateContentToken("11111111-1111-1111-1111-111111111111", 7, false)
if err != nil {
t.Fatal(err)
}
// A token minted for one file must not authorize another.
if _, err := s.ValidateContentToken(tok, "22222222-2222-2222-2222-222222222222"); err == nil {
t.Fatal("expected rejection for a different file id")
}
}
func TestContentTokenRejectsAccessToken(t *testing.T) {
s := newContentTokenService(time.Hour)
// An ordinary access token must not pass as a content token (wrong type).
access, err := s.issueToken(7, false, 1, 15*time.Minute, tokenTypeAccess)
if err != nil {
t.Fatal(err)
}
if _, err := s.ValidateContentToken(access, ""); err == nil {
t.Fatal("expected rejection of an access token as a content token")
}
}
func TestContentTokenRejectsExpired(t *testing.T) {
// Negative TTL → the token is already expired when minted.
s := newContentTokenService(-time.Minute)
const fid = "11111111-1111-1111-1111-111111111111"
tok, _, err := s.GenerateContentToken(fid, 7, false)
if err != nil {
t.Fatal(err)
}
if _, err := s.ValidateContentToken(tok, fid); err == nil {
t.Fatal("expected rejection of an expired content token")
}
}
func TestContentTokenRejectsGarbage(t *testing.T) {
s := newContentTokenService(time.Hour)
if _, err := s.ValidateContentToken("not-a-jwt", "11111111-1111-1111-1111-111111111111"); err == nil {
t.Fatal("expected rejection of a malformed token")
}
}