feat(backend): Discord OAuth login with DB-backed sessions (#23)

The browser UI signs in with a Discord authorization code grant
(identify + guilds.members.read) instead of a shared password. Guild
membership is the gate; the owner's Discord ID is the only identity
allowed in while registration is closed. Sessions become rows in a
sessions table with opaque random ids — the cookie carries only the id,
looked up and expiry-checked per request — so deleting a row revokes a
session. HMAC cookie signing, its derived key, and WEB_PASSWORD are
gone, and no replacement signing secret is introduced (ADR-0002).

Discord's API base is configurable (DISCORD_API_BASE); the full flow is
tested through the real router against a local stub, including the
form-encoded token exchange Discord rejects if sent as JSON.
This commit is contained in:
2026-08-08 08:31:46 +07:00
parent 8cebb94b92
commit 13e8e73da7
19 changed files with 1272 additions and 357 deletions
+37 -15
View File
@@ -36,14 +36,23 @@ func newTestServer(t *testing.T) http.Handler {
func newTestStore(t *testing.T) *store.Store {
t.Helper()
s, err := store.Open(pgtest.URL(t), store.Owner{
s, _ := newTestStoreURL(t)
return s
}
// newTestStoreURL is newTestStore plus the database URL, for tests that need
// to reach the same database directly.
func newTestStoreURL(t *testing.T) (*store.Store, string) {
t.Helper()
url := pgtest.URL(t)
s, err := store.Open(url, store.Owner{
DiscordID: "test-owner", TokenHash: sha256.Sum256([]byte("owner-token-hash")),
})
if err != nil {
t.Fatalf("store.Open: %v", err)
}
t.Cleanup(func() { s.Close() })
return s
return s, url
}
func auth(req *http.Request) *http.Request {
@@ -538,16 +547,29 @@ func TestLatestChapterNullable(t *testing.T) {
}
}
func TestLoadConfigWebPassword(t *testing.T) {
t.Setenv("API_TOKEN", "token-abc")
t.Setenv("WEB_PASSWORD", "hunter2")
if got := loadConfig().WebPassword; got != "hunter2" {
t.Fatalf("WebPassword = %q, want hunter2", got)
func TestLoadConfigDiscord(t *testing.T) {
t.Setenv("DISCORD_CLIENT_ID", "client-1")
t.Setenv("DISCORD_CLIENT_SECRET", "client-secret-1")
t.Setenv("DISCORD_GUILD_ID", "guild-1")
t.Setenv("DISCORD_REQUIRED_ROLE", "role-9")
t.Setenv("DISCORD_REDIRECT_URI", "https://bm.example.com/auth/discord/callback")
t.Setenv("DISCORD_API_BASE", "https://stub.example/api")
if got := loadConfig().Discord; got.ClientID != "client-1" || got.ClientSecret != "client-secret-1" ||
got.GuildID != "guild-1" || got.RequiredRole != "role-9" ||
got.RedirectURI != "https://bm.example.com/auth/discord/callback" ||
got.APIBBase != "https://stub.example/api" {
t.Fatalf("Discord config = %+v, want every field set", got)
}
t.Setenv("WEB_PASSWORD", "")
if got := loadConfig().WebPassword; got != "" {
t.Fatalf("WebPassword = %q with the variable unset, want empty", got)
// API base falls back to the Discord default; the role is optional.
t.Setenv("DISCORD_REQUIRED_ROLE", "")
t.Setenv("DISCORD_API_BASE", "")
got := loadConfig().Discord
if got.RequiredRole != "" {
t.Fatalf("RequiredRole = %q, want empty by default", got.RequiredRole)
}
if got.APIBBase != "https://discord.com/api/v10" {
t.Fatalf("APIBBase = %q, want the Discord default", got.APIBBase)
}
}
@@ -578,9 +600,9 @@ func TestPutDoesNotClobberLatestCheckedAt(t *testing.T) {
}
}
// The userscript route is registered outside the `if cfg.WebPassword != ""`
// block in newRouter, so it must keep working on a deployment that never set
// WEB_PASSWORD — see internal/userscript for the handler's own behaviour.
// The userscript route is registered outside the web UI's Discord auth, so it
// must keep working whatever the web config — see internal/userscript for the
// handler's own behaviour.
func TestUserscriptServedWithWebUIDisabled(t *testing.T) {
path := filepath.Join(t.TempDir(), "manga-bookmark.user.js")
if err := os.WriteFile(path, []byte("console.log(1);\n"), 0o644); err != nil {
@@ -588,7 +610,7 @@ func TestUserscriptServedWithWebUIDisabled(t *testing.T) {
}
s := newTestStore(t)
cfg := testConfig() // WebPassword empty
cfg := testConfig() // no Discord config needed for the userscript route
cfg.UserscriptPath = path
rr := httptest.NewRecorder()
@@ -600,7 +622,7 @@ func TestUserscriptServedWithWebUIDisabled(t *testing.T) {
}
// Both scripts are served from the same handler on the same token, outside the
// WEB_PASSWORD gate — a wrong token is a 404, never a 401.
// web UI's auth — a wrong token is a 404, never a 401.
func TestNovelUserscriptServed(t *testing.T) {
dir := t.TempDir()
novelPath := filepath.Join(dir, "novel-bookmark.user.js")