Skip to content
Open
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## [1.3.0](https://github.com/enum-gg/caddy-discord/compare/v1.2.1...v1.3.0) (2026-05-08)

### Features

* Info, warning, and error messages added throughout the Discord authentication/authorization flow to help with troubleshooting
* Full Discord API request/response tracing at `DEBUG` logging level: URL, request headers (including `Authorization`), response status, response headers, and raw response body. Enable via the `debug` global directive in your Caddyfile

## [1.2.1](https://github.com/enum-gg/caddy-discord/compare/v1.2.0...v1.2.1) (2025-09-22)


Expand Down
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,3 +103,41 @@ http://localhost:8080 {
```
xcaddy build --with github.com/enum-gg/caddy-discord=./
```

## Troubleshooting

### Enabling Debug Logging

caddy-discord emits debug messages through Caddy's built-in logging system. To see them, enable debug mode in your `Caddyfile` [global options block](https://caddyserver.com/docs/caddyfile/options#debug):

```caddyfile
{
debug <-----

discord {
client_id ...
client_secret ...
redirect ...
}
}
```

The `debug` directive lowers Caddy's default log level to `DEBUG`, which causes caddy-discord to emit a full trace of every Discord API call: the request URL, all request and response headers, the response status code, and the raw response body. This is the primary tool for diagnosing authentication failures.

### Log Levels

| Level | What is logged |
|---------|--------------------------------------------------------------------------------------------------------------|
| `DEBUG` | Every outgoing Discord API request and its full response (URL, headers, body) |
| `INFO` | Access denials (user authenticated but failed authorization rules like user ID, guild member, roles) |
| `WARN` | Recoverable issues: invalid/expired session cookie, guild membership fetch failures for member & role checks |
| `ERROR` | Internal failures: OAuth code exchange errors, token generation failures, unexpected Discord API responses |

### Common Issues

#### Guild or role rules never match

A `WARN` log entry with message `failed to fetch guild membership, skipping rule` means Discord returned a non-200 response when checking guild membership. The `discord_message` and `discord_code` fields in the log will identify the exact Discord API error. Verify the guild ID in your realm config.

