Skip to content

Commit 474d06d

Browse files
committed
feat: implement JWT-based admin API authentication middleware and configuration
1 parent d3ef0d6 commit 474d06d

7 files changed

Lines changed: 160 additions & 1 deletion

File tree

cmd/mail-admin/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ func main() {
5757
}
5858

5959
mux := http.NewServeMux()
60-
mux.Handle("/graphql", handler)
60+
mux.Handle("/graphql", admin.AuthMiddleware(cfg.AdminAuthSecret)(handler))
6161
mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) {
6262
w.WriteHeader(http.StatusOK)
6363
_, _ = w.Write([]byte(`{"status":"UP"}`))

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ require (
2424
github.com/go-playground/locales v0.14.1 // indirect
2525
github.com/go-playground/universal-translator v0.18.1 // indirect
2626
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
27+
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
2728
github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b // indirect
2829
github.com/inconshreveable/mousetrap v1.1.0 // indirect
2930
github.com/klauspost/compress v1.18.5 // indirect

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK
2828
github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc=
2929
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
3030
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
31+
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
32+
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
3133
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
3234
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
3335
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=

internal/admin/auth.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package admin
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"log/slog"
7+
"net/http"
8+
"strings"
9+
10+
"github.com/golang-jwt/jwt/v5"
11+
)
12+
13+
// AuthMiddleware validates Bearer JWTs signed with HMAC-SHA256.
14+
// Requests without a valid token receive 401; /health must be registered outside this middleware.
15+
func AuthMiddleware(secret string) func(http.Handler) http.Handler {
16+
return func(next http.Handler) http.Handler {
17+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
18+
tokenStr, ok := bearerToken(r)
19+
if !ok {
20+
writeUnauthorized(w, r)
21+
return
22+
}
23+
24+
_, err := jwt.Parse(tokenStr, func(t *jwt.Token) (any, error) {
25+
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
26+
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
27+
}
28+
return []byte(secret), nil
29+
})
30+
if err != nil {
31+
writeUnauthorized(w, r)
32+
return
33+
}
34+
35+
next.ServeHTTP(w, r)
36+
})
37+
}
38+
}
39+
40+
func bearerToken(r *http.Request) (string, bool) {
41+
token, found := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
42+
return token, found && token != ""
43+
}
44+
45+
func writeUnauthorized(w http.ResponseWriter, r *http.Request) {
46+
slog.Warn("unauthorized request",
47+
slog.String("method", r.Method),
48+
slog.String("path", r.URL.Path),
49+
)
50+
w.Header().Set("Content-Type", "application/json")
51+
w.WriteHeader(http.StatusUnauthorized)
52+
_ = json.NewEncoder(w).Encode(map[string]any{
53+
"status": 401,
54+
"code": "UNAUTHORIZED",
55+
"message": "invalid or missing token",
56+
})
57+
}

internal/admin/auth_test.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package admin
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
"testing"
7+
"time"
8+
9+
"github.com/golang-jwt/jwt/v5"
10+
)
11+
12+
const authTestSecret = "test-secret-key"
13+
14+
func signedToken(t *testing.T, secret string, expiry time.Time) string {
15+
t.Helper()
16+
claims := jwt.RegisteredClaims{ExpiresAt: jwt.NewNumericDate(expiry)}
17+
tok, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(secret))
18+
if err != nil {
19+
t.Fatalf("sign token: %v", err)
20+
}
21+
return tok
22+
}
23+
24+
func TestAuthMiddleware(t *testing.T) {
25+
validToken := signedToken(t, authTestSecret, time.Now().Add(time.Hour))
26+
expiredToken := signedToken(t, authTestSecret, time.Now().Add(-time.Hour))
27+
wrongSecretToken := signedToken(t, "wrong-secret", time.Now().Add(time.Hour))
28+
29+
cases := []struct {
30+
name string
31+
authHeader string
32+
wantStatus int
33+
}{
34+
{"valid token", "Bearer " + validToken, http.StatusOK},
35+
{"no token", "", http.StatusUnauthorized},
36+
{"wrong secret", "Bearer " + wrongSecretToken, http.StatusUnauthorized},
37+
{"expired token", "Bearer " + expiredToken, http.StatusUnauthorized},
38+
{"malformed token", "Bearer notajwt", http.StatusUnauthorized},
39+
{"missing bearer prefix", validToken, http.StatusUnauthorized},
40+
}
41+
42+
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
43+
w.WriteHeader(http.StatusOK)
44+
})
45+
middleware := AuthMiddleware(authTestSecret)(next)
46+
47+
for _, tc := range cases {
48+
t.Run(tc.name, func(t *testing.T) {
49+
req := httptest.NewRequest(http.MethodPost, "/graphql", nil)
50+
if tc.authHeader != "" {
51+
req.Header.Set("Authorization", tc.authHeader)
52+
}
53+
rr := httptest.NewRecorder()
54+
middleware.ServeHTTP(rr, req)
55+
if rr.Code != tc.wantStatus {
56+
t.Errorf("want %d, got %d (body: %s)", tc.wantStatus, rr.Code, rr.Body.String())
57+
}
58+
})
59+
}
60+
}
61+
62+
func TestAuthMiddleware_ResponseBody(t *testing.T) {
63+
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
64+
w.WriteHeader(http.StatusOK)
65+
})
66+
middleware := AuthMiddleware(authTestSecret)(next)
67+
68+
req := httptest.NewRequest(http.MethodPost, "/graphql", nil)
69+
rr := httptest.NewRecorder()
70+
middleware.ServeHTTP(rr, req)
71+
72+
if ct := rr.Header().Get("Content-Type"); ct != "application/json" {
73+
t.Errorf("Content-Type: want application/json, got %s", ct)
74+
}
75+
body := rr.Body.String()
76+
if body == "" {
77+
t.Error("expected non-empty response body on 401")
78+
}
79+
}

