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:
@@ -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
|
||||
|
||||
+10
-3
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user