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 @@ -12,7 +12,7 @@ fine-grained authorisation rules based on the user identity they contain.
| Variable | Default | Description |
| ------------------- | ------------------------ | ------------------------------------------------------- |
| `FMSG_DATA_DIR` | *(required)* | Path where message data files are stored, e.g. `/opt/fmsg/data` |
| `FMSG_API_JWT_SECRET` | *(required)* | HMAC secret used to validate JWT tokens |
| `FMSG_API_JWT_SECRET` | *(required)* | HMAC secret used to validate JWT tokens. Prefix with `base64:` to supply a base64-encoded key (e.g. `base64:c2VjcmV0`); otherwise the raw string is used. |
| `FMSG_API_PORT` | `8000` | TCP port the HTTP server listens on |
| `FMSG_ID_URL` | `http://127.0.0.1:8080` | Base URL of the fmsgid identity service |

Expand Down
20 changes: 19 additions & 1 deletion src/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ package main

import (
"context"
"encoding/base64"
"log"
"os"
"strings"

"github.com/gin-gonic/gin"
"github.com/joho/godotenv"
Expand All @@ -20,6 +22,7 @@ func main() {
// Required configuration.
dataDir := mustEnv("FMSG_DATA_DIR")
jwtSecret := mustEnv("FMSG_API_JWT_SECRET")
jwtKey := parseSecret(jwtSecret)

// Optional configuration with defaults.
port := envOrDefault("FMSG_API_PORT", "8000")
Expand All @@ -35,7 +38,7 @@ func main() {
log.Println("connected to PostgreSQL")

// Initialise JWT middleware.
jwtMiddleware, err := middleware.SetupJWT(jwtSecret, idURL)
jwtMiddleware, err := middleware.SetupJWT(jwtKey, idURL)
if err != nil {
log.Fatalf("failed to initialise JWT middleware: %v", err)
}
Expand Down Expand Up @@ -88,3 +91,18 @@ func envOrDefault(key, defaultValue string) string {
}
return defaultValue
}

// parseSecret returns the HMAC key bytes for the given secret string.
// If s begins with "base64:" the remainder is base64-decoded; otherwise the
// raw string bytes are used.
func parseSecret(s string) []byte {
const prefix = "base64:"
if strings.HasPrefix(s, prefix) {
b, err := base64.StdEncoding.DecodeString(s[len(prefix):])
if err != nil {
log.Fatalf("FMSG_API_JWT_SECRET has base64: prefix but is not valid base64: %v", err)
}
Comment on lines +101 to +104
Copy link

Copilot AI Apr 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parseSecret can return an empty HMAC key when FMSG_API_JWT_SECRET is set to just base64: (or base64 that decodes to an empty byte slice). That would silently run JWT validation with an empty secret, which is a security misconfiguration. Consider validating that the decoded key length is > 0 (and failing fast) before returning it; similarly consider trimming whitespace around the base64 payload to avoid surprising decode failures from env formatting.

Suggested change
b, err := base64.StdEncoding.DecodeString(s[len(prefix):])
if err != nil {
log.Fatalf("FMSG_API_JWT_SECRET has base64: prefix but is not valid base64: %v", err)
}
payload := strings.TrimSpace(s[len(prefix):])
b, err := base64.StdEncoding.DecodeString(payload)
if err != nil {
log.Fatalf("FMSG_API_JWT_SECRET has base64: prefix but is not valid base64: %v", err)
}
if len(b) == 0 {
log.Fatalf("FMSG_API_JWT_SECRET must not decode to an empty key")
}

Copilot uses AI. Check for mistakes.
return b
}
return []byte(s)
}
6 changes: 3 additions & 3 deletions src/middleware/jwt.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,17 +25,17 @@ type identityClaims struct {
}

// SetupJWT creates and returns a configured GinJWTMiddleware.
// secret is the HMAC secret used to validate tokens.
// key is the HMAC secret bytes used to validate tokens.
// idURL is the base URL of the fmsgid service used to validate user addresses.
func SetupJWT(secret string, idURL string) (*jwt.GinJWTMiddleware, error) {
func SetupJWT(key []byte, idURL string) (*jwt.GinJWTMiddleware, error) {
// Set the global TimeFunc used by golang-jwt/v4 when validating iat/nbf/exp
// inside MapClaims.Valid(). gin-jwt's own TimeFunc field does not affect this
// path; only the package-level variable does.
jwtv4.TimeFunc = func() time.Time { return time.Now().Add(clockSkew) }

mw, err := jwt.New(&jwt.GinJWTMiddleware{
Realm: "fmsg",
Key: []byte(secret),
Key: key,
Timeout: 24 * time.Hour,
MaxRefresh: 24 * time.Hour,
IdentityKey: IdentityKey,
Expand Down
2 changes: 1 addition & 1 deletion src/middleware/jwt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ func TestIATClockSkew(t *testing.T) {
const secret = "test-secret-for-iat-skew"

// SetupJWT sets jwtv4.TimeFunc as a side-effect.
if _, err := SetupJWT(secret, "http://127.0.0.1:0"); err != nil {
if _, err := SetupJWT([]byte(secret), "http://127.0.0.1:0"); err != nil {
t.Fatalf("SetupJWT: %v", err)
}

Expand Down
Loading