go-api/httpsign_test.go
Snider 7b3f99e421 feat: add WithHTTPSign HTTP signature verification middleware
Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-20 23:57:30 +00:00

216 lines
5.9 KiB
Go

// SPDX-License-Identifier: EUPL-1.2
package api_test
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-contrib/httpsign"
"github.com/gin-contrib/httpsign/crypto"
"github.com/gin-gonic/gin"
api "forge.lthn.ai/core/go-api"
)
const testSecretKey = "test-secret-key-for-hmac-sha256"
// testKeyID is the key ID used in HTTP signature tests.
var testKeyID = httpsign.KeyID("test-client")
// newTestSecrets builds a Secrets map with a single HMAC-SHA256 key for testing.
func newTestSecrets() httpsign.Secrets {
return httpsign.Secrets{
testKeyID: &httpsign.Secret{
Key: testSecretKey,
Algorithm: &crypto.HmacSha256{},
},
}
}
// signRequest constructs a valid HTTP Signature Authorization header for the
// given request, signing the specified headers with HMAC-SHA256 and the test
// secret key. The Date header is set to the current time if not already present.
func signRequest(req *http.Request, keyID httpsign.KeyID, secret string, headers []string) {
// Ensure a Date header exists.
if req.Header.Get("Date") == "" {
req.Header.Set("Date", time.Now().UTC().Format(http.TimeFormat))
}
// Build the signing string in the same way the library does:
// each header as "header: value", joined by newlines.
var parts []string
for _, h := range headers {
var val string
switch h {
case "(request-target)":
val = fmt.Sprintf("%s %s", strings.ToLower(req.Method), req.URL.RequestURI())
case "host":
val = req.Host
default:
val = req.Header.Get(h)
}
parts = append(parts, fmt.Sprintf("%s: %s", h, val))
}
signingString := strings.Join(parts, "\n")
// Sign with HMAC-SHA256.
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(signingString))
sig := base64.StdEncoding.EncodeToString(mac.Sum(nil))
// Build the Authorization header.
authValue := fmt.Sprintf(
"Signature keyId=\"%s\",algorithm=\"hmac-sha256\",headers=\"%s\",signature=\"%s\"",
keyID,
strings.Join(headers, " "),
sig,
)
req.Header.Set("Authorization", authValue)
}
// ── WithHTTPSign ──────────────────────────────────────────────────────────
func TestWithHTTPSign_Good_ValidSignatureAccepted(t *testing.T) {
gin.SetMode(gin.TestMode)
// Use only (request-target) and date as required headers, disable
// validators to keep the test focused on signature verification.
requiredHeaders := []string{"(request-target)", "date"}
e, _ := api.New(api.WithHTTPSign(
newTestSecrets(),
httpsign.WithRequiredHeaders(requiredHeaders),
httpsign.WithValidator(), // no validators — pure signature check
))
e.Register(&stubGroup{})
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
signRequest(req, testKeyID, testSecretKey, requiredHeaders)
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 for validly signed request, got %d (body: %s)", w.Code, w.Body.String())
}
}
func TestWithHTTPSign_Bad_InvalidSignatureRejected(t *testing.T) {
gin.SetMode(gin.TestMode)
requiredHeaders := []string{"(request-target)", "date"}
e, _ := api.New(api.WithHTTPSign(
newTestSecrets(),
httpsign.WithRequiredHeaders(requiredHeaders),
httpsign.WithValidator(),
))
e.Register(&stubGroup{})
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
// Sign with the wrong secret so the signature is invalid.
signRequest(req, testKeyID, "wrong-secret-key", requiredHeaders)
h.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 for invalid signature, got %d", w.Code)
}
}
func TestWithHTTPSign_Bad_MissingSignatureRejected(t *testing.T) {
gin.SetMode(gin.TestMode)
requiredHeaders := []string{"(request-target)", "date"}
e, _ := api.New(api.WithHTTPSign(
newTestSecrets(),
httpsign.WithRequiredHeaders(requiredHeaders),
httpsign.WithValidator(),
))
e.Register(&stubGroup{})
h := e.Handler()
w := httptest.NewRecorder()
// Send a request with no signature at all.
req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
req.Header.Set("Date", time.Now().UTC().Format(http.TimeFormat))
h.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 for missing signature, got %d", w.Code)
}
}
func TestWithHTTPSign_Good_CombinesWithOtherMiddleware(t *testing.T) {
gin.SetMode(gin.TestMode)
requiredHeaders := []string{"(request-target)", "date"}
e, _ := api.New(
api.WithRequestID(),
api.WithHTTPSign(
newTestSecrets(),
httpsign.WithRequiredHeaders(requiredHeaders),
httpsign.WithValidator(),
),
)
e.Register(&stubGroup{})
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
signRequest(req, testKeyID, testSecretKey, requiredHeaders)
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d (body: %s)", w.Code, w.Body.String())
}
// Verify that WithRequestID also ran.
if w.Header().Get("X-Request-ID") == "" {
t.Fatal("expected X-Request-ID header from WithRequestID")
}
}
func TestWithHTTPSign_Ugly_UnknownKeyIDRejected(t *testing.T) {
gin.SetMode(gin.TestMode)
requiredHeaders := []string{"(request-target)", "date"}
e, _ := api.New(api.WithHTTPSign(
newTestSecrets(),
httpsign.WithRequiredHeaders(requiredHeaders),
httpsign.WithValidator(),
))
e.Register(&stubGroup{})
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
// Sign with an unknown key ID that does not exist in the secrets map.
unknownKeyID := httpsign.KeyID("unknown-client")
signRequest(req, unknownKeyID, testSecretKey, requiredHeaders)
h.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 for unknown key ID, got %d", w.Code)
}
}