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:
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user