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)