fix(web): check guild membership on the OAuth endpoint, not the bot one

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.
This commit is contained in:
2026-08-08 15:56:37 +07:00
parent 77965c3d76
commit 40378192b1
2 changed files with 25 additions and 11 deletions
+15 -8
View File
@@ -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