Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ Three jobs get extra attention:
1. Create an application and a bot at the Discord developer portal.
2. Turn on the **Server Members Intent** and the **Message Content Intent**.
3. Add this redirect: `{PUBLIC_URL}/auth/discord/callback`.
4. Sign in from the dashboard. Discord asks you to add the bot to a server where you have Manage Server. Sign in again to add another. The permission integer is `1099847265366` (view channels, send messages, embed links, read history, manage messages, manage channels, add reactions, change nickname, kick, ban, manage roles, and moderate members). Change Nickname lets the bot use a display name on that server. Add the server again if an older invite is missing it. The same install, without signing in, is:
4. Sign in from the dashboard. Discord signs you in and lists servers where SquirrelBot is already installed and you have Manage Server, or a role saved under Access. Someone with Manage Server chooses those roles. Use Add a server when you want Discord to install the bot on another server you manage. The permission integer is `1099847265366` (view channels, send messages, embed links, read history, manage messages, manage channels, add reactions, change nickname, kick, ban, manage roles, and moderate members). Change Nickname lets the bot use a display name on that server. Add the server again if an older invite is missing it. The same install, without signing in, is:

```text
https://discord.com/oauth2/authorize?client_id=CLIENT_ID&permissions=1099847265366&integration_type=0&scope=bot%20applications.commands
Expand Down
73 changes: 73 additions & 0 deletions e2e/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"io"
"mime/multipart"
"net/http"
"net/url"
"os"
"strconv"
"strings"
Expand Down Expand Up @@ -69,6 +70,9 @@ func TestHealthAndSignIn(t *testing.T) {
if res.StatusCode != http.StatusFound || !strings.Contains(res.Header.Get("Location"), "https://discord.com/oauth2/authorize") {
t.Fatalf("login redirect %d %s", res.StatusCode, res.Header.Get("Location"))
}
if scope := oauthScope(t, res.Header.Get("Location")); scope != "identify guilds" {
t.Fatalf("login scope %q", scope)
}
var state string
for _, cookie := range res.Cookies() {
if cookie.Name == "sb_oauth_state" {
Expand All @@ -80,6 +84,15 @@ func TestHealthAndSignIn(t *testing.T) {
t.Fatal("login did not set the state cookie")
}

res = noFollow(t, http.MethodGet, "/auth/discord/invite", nil, "")
if res.StatusCode != http.StatusFound {
t.Fatalf("invite redirect %d", res.StatusCode)
}
if scope := oauthScope(t, res.Header.Get("Location")); scope != "identify guilds bot applications.commands" {
t.Fatalf("invite scope %q", scope)
}
res.Body.Close()

res = noFollow(t, http.MethodGet, "/auth/discord/callback?error=denied", nil, "")
if res.StatusCode != http.StatusFound || !strings.Contains(res.Header.Get("Location"), "error=denied") {
t.Fatalf("denied callback %d %s", res.StatusCode, res.Header.Get("Location"))
Expand Down Expand Up @@ -142,6 +155,57 @@ func TestGuildAccess(t *testing.T) {
if status != http.StatusNotFound {
t.Fatalf("bad id %d %s", status, body)
}

status, body = call(t, http.MethodGet, "/api/guilds", nil, stack.GuestCookie)
if status != http.StatusOK || strings.Contains(string(body), harness.GuildName) {
t.Fatalf("guest guild list %d %s", status, body)
}
status, body = call(t, http.MethodPut, "/api/guilds/"+harness.GuildID+"/manager-roles", map[string]any{
"roleIds": []string{harness.GuildID},
}, stack.Cookie)
if status != http.StatusBadRequest {
t.Fatalf("everyone role %d %s", status, body)
}
status, body = call(t, http.MethodPut, "/api/guilds/"+harness.GuildID+"/manager-roles", map[string]any{
"roleIds": []string{harness.BotRoleID},
}, stack.Cookie)
if status != http.StatusBadRequest {
t.Fatalf("managed role %d %s", status, body)
}
t.Cleanup(func() {
status, body := call(t, http.MethodPut, "/api/guilds/"+harness.GuildID+"/manager-roles", map[string]any{
"roleIds": []string{},
}, stack.Cookie)
if status != http.StatusOK {
t.Errorf("clear dashboard roles %d %s", status, body)
}
})
status, body = call(t, http.MethodPut, "/api/guilds/"+harness.GuildID+"/manager-roles", map[string]any{
"roleIds": []string{harness.MemberRoleID},
}, stack.Cookie)
if status != http.StatusOK || !strings.Contains(string(body), harness.MemberRoleID) {
t.Fatalf("save dashboard roles %d %s", status, body)
}
status, body = call(t, http.MethodGet, "/api/guilds", nil, stack.GuestCookie)
if status != http.StatusOK || !strings.Contains(string(body), harness.GuildName) {
t.Fatalf("guest guild list after grant %d %s", status, body)
}
status, body = call(t, http.MethodGet, "/api/guilds/"+harness.GuildID, nil, stack.GuestCookie)
if status != http.StatusOK || !strings.Contains(string(body), `"manageServer":false`) {
t.Fatalf("guest bundle %d %s", status, body)
}
status, body = call(t, http.MethodPut, "/api/guilds/"+harness.GuildID+"/manager-roles", map[string]any{
"roleIds": []string{harness.AdminRoleID},
}, stack.GuestCookie)
if status != http.StatusForbidden {
t.Fatalf("guest edit roles %d %s", status, body)
}
status, body = call(t, http.MethodPut, "/api/guilds/"+harness.GuildID+"/automod", map[string]any{
"action": "nope",
}, stack.GuestCookie)
if status != http.StatusBadRequest {
t.Fatalf("guest passed the guild gate %d %s", status, body)
}
}

func TestDashboardJourney(t *testing.T) {
Expand Down Expand Up @@ -438,6 +502,15 @@ func noFollow(t *testing.T, method, path string, payload any, cookie string) *ht
return res
}

func oauthScope(t *testing.T, location string) string {
t.Helper()
parsed, err := url.Parse(location)
if err != nil {
t.Fatal(err)
}
return parsed.Query().Get("scope")
}

func request(t *testing.T, method, path string, payload any, cookie string) *http.Request {
t.Helper()
var body io.Reader
Expand Down
1 change: 1 addition & 0 deletions e2e/harness/discord.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ func newDiscord(access, guestAccess, refresh string) *discordFake {
}
members := map[string]member{
UserID: {id: UserID, username: "acorn", globalName: UserName, roles: []string{AdminRoleID}},
GuestUserID: {id: GuestUserID, username: "guest", globalName: "Guest", roles: []string{MemberRoleID}},
TargetUserID: {id: TargetUserID, username: TargetUsername, globalName: TargetName, nick: TargetName, roles: []string{MemberRoleID}},
BotUserID: {id: BotUserID, username: "squirrelbot", globalName: "squirrelbot", roles: []string{BotRoleID}, bot: true},
}
Expand Down
139 changes: 127 additions & 12 deletions internal/api/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,29 +71,47 @@ func (v *jsonIntString) UnmarshalJSON(data []byte) error {

const botInvitePermissions = "1099847265366"

const oauthScopes = "identify guilds bot applications.commands"
const (
// loginScopes identifies the user and lists servers they can manage.
// It leaves out the bot scope so Discord does not ask them to install SquirrelBot.
loginScopes = "identify guilds"
// inviteScopes is the same sign-in, plus permission to add the bot to one server.
inviteScopes = "identify guilds bot applications.commands"
)

func discordAuthorizeURL(clientID, redirectURI, state string) string {
func discordAuthorizeURL(clientID, redirectURI, state string, invite bool) string {
query := url.Values{}
query.Set("client_id", clientID)
query.Set("redirect_uri", redirectURI)
query.Set("response_type", "code")
query.Set("permissions", botInvitePermissions)
query.Set("integration_type", "0")
query.Set("state", state)
query.Set("prompt", "consent")
scopes := loginScopes
if invite {
query.Set("permissions", botInvitePermissions)
query.Set("integration_type", "0")
scopes = inviteScopes
}
// Discord's authorize page splits scopes on %20. url.Values encodes a space as +.
scope := strings.ReplaceAll(url.QueryEscape(oauthScopes), "+", "%20")
scope := strings.ReplaceAll(url.QueryEscape(scopes), "+", "%20")
return "https://discord.com/oauth2/authorize?" + query.Encode() + "&scope=" + scope
}

func (s *Server) startLogin(c *fiber.Ctx) error {
return s.beginOAuth(c, false)
}

func (s *Server) startInvite(c *fiber.Ctx) error {
return s.beginOAuth(c, true)
}

func (s *Server) beginOAuth(c *fiber.Ctx, invite bool) error {
state, err := randomID()
if err != nil {
return fail(c, fiber.StatusInternalServerError, "Could not start sign-in.")
}
s.setCookie(c, stateCookie, state, time.Now().Add(10*time.Minute))
return c.Redirect(discordAuthorizeURL(s.cfg.DiscordClientID, s.cfg.PublicURL+"/auth/discord/callback", state), fiber.StatusFound)
return c.Redirect(discordAuthorizeURL(s.cfg.DiscordClientID, s.cfg.PublicURL+"/auth/discord/callback", state, invite), fiber.StatusFound)
}

func (s *Server) finishLogin(c *fiber.Ctx) error {
Expand Down Expand Up @@ -318,9 +336,19 @@ func (s *Server) requireGuild(c *fiber.Ctx) error {
return fail(c, fiber.StatusNotFound, "Unknown server.")
}
session := c.Locals("session").(store.Session)
perms, ok := s.permissions(c, session, guildID)
if !ok || !domain.CanManageGuild(perms) {
return fail(c, fiber.StatusForbidden, "You need Manage Server on that Discord server.")
perms, inGuild := s.permissions(c, session, guildID)
manageServer := inGuild && domain.CanManageGuild(perms)
if !manageServer {
if !inGuild {
return fail(c, fiber.StatusForbidden, "You need Manage Server, or a role chosen for dashboard access.")
}
allowed, err := s.holdsManagerRole(c, guildID, session.UserID)
if err != nil {
return managerRoleError(c, err)
}
if !allowed {
return fail(c, fiber.StatusForbidden, "You need Manage Server, or a role chosen for dashboard access.")
}
}
if _, err := s.store.GetGuild(c.UserContext(), guildID); err != nil {
if errors.Is(err, store.ErrNotFound) {
Expand All @@ -330,9 +358,53 @@ func (s *Server) requireGuild(c *fiber.Ctx) error {
return fail(c, fiber.StatusInternalServerError, "Could not load that server.")
}
c.Locals("guildID", guildID)
c.Locals("manageServer", manageServer)
return c.Next()
}

// holdsManagerRole is true when the member has a role saved for dashboard access.
// OAuth guild permissions do not include role ids, so the bot looks the member up.
func (s *Server) holdsManagerRole(c *fiber.Ctx, guildID, userID string) (bool, error) {
allowed, err := s.store.ManagerRoleIDs(c.UserContext(), guildID)
if errors.Is(err, store.ErrNotFound) || (err == nil && len(allowed) == 0) {
return false, nil
}
if err != nil {
return false, err
}
held, err := s.memberRoleIDs(guildID, userID)
if err != nil {
return false, err
}
return domain.SharesRole(held, allowed), nil
}

func (s *Server) memberRoleIDs(guildID, userID string) ([]string, error) {
if ids, ok := s.roles.get(guildID, userID); ok {
return ids, nil
}
if err := s.requireBot(); err != nil {
return nil, err
}
member, err := s.bot.Session().GuildMember(guildID, userID)
if err != nil {
return nil, err
}
ids := member.Roles
if ids == nil {
ids = []string{}
}
s.roles.set(guildID, userID, ids)
return ids, nil
}

func managerRoleError(c *fiber.Ctx, err error) error {
if err != nil && err.Error() == "The bot is still connecting to Discord." {
return fail(c, fiber.StatusServiceUnavailable, err.Error())
}
return fail(c, fiber.StatusBadGateway, "Discord could not confirm your roles in that server.")
}

func (s *Server) permissions(c *fiber.Ctx, session store.Session, guildID string) (int64, bool) {
guilds, ok := s.access.get(session.ID)
if !ok {
Expand Down Expand Up @@ -386,12 +458,18 @@ func (s *Server) guilds(c *fiber.Ctx) error {
bits |= domain.PermAdministrator
}
cached[guild.ID] = bits
if !domain.CanManageGuild(bits) {
continue
}
if _, err := s.store.GetGuild(c.UserContext(), guild.ID); err != nil {
continue
}
if !domain.CanManageGuild(bits) {
allowed, err := s.holdsManagerRole(c, guild.ID, session.UserID)
if err != nil || !allowed {
if err != nil {
s.log.Warn("dashboard role", "guild", guild.ID, "err", err)
}
continue
}
}
out = append(out, item{ID: guild.ID, Name: guild.Name, IconURL: iconURL(guild.ID, guild.Icon)})
}
s.access.set(session.ID, cached)
Expand Down Expand Up @@ -482,3 +560,40 @@ func (c *accessCache) drop(sessionID string) {
defer c.mu.Unlock()
delete(c.items, sessionID)
}

type memberRoleCache struct {
mu sync.Mutex
items map[string]memberRoleEntry
}

type memberRoleEntry struct {
roles []string
exp time.Time
}

func newMemberRoleCache() *memberRoleCache {
return &memberRoleCache{items: map[string]memberRoleEntry{}}
}

func memberRoleKey(guildID, userID string) string {
return guildID + "/" + userID
}

func (c *memberRoleCache) get(guildID, userID string) ([]string, bool) {
c.mu.Lock()
defer c.mu.Unlock()
entry, ok := c.items[memberRoleKey(guildID, userID)]
if !ok || time.Now().After(entry.exp) {
return nil, false
}
return entry.roles, true
}

func (c *memberRoleCache) set(guildID, userID string, roles []string) {
c.mu.Lock()
defer c.mu.Unlock()
if len(c.items) > 500 {
c.items = map[string]memberRoleEntry{}
}
c.items[memberRoleKey(guildID, userID)] = memberRoleEntry{roles: roles, exp: time.Now().Add(45 * time.Second)}
}
31 changes: 29 additions & 2 deletions internal/api/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,41 @@ import (
"testing"
)

func TestDiscordAuthorizeSignsInWithoutInstalling(t *testing.T) {
raw := discordAuthorizeURL("app", "http://localhost:5173/auth/discord/callback", "state", false)
parsed, err := url.Parse(raw)
if err != nil {
t.Fatal(err)
}
query := parsed.Query()
if query.Get("scope") != loginScopes {
t.Fatalf("scope %q", query.Get("scope"))
}
if !strings.Contains(raw, "scope=identify%20guilds") {
t.Fatalf("scope encoding %s", raw)
}
if strings.Contains(query.Get("scope"), "bot") || strings.Contains(query.Get("scope"), "applications.commands") {
t.Fatalf("login asked to install the bot: %s", raw)
}
if query.Get("permissions") != "" || query.Get("integration_type") != "" {
t.Fatalf("login carried invite fields %s", raw)
}
if query.Get("prompt") != "consent" {
t.Fatalf("prompt %q", query.Get("prompt"))
}
if query.Get("response_type") != "code" {
t.Fatalf("response_type %q", query.Get("response_type"))
}
}

func TestDiscordAuthorizeInvitesManagedServers(t *testing.T) {
raw := discordAuthorizeURL("app", "http://localhost:5173/auth/discord/callback", "state")
raw := discordAuthorizeURL("app", "http://localhost:5173/auth/discord/callback", "state", true)
parsed, err := url.Parse(raw)
if err != nil {
t.Fatal(err)
}
query := parsed.Query()
if query.Get("scope") != oauthScopes {
if query.Get("scope") != inviteScopes {
t.Fatalf("scope %q", query.Get("scope"))
}
if !strings.Contains(raw, "scope=identify%20guilds%20bot%20applications.commands") {
Expand Down
Loading
Loading