package web import ( "context" "encoding/json" "errors" "fmt" "io" "log" "net/http" "net/url" "slices" "strconv" "strings" "sync" "time" "bookmarkmanager/backend/internal/session" "bookmarkmanager/backend/internal/token" ) const ( // oauthStateTTL bounds how long a started sign-in stays valid. Ten // minutes is generous for Discord's round trip and short enough that a // captured state is stale before it is worth replaying. oauthStateTTL = 10 * time.Minute // maxStates caps the state map so a flood of /auth/discord hits cannot // grow memory; past the cap the oldest state is evicted, which at worst // invalidates an in-flight sign-in. maxStates = 256 // maxResponseBytes caps Discord API bodies; they are small, and an // unbounded read is an OOM handed to Discord's CDN. maxResponseBytes = 1 << 20 // discordTimeout keeps a hung Discord request from hanging the login // callback forever. discordTimeout = 15 * time.Second ) // DiscordConfig is the OAuth application this service registers as, plus the // guild that gates access. type DiscordConfig struct { ClientID string ClientSecret string GuildID string // GuildName is a human-readable name for the guild that gates access. // It is never fetched from Discord; it is an optional display string the // operator sets (DISCORD_GUILD_NAME) so the login screen can name the // community without inventing one. GuildName string // RequiredRole, when non-empty, is a role ID a member must hold on top of // guild membership. Empty by default: membership alone suffices. RequiredRole string // APIBase is the Discord API root; configurable so tests run the whole // flow against a local stub. APIBase string // RedirectURI is the full public URL of the callback — Discord requires // the exact string, so it is configured, never derived from headers. RedirectURI string } // oauthStates stores one-time sign-in states. A state is generated at // /auth/discord, echoed back by Discord at the callback, and consumed there. type oauthStates struct { mu sync.Mutex expiry map[string]time.Time } func newOAuthStates() *oauthStates { return &oauthStates{expiry: make(map[string]time.Time)} } func (s *oauthStates) put(state string, expires time.Time) { s.mu.Lock() defer s.mu.Unlock() now := time.Now() for k, at := range s.expiry { if !at.After(now) { delete(s.expiry, k) } } // Evict the state closest to expiring when full, so a flood of starts // cannot grow memory; at worst it invalidates an in-flight sign-in. if len(s.expiry) >= maxStates { var oldest string var oldestAt time.Time for k, at := range s.expiry { if oldest == "" || at.Before(oldestAt) { oldest, oldestAt = k, at } } delete(s.expiry, oldest) } s.expiry[state] = expires } // take validates and consumes a state in one step: a state works exactly // once, which is what makes a replayed callback useless. func (s *oauthStates) take(state string) bool { s.mu.Lock() defer s.mu.Unlock() expires, ok := s.expiry[state] if !ok || !expires.After(time.Now()) { return false } delete(s.expiry, state) return true } // discordStart begins the authorization code grant: a fresh state, then a // redirect to Discord's authorize page. func (h *Handler) discordStart(w http.ResponseWriter, r *http.Request) { state := session.NewID() h.states.put(state, time.Now().Add(oauthStateTTL)) u := h.discord.APIBase + "/oauth2/authorize?" + url.Values{ "client_id": {h.discord.ClientID}, "redirect_uri": {h.discord.RedirectURI}, "response_type": {"code"}, "scope": {"identify guilds.members.read"}, "state": {state}, }.Encode() http.Redirect(w, r, u, http.StatusSeeOther) } // discordCallback completes the grant: exchange the code, verify identity, // membership and role, then mint a session. Every failure path renders the // login page with an author-written message — nothing Discord supplied is // ever interpolated into a page, and no secret reaches a log line. func (h *Handler) discordCallback(w http.ResponseWriter, r *http.Request) { ip := session.ClientIP(r) if wait := h.limiter.RetryAfter(ip, time.Now()); wait > 0 { secs := int(wait.Seconds()) + 1 w.Header().Set("Retry-After", strconv.Itoa(secs)) h.renderLogin(w, http.StatusTooManyRequests, "Too many attempts. Try again in "+strconv.Itoa((secs+59)/60)+" min.") return } // Discord refuses the grant (the reader hit cancel, or the application // was misconfigured). The state is consumed so the flow is cleanly over; // this makes no Discord calls, so it is not a failure the limiter counts. if oerr := r.URL.Query().Get("error"); oerr != "" { h.states.take(r.URL.Query().Get("state")) h.renderLogin(w, http.StatusBadRequest, "Sign-in was cancelled.") return } code := r.URL.Query().Get("code") if code == "" || !h.states.take(r.URL.Query().Get("state")) { h.limiter.Fail(ip, time.Now()) h.renderLogin(w, http.StatusBadRequest, "This sign-in link was invalid or already used. Start again.") return } tok, err := h.exchangeToken(r.Context(), code) if err != nil { h.limiter.Fail(ip, time.Now()) log.Printf("discord token exchange: %v", err) h.renderLogin(w, http.StatusBadGateway, "Discord sign-in is unavailable right now. Try again in a moment.") return } userID, err := h.discordUserID(r.Context(), tok.AccessToken) if err != nil { h.limiter.Fail(ip, time.Now()) log.Printf("discord users/@me: %v", err) h.renderLogin(w, http.StatusBadGateway, "Discord sign-in is unavailable right now. Try again in a moment.") return } 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) h.renderLogin(w, http.StatusBadGateway, "Discord sign-in is unavailable right now. Try again in a moment.") return } // The refusal is the same for a non-member and a member without the // required role. When DISCORD_GUILD_NAME is set the message names the // community so a stranger knows which Discord to ask about; otherwise it // degrades to a generic community label and still gives the next step. It // never names the numeric guild id, which would not be actionable. // // It also returns before EnsureReader, so a refused sign-in leaves no // Reader row behind — the gate is the only thing standing between guild // membership and a library. if !isMember || (h.discord.RequiredRole != "" && !slices.Contains(member.Roles, h.discord.RequiredRole)) { h.limiter.Fail(ip, time.Now()) msg := "This Discord account is not a member of this community. Ask a member for an invite and try again." if h.discord.GuildName != "" { msg = fmt.Sprintf("This Discord account is not a member of the %s Discord. Ask a member for an invite and try again.", h.discord.GuildName) } h.renderLogin(w, http.StatusForbidden, msg) return } // Registration is the login (issue #27): first sight of a guild member // creates their Reader, every later sight returns the same one. Their // userscript credential is derived at epoch 0 the way the owner's is, so // the install links work before they have read anything. readerID, err := h.store.EnsureReader(userID, token.Hash(token.Token(h.tokenKey, userID, 0))) if err != nil { log.Printf("register reader: %v", err) http.Error(w, "internal error", http.StatusInternalServerError) return } h.limiter.Reset(ip) sess, err := h.store.CreateSession(session.NewID(), readerID, session.SessionTTL) if err != nil { log.Printf("create session: %v", err) http.Error(w, "internal error", http.StatusInternalServerError) return } session.SetCookie(w, r, sess.ID) http.Redirect(w, r, "/", http.StatusSeeOther) } // exchangeToken trades an authorization code for an access token. The body is // form-encoded because that is what Discord accepts — it rejects a JSON // payload — so the wire format is fixed here, not in a client library. func (h *Handler) exchangeToken(ctx context.Context, code string) (discordToken, error) { form := url.Values{ "client_id": {h.discord.ClientID}, "client_secret": {h.discord.ClientSecret}, "grant_type": {"authorization_code"}, "code": {code}, "redirect_uri": {h.discord.RedirectURI}, } req, err := http.NewRequestWithContext(ctx, http.MethodPost, h.discord.APIBase+"/oauth2/token", strings.NewReader(form.Encode())) if err != nil { return discordToken{}, err } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept", "application/json") resp, err := h.httpClient.Do(req) if err != nil { return discordToken{}, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return discordToken{}, fmt.Errorf("status %d", resp.StatusCode) } var tok discordToken if err := json.NewDecoder(io.LimitReader(resp.Body, maxResponseBytes)).Decode(&tok); err != nil { return discordToken{}, err } if tok.AccessToken == "" { return discordToken{}, errors.New("empty access token") } return tok, nil } // discordUserID fetches the signed-in user's id via the identify scope. func (h *Handler) discordUserID(ctx context.Context, accessToken string) (string, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, h.discord.APIBase+"/users/@me", nil) if err != nil { return "", err } req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("Accept", "application/json") resp, err := h.httpClient.Do(req) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("status %d", resp.StatusCode) } var u struct { ID string `json:"id"` } if err := json.NewDecoder(io.LimitReader(resp.Body, maxResponseBytes)).Decode(&u); err != nil { return "", err } if u.ID == "" { return "", errors.New("empty user id") } return u.ID, nil } type discordMember struct { Roles []string `json:"roles"` } // 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 } req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("Accept", "application/json") resp, err := h.httpClient.Do(req) if err != nil { return discordMember{}, false, err } defer resp.Body.Close() if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusForbidden { return discordMember{}, false, nil } if resp.StatusCode != http.StatusOK { return discordMember{}, false, fmt.Errorf("status %d", resp.StatusCode) } var m discordMember if err := json.NewDecoder(io.LimitReader(resp.Body, maxResponseBytes)).Decode(&m); err != nil { return discordMember{}, false, err } return m, true, nil } type discordToken struct { AccessToken string `json:"access_token"` }