Separate HMAC key from read token
Some checks failed
ci / build (push) Has been cancelled

This commit is contained in:
Martin McCaffery 2026-02-11 15:17:46 +01:00
parent aa3e8cddf9
commit 6e3242bbd8
Signed by: martin.mccaffery
GPG key ID: 7C4D0F375BCEE533
6 changed files with 91 additions and 41 deletions

View file

@ -18,7 +18,10 @@ import (
"edp.buildth.ing/DevFW-CICD/forgejo-runner-resource-collector/internal/summary"
)
const testReadToken = "integration-test-token"
const (
testReadToken = "integration-test-token"
testHMACKey = "integration-hmac-key"
)
// setupTestReceiver creates a test receiver with SQLite storage, auth, and HTTP server
func setupTestReceiver(t *testing.T) (*receiver.Store, *httptest.Server, func()) {
@ -29,7 +32,7 @@ func setupTestReceiver(t *testing.T) (*receiver.Store, *httptest.Server, func())
t.Fatalf("NewStore() error = %v", err)
}
handler := receiver.NewHandler(store, slog.New(slog.NewTextHandler(io.Discard, nil)), testReadToken)
handler := receiver.NewHandler(store, slog.New(slog.NewTextHandler(io.Discard, nil)), testReadToken, testHMACKey)
mux := http.NewServeMux()
handler.RegisterRoutes(mux)
@ -45,7 +48,7 @@ func setupTestReceiver(t *testing.T) (*receiver.Store, *httptest.Server, func())
// generatePushToken generates a scoped push token for an execution context
func generatePushToken(exec summary.ExecutionContext) string {
return receiver.GenerateScopedToken(testReadToken, exec.Organization, exec.Repository, exec.Workflow, exec.Job)
return receiver.GenerateScopedToken(testHMACKey, exec.Organization, exec.Repository, exec.Workflow, exec.Job)
}
func TestPushClientToReceiver(t *testing.T) {
@ -164,7 +167,7 @@ func TestPushClientIntegration(t *testing.T) {
t.Setenv("GITHUB_RUN_ID", "push-run-456")
// Generate scoped push token
pushToken := receiver.GenerateScopedToken(testReadToken, "push-client-org", "push-client-repo", "push-test.yml", "push-job")
pushToken := receiver.GenerateScopedToken(testHMACKey, "push-client-org", "push-client-repo", "push-test.yml", "push-job")
// Create push client with token - it reads execution context from env vars
pushClient := summary.NewPushClient(server.URL+"/api/v1/metrics", pushToken)
@ -274,8 +277,9 @@ func TestMultiplePushes(t *testing.T) {
}
func TestPushClientWithTokenIntegration(t *testing.T) {
readToken := "integration-secret"
store, server, cleanup := setupTestReceiverWithToken(t, readToken)
readToken := "integration-read-secret"
hmacKey := "integration-hmac-secret"
store, server, cleanup := setupTestReceiverWithToken(t, readToken, hmacKey)
defer cleanup()
// Generate a scoped token via the API
@ -340,8 +344,9 @@ func TestPushClientWithTokenIntegration(t *testing.T) {
}
func TestPushClientWithWrongTokenIntegration(t *testing.T) {
readToken := "integration-secret"
_, server, cleanup := setupTestReceiverWithToken(t, readToken)
readToken := "integration-read-secret"
hmacKey := "integration-hmac-secret"
_, server, cleanup := setupTestReceiverWithToken(t, readToken, hmacKey)
defer cleanup()
t.Setenv("GITHUB_REPOSITORY_OWNER", "token-org")
@ -358,7 +363,7 @@ func TestPushClientWithWrongTokenIntegration(t *testing.T) {
}
}
func setupTestReceiverWithToken(t *testing.T, token string) (*receiver.Store, *httptest.Server, func()) {
func setupTestReceiverWithToken(t *testing.T, readToken, hmacKey string) (*receiver.Store, *httptest.Server, func()) {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "test.db")
store, err := receiver.NewStore(dbPath)
@ -366,7 +371,7 @@ func setupTestReceiverWithToken(t *testing.T, token string) (*receiver.Store, *h
t.Fatalf("NewStore() error = %v", err)
}
handler := receiver.NewHandler(store, slog.New(slog.NewTextHandler(io.Discard, nil)), token)
handler := receiver.NewHandler(store, slog.New(slog.NewTextHandler(io.Discard, nil)), readToken, hmacKey)
mux := http.NewServeMux()
handler.RegisterRoutes(mux)

View file

@ -14,13 +14,15 @@ import (
type Handler struct {
store *Store
logger *slog.Logger
readToken string // Pre-shared token for authentication (required)
readToken string // Pre-shared token for read endpoint authentication
hmacKey string // Separate key for HMAC-based push token generation/validation
}
// NewHandler creates a new HTTP handler with the given store.
// readToken is required for authenticating all metrics endpoints.
func NewHandler(store *Store, logger *slog.Logger, readToken string) *Handler {
return &Handler{store: store, logger: logger, readToken: readToken}
// readToken authenticates read endpoints and the token generation endpoint.
// hmacKey is the secret used to derive scoped push tokens.
func NewHandler(store *Store, logger *slog.Logger, readToken, hmacKey string) *Handler {
return &Handler{store: store, logger: logger, readToken: readToken, hmacKey: hmacKey}
}
// RegisterRoutes registers all HTTP routes on the given mux
@ -64,8 +66,8 @@ func (h *Handler) validateReadToken(w http.ResponseWriter, r *http.Request) bool
}
func (h *Handler) handleGenerateToken(w http.ResponseWriter, r *http.Request) {
if h.readToken == "" {
http.Error(w, "token generation requires a configured read-token", http.StatusBadRequest)
if h.hmacKey == "" {
http.Error(w, "token generation requires a configured HMAC key", http.StatusBadRequest)
return
}
@ -84,7 +86,7 @@ func (h *Handler) handleGenerateToken(w http.ResponseWriter, r *http.Request) {
return
}
token := GenerateScopedToken(h.readToken, req.Organization, req.Repository, req.Workflow, req.Job)
token := GenerateScopedToken(h.hmacKey, req.Organization, req.Repository, req.Workflow, req.Job)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(TokenResponse{Token: token})
@ -92,8 +94,8 @@ func (h *Handler) handleGenerateToken(w http.ResponseWriter, r *http.Request) {
// validatePushToken checks push authentication via scoped HMAC token.
func (h *Handler) validatePushToken(w http.ResponseWriter, r *http.Request, exec ExecutionContext) bool {
if h.readToken == "" {
h.logger.Warn("no read-token configured, rejecting push", slog.String("path", r.URL.Path))
if h.hmacKey == "" {
h.logger.Warn("no HMAC key configured, rejecting push", slog.String("path", r.URL.Path))
http.Error(w, "authorization required", http.StatusUnauthorized)
return false
}
@ -113,7 +115,7 @@ func (h *Handler) validatePushToken(w http.ResponseWriter, r *http.Request, exec
}
token := strings.TrimPrefix(authHeader, bearerPrefix)
if !ValidateScopedToken(h.readToken, token, exec.Organization, exec.Repository, exec.Workflow, exec.Job) {
if !ValidateScopedToken(h.hmacKey, token, exec.Organization, exec.Repository, exec.Workflow, exec.Job) {
h.logger.Warn("invalid push token", slog.String("path", r.URL.Path))
http.Error(w, "invalid token", http.StatusUnauthorized)
return false

View file

@ -448,12 +448,17 @@ func newTestHandler(t *testing.T) (*Handler, func()) {
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
handler := NewHandler(store, logger, "") // no auth token — endpoints will reject
handler := NewHandler(store, logger, "", "") // no auth — endpoints will reject
return handler, func() { _ = store.Close() }
}
func newTestHandlerWithToken(t *testing.T, token string) (*Handler, func()) {
func newTestHandlerWithToken(t *testing.T, readToken string) (*Handler, func()) {
t.Helper()
return newTestHandlerWithKeys(t, readToken, readToken)
}
func newTestHandlerWithKeys(t *testing.T, readToken, hmacKey string) (*Handler, func()) {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "test.db")
store, err := NewStore(dbPath)
@ -462,7 +467,7 @@ func newTestHandlerWithToken(t *testing.T, token string) (*Handler, func()) {
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
handler := NewHandler(store, logger, token)
handler := NewHandler(store, logger, readToken, hmacKey)
return handler, func() { _ = store.Close() }
}