internal/config/config.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ type Config struct {
2424
GraphRateLimiterSkip bool
2525
GraphProxyURL string // MS_GRAPH_PROXY_URL — routes Graph calls through Dev Proxy
2626
GraphMockToken string // MS_GRAPH_MOCK_TOKEN — skips OAuth2, makes Graph credentials optional
27+
AdminAuthSecret string // DISPATCH_ADMIN_AUTH_SECRET — HMAC secret for Admin-API JWT auth
2728
}
2829

2930
func Load() (Config, error) {
@@ -55,6 +56,11 @@ func Load() (Config, error) {
5556
}
5657
}
5758

59+
adminAuthSecret := os.Getenv("DISPATCH_ADMIN_AUTH_SECRET")
60+
if adminAuthSecret == "" {
61+
return Config{}, fmt.Errorf("DISPATCH_ADMIN_AUTH_SECRET is required")
62+
}
63+
5864
bounceMailbox := os.Getenv("MS_GRAPH_BOUNCE_MAILBOX")
5965
if bounceMailbox == "" {
6066
bounceMailbox = envOr("MS_GRAPH_SENDER_EMAIL", "noreply@dev.local")
@@ -79,6 +85,7 @@ func Load() (Config, error) {
7985
GraphRateLimiterSkip: os.Getenv("DISPATCH_GRAPH_RATE_LIMITER_SKIP_SLEEP") == "true",
8086
GraphProxyURL: graphProxyURL,
8187
GraphMockToken: graphMockToken,
88+
AdminAuthSecret: adminAuthSecret,
8289
}, nil
8390
}
8491

internal/config/config_test.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ func setRequiredEnv(t *testing.T) {
1818
t.Setenv("MS_GRAPH_CLIENT_ID", "client")
1919
t.Setenv("MS_GRAPH_CLIENT_SECRET", "secret")
2020
t.Setenv("MS_GRAPH_SENDER_EMAIL", testSenderEmail)
21+
t.Setenv("DISPATCH_ADMIN_AUTH_SECRET", "test-secret")
2122
}
2223

2324
func TestLoad_Success(t *testing.T) {
@@ -60,6 +61,16 @@ func TestLoad_Defaults(t *testing.T) {
6061
}
6162
}
6263

64+
func TestLoad_MissingAdminAuthSecret(t *testing.T) {
65+
setRequiredEnv(t)
66+
t.Setenv("DISPATCH_ADMIN_AUTH_SECRET", "")
67+
68+
_, err := Load()
69+
if err == nil {
70+
t.Fatal("expected error for missing DISPATCH_ADMIN_AUTH_SECRET")
71+
}
72+
}
73+
6374
func TestLoad_MissingNatsURL(t *testing.T) {
6475
t.Setenv("NATS_URL", "")
6576
t.Setenv("MS_GRAPH_TENANT_ID", "tenant")
@@ -75,6 +86,7 @@ func TestLoad_MissingNatsURL(t *testing.T) {
7586

7687
func TestLoad_MissingGraphCredentials(t *testing.T) {
7788
t.Setenv("NATS_URL", testNatsURL)
89+
t.Setenv("DISPATCH_ADMIN_AUTH_SECRET", "test-secret")
7890
// MS_GRAPH_MOCK_TOKEN is not set → credentials are required
7991

8092
cases := []struct {
@@ -107,6 +119,7 @@ func TestLoad_MissingGraphCredentials(t *testing.T) {
107119
func TestLoad_MockTokenSkipsCredentialCheck(t *testing.T) {
108120
t.Setenv("NATS_URL", testNatsURL)
109121
t.Setenv("MS_GRAPH_MOCK_TOKEN", "dev-token")
122+
t.Setenv("DISPATCH_ADMIN_AUTH_SECRET", "test-secret")
110123
// deliberately leave Graph credentials unset
111124

112125
cfg, err := Load()

0 commit comments

Comments
 (0)