#### `Internal Error` response at the callback URL
A `DEBUG`-level `Discord API response` log will show the raw response body. Cross-reference `discord_code` values against the [Discord API error codes](https://discord.com/developers/docs/topics/opcodes-and-status-codes#json). Frequently will be the result of temporary Discord API outage.
82 changes: 68 additions & 14 deletions internal/discord/client.go
Original file line number Diff line number Diff line change
@@ -1,22 +1,28 @@
package discord

import (
"bytes"
"fmt"
"io"
"net/http"
"net/url"

"go.uber.org/zap"
)

type APIClient struct {
client *http.Client
logger *zap.Logger
}

func (d *APIClient) getRequest(url string) (*http.Response, error) {
return d.client.Get(url)
func (d *APIClient) getRequest(rawURL string) (*http.Response, error) {
return d.client.Get(rawURL)
}

func NewClientWrapper(client *http.Client) *APIClient {
func NewClientWrapper(client *http.Client, logger *zap.Logger) *APIClient {
return &APIClient{
client: client,
logger: logger,
}
}

Expand All @@ -28,53 +34,101 @@ func (d *APIClient) FetchGuildMembership(guildID string) (*GuildMemberResponse,
return fetch[GuildMemberResponse](d, fmt.Sprintf("https://discord.com/api/users/@me/guilds/%s/member", url.QueryEscape(guildID)))
}

func fetch[T any](client *APIClient, url string) (*T, error) {
response, err := client.getRequest(url)
func fetch[T any](client *APIClient, rawURL string) (*T, error) {
client.logger.Debug("Discord API request", zap.String("url", rawURL))

response, err := client.getRequest(rawURL)
if err != nil {
client.logger.Error("Discord API request failed", zap.String("url", rawURL), zap.Error(err))
return nil, err
}

var bodyBytes []byte
if response.Body != nil {
defer response.Body.Close()
bodyBytes, err = io.ReadAll(response.Body)
response.Body.Close()
if err != nil {
client.logger.Error("failed to read Discord API response body",
zap.String("url", rawURL),
zap.Int("status", response.StatusCode),
zap.Error(err),
)
return nil, err
}
response.Body = io.NopCloser(bytes.NewReader(bodyBytes))
}

logFields := []zap.Field{
zap.String("url", rawURL),
zap.Int("status", response.StatusCode),
zap.Any("response_headers", response.Header),
zap.ByteString("body", bodyBytes),
}
if response.Request != nil {
logFields = append(logFields, zap.Any("request_headers", response.Request.Header))
}
client.logger.Debug("Discord API response", logFields...)

if response.StatusCode == http.StatusOK {
normalised, err := getBody[T](response)
if err != nil {
client.logger.Error("failed to unmarshal Discord API response",
zap.String("url", rawURL),
zap.ByteString("body", bodyBytes),
zap.Error(err),
)
return nil, err
}

return normalised, nil
}

normalisedError, err := getBody[ErrorResponse](response)
if err != nil {
//failed to parse response body into error
client.logger.Error("failed to parse Discord API error response body",
zap.String("url", rawURL),
zap.Int("status", response.StatusCode),
zap.Error(err),
)
return nil, err
}

// Invalid requests
// https://discord.com/developers/docs/topics/rate-limits#invalid-request-limit-aka-cloudflare-bans
// https://discord.com/developers/docs/topics/opcodes-and-status-codes#http
if response.StatusCode == http.StatusUnauthorized {
// Token expired?
// log .Message, .Code?
client.logger.Warn("Discord API: unauthorized (token expired?)",
zap.String("url", rawURL),
zap.String("discord_message", normalisedError.Message),
zap.Int("discord_code", int(normalisedError.Code)),
)
return nil, ErrInsufficientScope
}

if response.StatusCode == http.StatusForbidden {
// Scopes insufficient?
// log .Message, .Code?
client.logger.Warn("Discord API: forbidden (insufficient scope?)",
zap.String("url", rawURL),
zap.String("discord_message", normalisedError.Message),
zap.Int("discord_code", int(normalisedError.Code)),
)
return nil, ErrInsufficientScope
}

// Rate limited
// https://discord.com/developers/docs/topics/rate-limits#rate-limits
if response.StatusCode == http.StatusTooManyRequests {
// TODO: http.client transport for retrying
// log .Message, .Code?
client.logger.Warn("Discord API: rate limited",
zap.String("url", rawURL),
zap.String("discord_message", normalisedError.Message),
zap.Int("discord_code", int(normalisedError.Code)),
)
return nil, ErrRateLimited
}

client.logger.Error("Discord API: unexpected error response",
zap.String("url", rawURL),
zap.Int("status", response.StatusCode),
zap.String("discord_message", normalisedError.Message),
zap.Int("discord_code", int(normalisedError.Code)),
)
return nil, resolveError(normalisedError.Code)
}
9 changes: 1 addition & 8 deletions internal/discord/normaliser.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,11 @@ import (

func getBody[T any](r *http.Response) (*T, error) {
body, err := io.ReadAll(r.Body)

if err != nil {
return nil, err
}

result, err := unmarshalAny[T](body)

if err != nil {
return nil, err
}

return result, nil
return unmarshalAny[T](body)
}

func unmarshalAny[T any](bytes []byte) (*T, error) {
Expand Down
52 changes: 39 additions & 13 deletions module_callback.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,17 @@ package caddydiscord
import (
"context"
"encoding/hex"
"net/http"
"net/url"
"time"

"github.com/caddyserver/caddy/v2"
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
"github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile"
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
"github.com/enum-gg/caddy-discord/internal/discord"
"go.uber.org/zap"
"golang.org/x/oauth2"
"net/http"
"net/url"
"time"
)

var (
Expand Down Expand Up @@ -45,6 +47,7 @@ type DiscordAuthPlugin struct {
tokenSigner TokenSignerSignature
flowTokenParser FlowTokenParserSignature
cookie CookieNamer
logger *zap.Logger
}

func (DiscordAuthPlugin) CaddyModule() caddy.ModuleInfo {
Expand All @@ -55,6 +58,8 @@ func (DiscordAuthPlugin) CaddyModule() caddy.ModuleInfo {
}

func (s *DiscordAuthPlugin) Provision(ctx caddy.Context) error {
s.logger = ctx.Logger(s)

ctxApp, _ := ctx.App(moduleName)
app := ctxApp.(*DiscordPortalApp)

Expand Down Expand Up @@ -107,40 +112,47 @@ func (d DiscordAuthPlugin) ServeHTTP(w http.ResponseWriter, r *http.Request, _ c

token, err := d.flowTokenParser(q.Get("state"))
if err != nil {
// Unable to find session. Using load balancers? Server was restarted?
d.logger.Error("failed to parse OAuth state parameter; session lost (load balancer or server restart?)", zap.Error(err))
http.Error(w, "Internal Error", http.StatusInternalServerError)
return err
}

realm := d.Realms.ByName(token.Realm)
if realm == nil {
// Unable to resolve realm
d.logger.Error("realm not found", zap.String("realm", token.Realm))
http.Error(w, "Internal Error", http.StatusInternalServerError)
return err
}

tok, err := d.OAuth.Exchange(ctx, q.Get("code"))
if err != nil {
d.logger.Error("OAuth authorization code exchange failed", zap.String("realm", token.Realm), zap.Error(err))
return err
}

client := discord.NewClientWrapper(d.OAuth.Client(ctx, tok))
client := discord.NewClientWrapper(d.OAuth.Client(ctx, tok), d.logger)

allowed := false

identity, err := client.FetchCurrentUser()
if err != nil || len(identity.ID) == 0 {
// Unable to resolve realm
d.logger.Error("failed to fetch Discord user identity", zap.String("realm", realm.Ref), zap.Error(err))
http.Error(w, "Failed to resolve Discord User", http.StatusInternalServerError)
return err
}

for _, rule := range realm.Identifiers {
d.logger.Debug("request", zap.String("realm", realm.Ref))
if ResourceRequiresGuild(rule.Resource) {
guildMembership, err := client.FetchGuildMembership(rule.GuildID)
if err != nil {
d.logger.Debug("failed to fetch guild membership, skipping rule",
zap.String("user_id", identity.ID),
zap.String("guild_id", rule.GuildID),
zap.String("realm", realm.Ref),
zap.Error(err),
)
continue
// TODO: check error type - probably not a member of guild...
}

if rule.Resource == DiscordRoleRule {
Expand All @@ -150,6 +162,14 @@ func (d DiscordAuthPlugin) ServeHTTP(w http.ResponseWriter, r *http.Request, _ c
// Authorised based on role whitelist.
allowed = true
break
} else {
d.logger.Debug("authenticated member does not have role",
zap.String("member_id", identity.ID),
zap.String("guild_id", rule.GuildID),
zap.Strings("member_roles", guildMembership.Roles),
zap.String("rule", rule.Identifier),
zap.String("realm", realm.Ref),
)
}
}

Expand All @@ -168,10 +188,7 @@ func (d DiscordAuthPlugin) ServeHTTP(w http.ResponseWriter, r *http.Request, _ c
break
}
}
} else if rule.Resource == DiscordUserRule && rule.Wildcard == false && rule.Identifier == identity.ID {
allowed = true
break
} else if rule.Resource == DiscordUserRule && rule.Wildcard == true {
} else if rule.Resource == DiscordUserRule && (rule.Wildcard || rule.Identifier == identity.ID) {
allowed = true
break
}
Expand All @@ -184,12 +201,21 @@ func (d DiscordAuthPlugin) ServeHTTP(w http.ResponseWriter, r *http.Request, _ c
// in-case of Discord role change, etc.
if !allowed {
expiration = time.Now().Add(time.Minute * 3)
d.logger.Info("access denied: user does not meet any authorization rules",
zap.String("user_id", identity.ID),
zap.String("username", identity.Username),
zap.String("realm", realm.Ref),
)
}

authedToken := NewAuthenticatedToken(*identity, realm.Ref, expiration, allowed)
signedToken, err := d.tokenSigner(authedToken)
if err != nil {
// Unable to generate JWT
d.logger.Error("failed to generate authenticated token",
zap.String("user_id", identity.ID),
zap.String("realm", realm.Ref),
zap.Error(err),
)
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
return err
}
Expand Down
Loading