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