27cf0955de
Closes #24. Child of #18; based on current main (includes Postgres, Reader table, Discord OAuth).
## What
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` (new `token_epoch` column, migration 0006). One credential authenticates the script download path and the API bearer header.
- `internal/token`: derivation + hashing; the seed refreshes the owner's epoch-0 hash only before first rotation, so a restart can never resurrect a rotated-away credential
- `httpmw.Auth`/`ResolveReader`: acting Reader resolved from the credential hash, stashed in request context; the retired global `API_TOKEN` resolves to the owner until `API_TOKEN_GRACE_UNTIL` (enforced in code, 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 installed devices self-migrate on their next update poll
- Web UI: "Userscripts" panel — session-gated install endpoints render the script directly (credential never in markup, address bar, or a redirect), confirm-gated rotation with an atomic epoch bump + hash rewrite and a reinstall warning
- Both userscripts carry `__API_TOKEN__` placeholders; the committed global-token literal is removed
## Design note
Credentials are derived rather than stored-random because the server must rebuild install URLs after restarts while the DB holds only hashes. HMAC output is high-entropy and unbrute-forceable; the AC's intent (unguessable, DB-leak-proof) is met.
## Deploy (also in DEPLOY.md)
1. Add `TOKEN_KEY` (`openssl rand -hex 32`) — required; changing it later invalidates every credential.
2. Keep `API_TOKEN` + set `API_TOKEN_GRACE_UNTIL` for the 14-day window.
3. After deploy, sign in → Userscripts → reinstall both scripts on every device. This also retires the old global credential for real — its literal survives in git history (present since 0ef5286), so rotation is what kills it.
## Verification
- Full Go suite green against real Postgres per test; userscript JS suite 45/45
- New router-level tests: per-Reader isolation (read/write/delete), grace expiry on bearer + script path, self-migrating legacy path, install serving, rotation (old cred 401/404, new cred works, install renders new credential), app page leaks no credential
- Store tests: hash lookup, token info, atomic rotation with stale-epoch rejection, rotation survives restart
- Live smoke of the built binary: grace acceptance logged, derived auth, substitution, restart resilience, stored hash = SHA-256 of derived credential
Reviewed-on: #32
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
340 lines
13 KiB
Go
340 lines
13 KiB
Go
package main
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"bookmarkmanager/backend/internal/store"
|
|
"bookmarkmanager/backend/internal/token"
|
|
|
|
_ "github.com/jackc/pgx/v5/stdlib"
|
|
)
|
|
|
|
// insertReader creates an extra reader row (registration is closed, so the
|
|
// store has no path for this — tests reach past it) and returns its id. The
|
|
// credential is derived the same way the owner's is, so it authenticates
|
|
// through the real router.
|
|
func insertReader(t *testing.T, dbURL, discordID string) int64 {
|
|
t.Helper()
|
|
db, err := sql.Open("pgx", dbURL)
|
|
if err != nil {
|
|
t.Fatalf("open db: %v", err)
|
|
}
|
|
defer db.Close()
|
|
hash := token.Hash(token.Token([]byte(testTokenKey), discordID, 0))
|
|
var id int64
|
|
if err := db.QueryRow(
|
|
`INSERT INTO readers (discord_id, token_sha256) VALUES ($1, $2) RETURNING id`,
|
|
discordID, hash[:]).Scan(&id); err != nil {
|
|
t.Fatalf("insert reader: %v", err)
|
|
}
|
|
return id
|
|
}
|
|
|
|
// credRequest builds a request authenticated as the Reader whose credential
|
|
// is passed.
|
|
func credRequest(method, target, cred string) *http.Request {
|
|
req := httptest.NewRequest(method, target, nil)
|
|
req.Header.Set("Authorization", "Bearer "+cred)
|
|
return req
|
|
}
|
|
|
|
// readerCredential is the epoch-0 derived credential of an arbitrary Reader.
|
|
func readerCredential(discordID string) string {
|
|
return token.Token([]byte(testTokenKey), discordID, 0)
|
|
}
|
|
|
|
// withBody attaches a request body, for PUTs that carry a JSON payload.
|
|
func withBody(req *http.Request, body string) *http.Request {
|
|
req.Body = io.NopCloser(strings.NewReader(body))
|
|
req.ContentLength = int64(len(body))
|
|
return req
|
|
}
|
|
|
|
// The retired global token resolves to the owner Reader only while the grace
|
|
// deadline is in the future — testConfig sets it, so the acceptance path is
|
|
// the existing auth() tests; this pins the other side of the window.
|
|
func TestLegacyTokenDeadAfterGrace(t *testing.T) {
|
|
s := newTestStore(t)
|
|
cfg := testConfig()
|
|
cfg.GraceUntil = time.Now().Add(-time.Hour)
|
|
srv := newRouter(s, cfg)
|
|
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, credRequest(http.MethodGet, "/bookmarks", testToken))
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Fatalf("legacy token after grace: status = %d, want 401", rr.Code)
|
|
}
|
|
|
|
// The owner's own derived credential is unaffected by the window closing.
|
|
rr = httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, credRequest(http.MethodGet, "/bookmarks", ownerCredential()))
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("derived token after grace: status = %d, want 200", rr.Code)
|
|
}
|
|
}
|
|
|
|
// A Reader's credential authenticates exactly that Reader: rows written under
|
|
// one credential are invisible to the other, on the same key.
|
|
func TestPerReaderIsolation(t *testing.T) {
|
|
s, dbURL := newTestStoreURL(t)
|
|
insertReader(t, dbURL, "other-reader")
|
|
srv := newRouter(s, testConfig())
|
|
|
|
ownerKey := "asura:solo"
|
|
putBookmark(t, srv, ownerKey, store.Bookmark{
|
|
Key: ownerKey, Site: "asura", SeriesID: "solo",
|
|
Title: "Solo Leveling", UpdatedAt: 1,
|
|
})
|
|
|
|
// The other Reader's list is empty even though the owner holds the key.
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, credRequest(http.MethodGet, "/bookmarks", readerCredential("other-reader")))
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("other reader list: status = %d, want 200", rr.Code)
|
|
}
|
|
var theirs []store.Bookmark
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &theirs); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if len(theirs) != 0 {
|
|
t.Fatalf("other reader sees %d bookmarks, want 0 (owner's rows leaked)", len(theirs))
|
|
}
|
|
|
|
// The other Reader writes the same key; both rows coexist, each visible
|
|
// only to its owner. The series title is shared (ADR-0003) — the
|
|
// reader-owned fields are progress and updated_at.
|
|
req := credRequest(http.MethodPut, "/bookmarks/"+ownerKey, readerCredential("other-reader"))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
body := `{"key":"asura:solo","site":"asura","series_id":"solo","title":"Theirs","last_chapter_num":3}`
|
|
rr = httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, withBody(req, body))
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("other reader put: status = %d, want 200", rr.Code)
|
|
}
|
|
|
|
rr = httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, credRequest(http.MethodGet, "/bookmarks", readerCredential("other-reader")))
|
|
var theirs2 []store.Bookmark
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &theirs2); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if len(theirs2) != 1 || theirs2[0].LastChapterNum != 3 {
|
|
t.Fatalf("other reader list = %+v, want their own row with their progress", theirs2)
|
|
}
|
|
|
|
rr = httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, credRequest(http.MethodGet, "/bookmarks", ownerCredential()))
|
|
var owners []store.Bookmark
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &owners); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if len(owners) != 1 || owners[0].Title != "Solo Leveling" {
|
|
t.Fatalf("owner list = %+v, want their own row", owners)
|
|
}
|
|
|
|
// One Reader's credential cannot delete the other's row.
|
|
req = credRequest(http.MethodDelete, "/bookmarks/"+ownerKey, readerCredential("other-reader"))
|
|
rr = httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusNoContent {
|
|
t.Fatalf("other reader delete: status = %d, want 204", rr.Code)
|
|
}
|
|
rr = httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, credRequest(http.MethodGet, "/bookmarks", ownerCredential()))
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &owners); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if len(owners) != 1 {
|
|
t.Fatalf("owner's row was deletable by another Reader: list = %+v", owners)
|
|
}
|
|
}
|
|
|
|
// The install endpoints are session-gated and render the script directly
|
|
// with the Reader's credential inside: the credential never appears in the
|
|
// address bar, the page markup, or any Location header.
|
|
func TestInstallServesScriptWithCredential(t *testing.T) {
|
|
cfg := webConfig()
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "manga-bookmark.user.js")
|
|
novelPath := filepath.Join(dir, "novel-bookmark.user.js")
|
|
for _, p := range []string{path, novelPath} {
|
|
if err := os.WriteFile(p, []byte("const API_TOKEN = \"__API_TOKEN__\";\n"), 0o644); err != nil {
|
|
t.Fatalf("write script: %v", err)
|
|
}
|
|
}
|
|
cfg.UserscriptPath = path
|
|
cfg.NovelUserscriptPath = novelPath
|
|
srv, st := newWebTestServer(t, cfg)
|
|
|
|
for _, script := range []string{"manga-bookmark.user.js", "novel-bookmark.user.js"} {
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/install/"+script, nil))
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Fatalf("%s without session: status = %d, want 401", script, rr.Code)
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/install/"+script, nil)
|
|
req.AddCookie(sessionCookie(t, st))
|
|
rr = httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("%s with session: status = %d, want 200", script, rr.Code)
|
|
}
|
|
body := rr.Body.String()
|
|
if strings.Contains(body, "__API_TOKEN__") {
|
|
t.Fatalf("%s served with an unsubstituted placeholder", script)
|
|
}
|
|
// The credential rides inside the served script — nowhere visible in
|
|
// the UI — and is the session holder's own.
|
|
if !strings.Contains(body, `API_TOKEN = "`+ownerCredential()+`"`) {
|
|
t.Fatalf("%s does not carry the owner's credential:\n%s", script, body)
|
|
}
|
|
if loc := rr.Header().Get("Location"); loc != "" {
|
|
t.Fatalf("%s answered with a redirect, credential in Location %q", script, loc)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The retired global token also keeps the script download path working during
|
|
// the grace window — that is how already-installed scripts auto-update across
|
|
// the cutover — and dies with it. The copy served on the legacy path embeds
|
|
// the Reader's derived credential, so the next update poll migrates the
|
|
// device onto its per-Reader path: the window empties itself.
|
|
func TestLegacyTokenUserscriptPathDuringGrace(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "manga-bookmark.user.js")
|
|
if err := os.WriteFile(path, []byte("const API_TOKEN = \"__API_TOKEN__\";\n"), 0o644); err != nil {
|
|
t.Fatalf("write script: %v", err)
|
|
}
|
|
|
|
s := newTestStore(t)
|
|
cfg := testConfig()
|
|
cfg.UserscriptPath = path
|
|
|
|
// Within the window the legacy URL serves the script, but with the
|
|
// owner's derived credential substituted — not the legacy one.
|
|
rr := httptest.NewRecorder()
|
|
newRouter(s, cfg).ServeHTTP(rr, httptest.NewRequest(http.MethodGet,
|
|
"/u/"+testToken+"/manga-bookmark.user.js", nil))
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("legacy path during grace: status = %d, want 200", rr.Code)
|
|
}
|
|
if got := rr.Body.String(); !strings.Contains(got, `API_TOKEN = "`+ownerCredential()+`"`) {
|
|
t.Fatalf("legacy-path script does not carry the derived credential:\n%s", got)
|
|
}
|
|
|
|
// After the deadline the same URL is a 404 like any unknown credential.
|
|
cfg.GraceUntil = time.Now().Add(-time.Hour)
|
|
rr = httptest.NewRecorder()
|
|
newRouter(s, cfg).ServeHTTP(rr, httptest.NewRequest(http.MethodGet,
|
|
"/u/"+testToken+"/manga-bookmark.user.js", nil))
|
|
if rr.Code != http.StatusNotFound {
|
|
t.Fatalf("legacy path after grace: status = %d, want 404", rr.Code)
|
|
}
|
|
}
|
|
|
|
// Rotation through the web UI invalidates the old credential immediately,
|
|
// mints one that authenticates the API and the script path, and warns that
|
|
// every device must reinstall.
|
|
func TestRotateCredentialViaWebUI(t *testing.T) {
|
|
s, _ := newTestStoreURL(t)
|
|
path := filepath.Join(t.TempDir(), "manga-bookmark.user.js")
|
|
if err := os.WriteFile(path, []byte("const API_TOKEN = \"__API_TOKEN__\";\n"), 0o644); err != nil {
|
|
t.Fatalf("write script: %v", err)
|
|
}
|
|
cfg := webConfig()
|
|
cfg.UserscriptPath = path
|
|
srv := newRouter(s, cfg)
|
|
|
|
oldCred := ownerCredential()
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, credRequest(http.MethodGet, "/bookmarks", oldCred))
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("old credential before rotation: status = %d, want 200", rr.Code)
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/rotate-token", nil)
|
|
req.AddCookie(sessionCookie(t, s))
|
|
rr = httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("rotate: status = %d, want 200", rr.Code)
|
|
}
|
|
if !strings.Contains(rr.Body.String(), "Credential rotated") {
|
|
t.Fatalf("rotation response does not warn about reinstall:\n%s", rr.Body.String())
|
|
}
|
|
|
|
// The old credential is dead on the API and on the script path.
|
|
rr = httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, credRequest(http.MethodGet, "/bookmarks", oldCred))
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Fatalf("old credential after rotation: status = %d, want 401", rr.Code)
|
|
}
|
|
rr = httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/u/"+oldCred+"/manga-bookmark.user.js", nil))
|
|
if rr.Code != http.StatusNotFound {
|
|
t.Fatalf("old credential script path after rotation: status = %d, want 404", rr.Code)
|
|
}
|
|
|
|
// The new credential authenticates the API and the script path, and is
|
|
// substituted into the served script.
|
|
newCred := token.Token([]byte(testTokenKey), testDiscordID, 1)
|
|
rr = httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, credRequest(http.MethodGet, "/bookmarks", newCred))
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("new credential after rotation: status = %d, want 200", rr.Code)
|
|
}
|
|
rr = httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/u/"+newCred+"/manga-bookmark.user.js", nil))
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("new credential script path: status = %d, want 200", rr.Code)
|
|
}
|
|
if got := rr.Body.String(); !strings.Contains(got, `API_TOKEN = "`+newCred+`"`) {
|
|
t.Fatalf("served script does not carry the rotated credential:\n%s", got)
|
|
}
|
|
|
|
// The install link now renders the script with the new credential.
|
|
req = httptest.NewRequest(http.MethodGet, "/install/manga-bookmark.user.js", nil)
|
|
req.AddCookie(sessionCookie(t, s))
|
|
rr = httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("install after rotation: status = %d, want 200", rr.Code)
|
|
}
|
|
if got := rr.Body.String(); !strings.Contains(got, `API_TOKEN = "`+newCred+`"`) {
|
|
t.Fatalf("install after rotation does not carry the new credential:\n%s", got)
|
|
}
|
|
}
|
|
|
|
// The app page offers the install links; the credential never appears in its
|
|
// markup.
|
|
func TestIndexShowsSetupPanelWithoutCredential(t *testing.T) {
|
|
srv, st := newWebTestServer(t, webConfig())
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req.AddCookie(sessionCookie(t, st))
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, req)
|
|
|
|
body := rr.Body.String()
|
|
for _, want := range []string{
|
|
`href="/install/manga-bookmark.user.js"`,
|
|
`href="/install/novel-bookmark.user.js"`,
|
|
"Rotate credential",
|
|
} {
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("app page lacks %q", want)
|
|
}
|
|
}
|
|
if strings.Contains(body, ownerCredential()) {
|
|
t.Fatal("app page leaks the credential")
|
|
}
|
|
}
|