From 40378192b14e494685bb74f573a9760d13cfd74c Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Sat, 8 Aug 2026 15:56:37 +0700 Subject: [PATCH] fix(web): check guild membership on the OAuth endpoint, not the bot one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The login gate called GET /guilds/{guild}/members/{user} — the Guild resource's Get Guild Member, which wants a Bot token and the application present in the guild. Handed a user Bearer token it answers 401, which discordMember reports as an error, so every sign-in rendered "Discord sign-in is unavailable right now" and nobody could get in. The endpoint guilds.members.read actually grants is Get Current User Guild Member, GET /users/@me/guilds/{guild}/member. Same single-guild question, same privacy property, and it takes the token we hold. #18 flagged this as verified from Discord's documentation but never from a live flow; it was wrong. The stub mirrored the implementation, so the suite could not see it. It now serves the OAuth path and answers the bot path 401 the way Discord does — without that, a regression falls through to 404 and reads as an ordinary "not a member" refusal instead of failing. --- backend/internal/web/discord.go | 23 +++++++++++++++-------- backend/web_test.go | 13 ++++++++++--- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/backend/internal/web/discord.go b/backend/internal/web/discord.go index 930c72c..f25ab4c 100644 --- a/backend/internal/web/discord.go +++ b/backend/internal/web/discord.go @@ -174,7 +174,7 @@ func (h *Handler) discordCallback(w http.ResponseWriter, r *http.Request) { return } - member, isMember, err := h.discordMember(r.Context(), tok.AccessToken, userID) + member, isMember, err := h.discordMember(r.Context(), tok.AccessToken) if err != nil { h.limiter.Fail(ip, time.Now()) log.Printf("discord member check: %v", err) @@ -272,13 +272,20 @@ type discordMember struct { Roles []string `json:"roles"` } -// discordMember fetches the user's membership in the configured guild — the -// single-guild endpoint, not the list of every guild the user is in, so the -// gate asks exactly the question it names. A 404 or 403 (not in the guild, or -// the token lacks the scope) is a non-member, not an error. -func (h *Handler) discordMember(ctx context.Context, accessToken, userID string) (discordMember, bool, error) { - u := h.discord.APIBase + "/guilds/" + url.PathEscape(h.discord.GuildID) + - "/members/" + url.PathEscape(userID) +// discordMember fetches the current user's membership in the configured guild. +// +// This is the OAuth endpoint (Get Current User Guild Member), the one the +// guilds.members.read scope grants. Its bot-side twin, GET /guilds/{id}/ +// members/{user}, reads almost identically and is the wrong one: it wants a +// Bot token and the application present in the guild, and answers a user +// Bearer token with 401 — which fails as an outage rather than a refusal, so +// nobody could sign in at all. +// +// A 404 or 403 (not in the guild, or the token lacks the scope) is a +// non-member, not an error. +func (h *Handler) discordMember(ctx context.Context, accessToken string) (discordMember, bool, error) { + u := h.discord.APIBase + "/users/@me/guilds/" + + url.PathEscape(h.discord.GuildID) + "/member" req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) if err != nil { return discordMember{}, false, err diff --git a/backend/web_test.go b/backend/web_test.go index 8240c48..336208b 100644 --- a/backend/web_test.go +++ b/backend/web_test.go @@ -103,7 +103,13 @@ func newDiscordStub(t *testing.T) (*discordStub, *httptest.Server) { if status == http.StatusOK { fmt.Fprintf(w, `{"id":%q,"username":"owner"}`, st.ownerID) } + // Discord answers the bot endpoint with 401 for a user Bearer token. + // Standing in for that keeps a regression onto it loud: without this + // the request would fall through to 404 and read as "not a member", + // which is a refusal the caller treats as ordinary. case strings.HasPrefix(r.URL.Path, "/guilds/"): + w.WriteHeader(http.StatusUnauthorized) + case strings.HasPrefix(r.URL.Path, "/users/@me/guilds/"): st.memberPaths = append(st.memberPaths, r.URL.Path) st.memberAuth = append(st.memberAuth, r.Header.Get("Authorization")) status := st.memberStatus @@ -299,12 +305,13 @@ func TestDiscordLoginFullFlow(t *testing.T) { } // Identity and membership were fetched with the exchanged token, and the - // membership check used the single-guild endpoint. + // membership check used the OAuth single-guild endpoint — the one + // guilds.members.read grants, not its bot-token twin. if len(stub.userAuth) != 1 || stub.userAuth[0] != "Bearer tok-1" { t.Fatalf("users/@me Authorization = %v, want [Bearer tok-1]", stub.userAuth) } - if len(stub.memberPaths) != 1 || stub.memberPaths[0] != "/guilds/guild-1/members/owner-snowflake" { - t.Fatalf("member requests = %v, want the single-guild endpoint", stub.memberPaths) + if len(stub.memberPaths) != 1 || stub.memberPaths[0] != "/users/@me/guilds/guild-1/member" { + t.Fatalf("member requests = %v, want the OAuth single-guild endpoint", stub.memberPaths) } if len(stub.memberAuth) != 1 || stub.memberAuth[0] != "Bearer tok-1" { t.Fatalf("member Authorization = %v, want [Bearer tok-1]", stub.memberAuth)