feat(backend): per-Reader userscript credential with UI install and rotation (#24)

Each Reader's userscript credential is derived from TOKEN_KEY, their
Discord id and a token epoch (HMAC-SHA256, hex); only its SHA-256 sits in
readers.token_sha256, so install URLs survive restarts while a database
leak yields nothing but hashes. One credential authenticates the script
download path and the API bearer header.

- internal/token: derivation + hashing; migration 0006 adds token_epoch
- seed refreshes the owner's epoch-0 hash only before first rotation
- httpmw.Auth resolves the acting Reader from the credential hash and
  stashes it in the request context; the retired API_TOKEN resolves to
  the owner until API_TOKEN_GRACE_UNTIL, logged per use, on both the
  bearer and script-download paths
- userscript handler renders the bindmounted file with the resolved
  Reader's credential substituted for __API_TOKEN__; a legacy-path
  request during grace serves the derived credential, so devices
  self-migrate on their next update poll
- web UI: Userscripts panel with session-gated install endpoints that
  render the script directly (credential never in markup, address bar
  or a redirect) and confirm-gated rotation; atomic epoch bump + hash
  rewrite in the store
- both userscripts carry __API_TOKEN__ placeholders; the committed
  global-token literal is removed (rotating at deploy retires it for
  real — it survives in git history)
- env: TOKEN_KEY required, API_TOKEN/API_TOKEN_GRACE_UNTIL retire the
  legacy credential; docs and compose updated
This commit is contained in:
2026-08-08 09:34:52 +07:00
parent bcc6b45515
commit 8f752ed86b
25 changed files with 1149 additions and 234 deletions
+86
View File
@@ -75,6 +75,92 @@ func TestOpenIsIdempotent(t *testing.T) {
}
}
// The hash lookup is the whole authentication path: the store resolves a
// Reader from the SHA-256 of their presented credential, and nothing else.
func TestReaderIDForTokenHash(t *testing.T) {
store := newTestStore(t)
ownerHash := sha256.Sum256([]byte("owner-token-hash"))
id, ok, err := store.ReaderIDForTokenHash(ownerHash)
if err != nil {
t.Fatalf("ReaderIDForTokenHash: %v", err)
}
if !ok || id != store.OwnerID() {
t.Fatalf("owner lookup = (%d, %v), want (%d, true)", id, ok, store.OwnerID())
}
if _, ok, err := store.ReaderIDForTokenHash(sha256.Sum256([]byte("nope"))); err != nil {
t.Fatalf("miss: %v", err)
} else if ok {
t.Fatal("unknown hash resolved to a Reader")
}
}
func TestReaderTokenInfo(t *testing.T) {
store := newTestStore(t)
discordID, epoch, err := store.ReaderTokenInfo(store.OwnerID())
if err != nil {
t.Fatalf("ReaderTokenInfo: %v", err)
}
if discordID != testOwner.DiscordID || epoch != 0 {
t.Fatalf("ReaderTokenInfo = (%q, %d), want (%q, 0)", discordID, epoch, testOwner.DiscordID)
}
}
// Rotation swaps the stored hash and bumps the epoch in one step, and the
// seed must not undo it: a restart re-runs seedOwner, which refreshes the
// epoch-0 hash only while the row has never been rotated.
func TestRotateTokenInvalidatesOldAndSurvivesRestart(t *testing.T) {
url := pgtest.URL(t)
store, err := Open(url, testOwner)
if err != nil {
t.Fatalf("Open: %v", err)
}
oldHash := sha256.Sum256([]byte("owner-token-hash"))
newHash := sha256.Sum256([]byte("rotated-token-hash"))
if err := store.RotateToken(store.OwnerID(), 0, newHash); err != nil {
t.Fatalf("RotateToken: %v", err)
}
// A second rotation against the stale epoch is refused: the stored hash
// must never describe a different epoch than the column says.
if err := store.RotateToken(store.OwnerID(), 0, sha256.Sum256([]byte("third-hash"))); err == nil {
t.Fatal("stale-epoch rotation succeeded, want error")
}
if _, ok, err := store.ReaderIDForTokenHash(oldHash); err != nil {
t.Fatalf("old lookup: %v", err)
} else if ok {
t.Fatal("old hash still resolves after rotation")
}
if id, ok, err := store.ReaderIDForTokenHash(newHash); err != nil {
t.Fatalf("new lookup: %v", err)
} else if !ok || id != store.OwnerID() {
t.Fatalf("new hash resolved to (%d, %v), want owner", id, ok)
}
if _, epoch, err := store.ReaderTokenInfo(store.OwnerID()); err != nil {
t.Fatalf("ReaderTokenInfo: %v", err)
} else if epoch != 1 {
t.Fatalf("epoch = %d after rotation, want 1", epoch)
}
store.Close()
reopened, err := Open(url, testOwner)
if err != nil {
t.Fatalf("reopen: %v", err)
}
t.Cleanup(func() { reopened.Close() })
if _, ok, err := reopened.ReaderIDForTokenHash(oldHash); err != nil {
t.Fatalf("old lookup after reopen: %v", err)
} else if ok {
t.Fatal("restart resurrected the pre-rotation hash")
}
if _, ok, err := reopened.ReaderIDForTokenHash(newHash); err != nil {
t.Fatalf("new lookup after reopen: %v", err)
} else if !ok {
t.Fatal("restart dropped the rotated hash")
}
}
func TestStoreGet(t *testing.T) {
store := newTestStore(t)
if _, err := store.Upsert(store.OwnerID(), Bookmark{