feat: added timestamp to HMAC to allow a TTL for the token
Some checks failed
ci / build (push) Failing after 25s
Some checks failed
ci / build (push) Failing after 25s
This commit is contained in:
parent
a96a1079eb
commit
7e3a4efb2d
6 changed files with 225 additions and 58 deletions
|
|
@ -24,6 +24,7 @@ func main() {
|
|||
dbPath := flag.String("db", defaultDBPath, "SQLite database path")
|
||||
readToken := flag.String("read-token", os.Getenv("RECEIVER_READ_TOKEN"), "Pre-shared token for read endpoints (or set RECEIVER_READ_TOKEN)")
|
||||
hmacKey := flag.String("hmac-key", os.Getenv("RECEIVER_HMAC_KEY"), "Secret key for push token generation/validation (or set RECEIVER_HMAC_KEY)")
|
||||
tokenTTL := flag.Duration("token-ttl", 2*time.Hour, "Time-to-live for push tokens (default 2h)")
|
||||
flag.Parse()
|
||||
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{
|
||||
|
|
@ -37,7 +38,7 @@ func main() {
|
|||
}
|
||||
defer func() { _ = store.Close() }()
|
||||
|
||||
handler := receiver.NewHandler(store, logger, *readToken, *hmacKey)
|
||||
handler := receiver.NewHandler(store, logger, *readToken, *hmacKey, *tokenTTL)
|
||||
mux := http.NewServeMux()
|
||||
handler.RegisterRoutes(mux)
|
||||
|
||||
|
|
|
|||
|
|
@ -32,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, testHMACKey)
|
||||
handler := receiver.NewHandler(store, slog.New(slog.NewTextHandler(io.Discard, nil)), testReadToken, testHMACKey, 0)
|
||||
mux := http.NewServeMux()
|
||||
handler.RegisterRoutes(mux)
|
||||
|
||||
|
|
@ -46,9 +46,9 @@ func setupTestReceiver(t *testing.T) (*receiver.Store, *httptest.Server, func())
|
|||
return store, server, cleanup
|
||||
}
|
||||
|
||||
// generatePushToken generates a scoped push token for an execution context
|
||||
// generatePushToken generates a push token for an execution context
|
||||
func generatePushToken(exec summary.ExecutionContext) string {
|
||||
return receiver.GenerateScopedToken(testHMACKey, exec.Organization, exec.Repository, exec.Workflow, exec.Job)
|
||||
return receiver.GenerateToken(testHMACKey, exec.Organization, exec.Repository, exec.Workflow, exec.Job)
|
||||
}
|
||||
|
||||
func TestPushClientToReceiver(t *testing.T) {
|
||||
|
|
@ -166,8 +166,8 @@ func TestPushClientIntegration(t *testing.T) {
|
|||
t.Setenv("GITHUB_JOB", "push-job")
|
||||
t.Setenv("GITHUB_RUN_ID", "push-run-456")
|
||||
|
||||
// Generate scoped push token
|
||||
pushToken := receiver.GenerateScopedToken(testHMACKey, "push-client-org", "push-client-repo", "push-test.yml", "push-job")
|
||||
// Generate push token
|
||||
pushToken := receiver.GenerateToken(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)
|
||||
|
|
@ -371,7 +371,7 @@ func setupTestReceiverWithToken(t *testing.T, readToken, hmacKey string) (*recei
|
|||
t.Fatalf("NewStore() error = %v", err)
|
||||
}
|
||||
|
||||
handler := receiver.NewHandler(store, slog.New(slog.NewTextHandler(io.Discard, nil)), readToken, hmacKey)
|
||||
handler := receiver.NewHandler(store, slog.New(slog.NewTextHandler(io.Discard, nil)), readToken, hmacKey, 0)
|
||||
mux := http.NewServeMux()
|
||||
handler.RegisterRoutes(mux)
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import (
|
|||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Handler handles HTTP requests for the metrics receiver
|
||||
|
|
@ -17,13 +18,18 @@ type Handler struct {
|
|||
logger *slog.Logger
|
||||
readToken string // Pre-shared token for read endpoint authentication
|
||||
hmacKey string // Separate key for HMAC-based push token generation/validation
|
||||
tokenTTL time.Duration
|
||||
}
|
||||
|
||||
// NewHandler creates a new HTTP handler with the given store.
|
||||
// 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}
|
||||
// tokenTTL specifies how long push tokens are valid (0 uses DefaultTokenTTL).
|
||||
func NewHandler(store *Store, logger *slog.Logger, readToken, hmacKey string, tokenTTL time.Duration) *Handler {
|
||||
if tokenTTL == 0 {
|
||||
tokenTTL = DefaultTokenTTL
|
||||
}
|
||||
return &Handler{store: store, logger: logger, readToken: readToken, hmacKey: hmacKey, tokenTTL: tokenTTL}
|
||||
}
|
||||
|
||||
// RegisterRoutes registers all HTTP routes on the given mux
|
||||
|
|
@ -88,7 +94,7 @@ func (h *Handler) handleGenerateToken(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
token := GenerateScopedToken(h.hmacKey, req.Organization, req.Repository, req.Workflow, req.Job)
|
||||
token := GenerateToken(h.hmacKey, req.Organization, req.Repository, req.Workflow, req.Job)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(TokenResponse{Token: token})
|
||||
|
|
@ -117,7 +123,7 @@ func (h *Handler) validatePushToken(w http.ResponseWriter, r *http.Request, exec
|
|||
}
|
||||
|
||||
token := strings.TrimPrefix(authHeader, bearerPrefix)
|
||||
if !ValidateScopedToken(h.hmacKey, token, exec.Organization, exec.Repository, exec.Workflow, exec.Job) {
|
||||
if !ValidateToken(h.hmacKey, token, exec.Organization, exec.Repository, exec.Workflow, exec.Job, h.tokenTTL) {
|
||||
h.logger.Warn("invalid push token", slog.String("path", r.URL.Path))
|
||||
http.Error(w, "invalid token", http.StatusUnauthorized)
|
||||
return false
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import (
|
|||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"edp.buildth.ing/DevFW-CICD/forgejo-runner-optimiser/internal/summary"
|
||||
|
|
@ -25,7 +26,7 @@ func TestHandler_ReceiveMetrics(t *testing.T) {
|
|||
Job: "build",
|
||||
RunID: "run-123",
|
||||
}
|
||||
pushToken := GenerateScopedToken(readToken, exec.Organization, exec.Repository, exec.Workflow, exec.Job)
|
||||
pushToken := GenerateToken(readToken, exec.Organization, exec.Repository, exec.Workflow, exec.Job)
|
||||
|
||||
payload := MetricsPayload{
|
||||
Execution: exec,
|
||||
|
|
@ -264,8 +265,13 @@ func TestHandler_GenerateToken(t *testing.T) {
|
|||
if resp.Token == "" {
|
||||
t.Error("expected non-empty token")
|
||||
}
|
||||
if len(resp.Token) != 64 {
|
||||
t.Errorf("token length = %d, want 64", len(resp.Token))
|
||||
// Token format is "timestamp:hmac" where hmac is 64 hex chars
|
||||
parts := strings.SplitN(resp.Token, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
t.Errorf("token should have format 'timestamp:hmac', got %q", resp.Token)
|
||||
}
|
||||
if len(parts[1]) != 64 {
|
||||
t.Errorf("HMAC part length = %d, want 64", len(parts[1]))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -357,8 +363,8 @@ func TestHandler_ReceiveMetrics_WithPushToken(t *testing.T) {
|
|||
RunID: "run-1",
|
||||
}
|
||||
|
||||
validToken := GenerateScopedToken(readToken, exec.Organization, exec.Repository, exec.Workflow, exec.Job)
|
||||
wrongScopeToken := GenerateScopedToken(readToken, "other-org", "repo", "ci.yml", "build")
|
||||
validToken := GenerateToken(readToken, exec.Organization, exec.Repository, exec.Workflow, exec.Job)
|
||||
wrongScopeToken := GenerateToken(readToken, "other-org", "repo", "ci.yml", "build")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
|
|
@ -448,7 +454,7 @@ func newTestHandler(t *testing.T) (*Handler, func()) {
|
|||
}
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
handler := NewHandler(store, logger, "", "") // no auth — endpoints will reject
|
||||
handler := NewHandler(store, logger, "", "", 0) // no auth — endpoints will reject
|
||||
|
||||
return handler, func() { _ = store.Close() }
|
||||
}
|
||||
|
|
@ -467,7 +473,7 @@ func newTestHandlerWithKeys(t *testing.T, readToken, hmacKey string) (*Handler,
|
|||
}
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
handler := NewHandler(store, logger, readToken, hmacKey)
|
||||
handler := NewHandler(store, logger, readToken, hmacKey, 0) // 0 uses DefaultTokenTTL
|
||||
|
||||
return handler, func() { _ = store.Close() }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
// ABOUTME: HMAC-SHA256 token generation and validation for scoped push authentication.
|
||||
// ABOUTME: Tokens are derived from a key + scope, enabling stateless validation without DB storage.
|
||||
// ABOUTME: Tokens are derived from a key + scope + timestamp, enabling stateless validation with expiration.
|
||||
package receiver
|
||||
|
||||
import (
|
||||
|
|
@ -7,19 +7,71 @@ import (
|
|||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// GenerateScopedToken computes an HMAC-SHA256 token scoped to a specific org/repo/workflow/job.
|
||||
// The canonical input is "v1\x00<org>\x00<repo>\x00<workflow>\x00<job>".
|
||||
func GenerateScopedToken(key, org, repo, workflow, job string) string {
|
||||
mac := hmac.New(sha256.New, []byte(key))
|
||||
mac.Write([]byte("v1\x00" + org + "\x00" + repo + "\x00" + workflow + "\x00" + job))
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
// DefaultTokenTTL is the default time-to-live for push tokens.
|
||||
const DefaultTokenTTL = 2 * time.Hour
|
||||
|
||||
// GenerateToken creates a token with embedded timestamp for expiration support.
|
||||
// Format: "<unix_timestamp>:<hmac_hex>"
|
||||
func GenerateToken(key, org, repo, workflow, job string) string {
|
||||
return GenerateTokenAt(key, org, repo, workflow, job, time.Now())
|
||||
}
|
||||
|
||||
// ValidateScopedToken checks whether a token matches the expected HMAC for the given scope.
|
||||
// Uses constant-time comparison to prevent timing attacks.
|
||||
func ValidateScopedToken(key, token, org, repo, workflow, job string) bool {
|
||||
expected := GenerateScopedToken(key, org, repo, workflow, job)
|
||||
return subtle.ConstantTimeCompare([]byte(token), []byte(expected)) == 1
|
||||
// GenerateTokenAt creates a token with the specified timestamp.
|
||||
// The HMAC input is "v1\x00<org>\x00<repo>\x00<workflow>\x00<job>\x00<timestamp>".
|
||||
func GenerateTokenAt(key, org, repo, workflow, job string, timestamp time.Time) string {
|
||||
ts := strconv.FormatInt(timestamp.Unix(), 10)
|
||||
mac := hmac.New(sha256.New, []byte(key))
|
||||
mac.Write([]byte("v1\x00" + org + "\x00" + repo + "\x00" + workflow + "\x00" + job + "\x00" + ts))
|
||||
return ts + ":" + hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
// ValidateToken validates a token and checks expiration.
|
||||
// Returns true if the token is valid and not expired.
|
||||
func ValidateToken(key, token, org, repo, workflow, job string, ttl time.Duration) bool {
|
||||
return ValidateTokenAt(key, token, org, repo, workflow, job, ttl, time.Now())
|
||||
}
|
||||
|
||||
// ValidateTokenAt validates a token against a specific reference time.
|
||||
func ValidateTokenAt(key, token, org, repo, workflow, job string, ttl time.Duration, now time.Time) bool {
|
||||
parts := strings.SplitN(token, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return false
|
||||
}
|
||||
|
||||
tsStr, hmacHex := parts[0], parts[1]
|
||||
ts, err := strconv.ParseInt(tsStr, 10, 64)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
tokenTime := time.Unix(ts, 0)
|
||||
if now.Sub(tokenTime) > ttl {
|
||||
return false
|
||||
}
|
||||
|
||||
// Recompute expected HMAC
|
||||
mac := hmac.New(sha256.New, []byte(key))
|
||||
mac.Write([]byte("v1\x00" + org + "\x00" + repo + "\x00" + workflow + "\x00" + job + "\x00" + tsStr))
|
||||
expected := hex.EncodeToString(mac.Sum(nil))
|
||||
|
||||
return subtle.ConstantTimeCompare([]byte(hmacHex), []byte(expected)) == 1
|
||||
}
|
||||
|
||||
// ParseTokenTimestamp extracts the timestamp from a timestamped token without validating it.
|
||||
func ParseTokenTimestamp(token string) (time.Time, error) {
|
||||
parts := strings.SplitN(token, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return time.Time{}, fmt.Errorf("invalid token format")
|
||||
}
|
||||
ts, err := strconv.ParseInt(parts[0], 10, 64)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("invalid timestamp: %w", err)
|
||||
}
|
||||
return time.Unix(ts, 0), nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,35 @@
|
|||
package receiver
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestGenerateScopedToken_Deterministic(t *testing.T) {
|
||||
token1 := GenerateScopedToken("key", "org", "repo", "wf", "job")
|
||||
token2 := GenerateScopedToken("key", "org", "repo", "wf", "job")
|
||||
func TestGenerateToken_Format(t *testing.T) {
|
||||
token := GenerateToken("key", "org", "repo", "wf", "job")
|
||||
parts := strings.SplitN(token, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
t.Fatalf("token should have format 'timestamp:hmac', got %q", token)
|
||||
}
|
||||
if len(parts[1]) != 64 {
|
||||
t.Errorf("HMAC part length = %d, want 64", len(parts[1]))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateTokenAt_Deterministic(t *testing.T) {
|
||||
ts := time.Unix(1700000000, 0)
|
||||
token1 := GenerateTokenAt("key", "org", "repo", "wf", "job", ts)
|
||||
token2 := GenerateTokenAt("key", "org", "repo", "wf", "job", ts)
|
||||
if token1 != token2 {
|
||||
t.Errorf("tokens differ: %q vs %q", token1, token2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateScopedToken_ScopePinning(t *testing.T) {
|
||||
base := GenerateScopedToken("key", "org", "repo", "wf", "job")
|
||||
func TestGenerateTokenAt_ScopePinning(t *testing.T) {
|
||||
ts := time.Unix(1700000000, 0)
|
||||
base := GenerateTokenAt("key", "org", "repo", "wf", "job", ts)
|
||||
|
||||
variants := []struct {
|
||||
name string
|
||||
|
|
@ -31,7 +46,7 @@ func TestGenerateScopedToken_ScopePinning(t *testing.T) {
|
|||
|
||||
for _, v := range variants {
|
||||
t.Run(v.name, func(t *testing.T) {
|
||||
token := GenerateScopedToken("key", v.org, v.repo, v.wf, v.job)
|
||||
token := GenerateTokenAt("key", v.org, v.repo, v.wf, v.job, ts)
|
||||
if token == base {
|
||||
t.Errorf("token for %s should differ from base", v.name)
|
||||
}
|
||||
|
|
@ -39,40 +54,127 @@ func TestGenerateScopedToken_ScopePinning(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestGenerateScopedToken_DifferentKeys(t *testing.T) {
|
||||
token1 := GenerateScopedToken("key-a", "org", "repo", "wf", "job")
|
||||
token2 := GenerateScopedToken("key-b", "org", "repo", "wf", "job")
|
||||
func TestGenerateTokenAt_DifferentKeys(t *testing.T) {
|
||||
ts := time.Unix(1700000000, 0)
|
||||
token1 := GenerateTokenAt("key-a", "org", "repo", "wf", "job", ts)
|
||||
token2 := GenerateTokenAt("key-b", "org", "repo", "wf", "job", ts)
|
||||
if token1 == token2 {
|
||||
t.Error("different keys should produce different tokens")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateScopedToken_ValidHex(t *testing.T) {
|
||||
token := GenerateScopedToken("key", "org", "repo", "wf", "job")
|
||||
if len(token) != 64 {
|
||||
t.Errorf("token length = %d, want 64", len(token))
|
||||
}
|
||||
if _, err := hex.DecodeString(token); err != nil {
|
||||
t.Errorf("token is not valid hex: %v", err)
|
||||
func TestGenerateTokenAt_DifferentTimestamps(t *testing.T) {
|
||||
ts1 := time.Unix(1700000000, 0)
|
||||
ts2 := time.Unix(1700000001, 0)
|
||||
token1 := GenerateTokenAt("key", "org", "repo", "wf", "job", ts1)
|
||||
token2 := GenerateTokenAt("key", "org", "repo", "wf", "job", ts2)
|
||||
if token1 == token2 {
|
||||
t.Error("different timestamps should produce different tokens")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateScopedToken_Correct(t *testing.T) {
|
||||
token := GenerateScopedToken("key", "org", "repo", "wf", "job")
|
||||
if !ValidateScopedToken("key", token, "org", "repo", "wf", "job") {
|
||||
t.Error("ValidateScopedToken should accept correct token")
|
||||
func TestValidateToken_Correct(t *testing.T) {
|
||||
ts := time.Now()
|
||||
token := GenerateTokenAt("key", "org", "repo", "wf", "job", ts)
|
||||
if !ValidateToken("key", token, "org", "repo", "wf", "job", 5*time.Minute) {
|
||||
t.Error("ValidateToken should accept correct token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateScopedToken_WrongToken(t *testing.T) {
|
||||
if ValidateScopedToken("key", "deadbeef", "org", "repo", "wf", "job") {
|
||||
t.Error("ValidateScopedToken should reject wrong token")
|
||||
func TestValidateToken_WrongToken(t *testing.T) {
|
||||
if ValidateToken("key", "12345:deadbeef", "org", "repo", "wf", "job", 5*time.Minute) {
|
||||
t.Error("ValidateToken should reject wrong token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateScopedToken_WrongScope(t *testing.T) {
|
||||
token := GenerateScopedToken("key", "org", "repo", "wf", "job")
|
||||
if ValidateScopedToken("key", token, "org", "repo", "wf", "other-job") {
|
||||
t.Error("ValidateScopedToken should reject token for different scope")
|
||||
func TestValidateToken_WrongScope(t *testing.T) {
|
||||
ts := time.Now()
|
||||
token := GenerateTokenAt("key", "org", "repo", "wf", "job", ts)
|
||||
if ValidateToken("key", token, "org", "repo", "wf", "other-job", 5*time.Minute) {
|
||||
t.Error("ValidateToken should reject token for different scope")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateToken_Expired(t *testing.T) {
|
||||
ts := time.Now().Add(-10 * time.Minute)
|
||||
token := GenerateTokenAt("key", "org", "repo", "wf", "job", ts)
|
||||
if ValidateToken("key", token, "org", "repo", "wf", "job", 5*time.Minute) {
|
||||
t.Error("ValidateToken should reject expired token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTokenAt_NotExpired(t *testing.T) {
|
||||
tokenTime := time.Unix(1700000000, 0)
|
||||
token := GenerateTokenAt("key", "org", "repo", "wf", "job", tokenTime)
|
||||
|
||||
// Validate at 4 minutes later (within 5 minute TTL)
|
||||
now := tokenTime.Add(4 * time.Minute)
|
||||
if !ValidateTokenAt("key", token, "org", "repo", "wf", "job", 5*time.Minute, now) {
|
||||
t.Error("ValidateTokenAt should accept token within TTL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTokenAt_JustExpired(t *testing.T) {
|
||||
tokenTime := time.Unix(1700000000, 0)
|
||||
token := GenerateTokenAt("key", "org", "repo", "wf", "job", tokenTime)
|
||||
|
||||
// Validate at 6 minutes later (beyond 5 minute TTL)
|
||||
now := tokenTime.Add(6 * time.Minute)
|
||||
if ValidateTokenAt("key", token, "org", "repo", "wf", "job", 5*time.Minute, now) {
|
||||
t.Error("ValidateTokenAt should reject token beyond TTL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateToken_InvalidFormat(t *testing.T) {
|
||||
if ValidateToken("key", "no-colon-here", "org", "repo", "wf", "job", 5*time.Minute) {
|
||||
t.Error("ValidateToken should reject token without colon")
|
||||
}
|
||||
if ValidateToken("key", "not-a-number:abc123", "org", "repo", "wf", "job", 5*time.Minute) {
|
||||
t.Error("ValidateToken should reject token with invalid timestamp")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTokenTimestamp(t *testing.T) {
|
||||
ts := time.Unix(1700000000, 0)
|
||||
token := GenerateTokenAt("key", "org", "repo", "wf", "job", ts)
|
||||
|
||||
parsed, err := ParseTokenTimestamp(token)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseTokenTimestamp failed: %v", err)
|
||||
}
|
||||
if !parsed.Equal(ts) {
|
||||
t.Errorf("parsed timestamp = %v, want %v", parsed, ts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTokenTimestamp_Invalid(t *testing.T) {
|
||||
_, err := ParseTokenTimestamp("no-colon")
|
||||
if err == nil {
|
||||
t.Error("ParseTokenTimestamp should fail on missing colon")
|
||||
}
|
||||
|
||||
_, err = ParseTokenTimestamp("not-a-number:abc123")
|
||||
if err == nil {
|
||||
t.Error("ParseTokenTimestamp should fail on invalid timestamp")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateToken_TamperedTimestamp(t *testing.T) {
|
||||
// Generate a valid token
|
||||
ts := time.Now()
|
||||
token := GenerateTokenAt("key", "org", "repo", "wf", "job", ts)
|
||||
|
||||
parts := strings.SplitN(token, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
t.Fatalf("unexpected token format: %q", token)
|
||||
}
|
||||
hmacPart := parts[1]
|
||||
|
||||
// Tamper with timestamp (e.g., attacker tries to extend token lifetime)
|
||||
tamperedTimestamp := strconv.FormatInt(time.Now().Add(1*time.Hour).Unix(), 10)
|
||||
tamperedToken := tamperedTimestamp + ":" + hmacPart
|
||||
|
||||
if ValidateToken("key", tamperedToken, "org", "repo", "wf", "job", 5*time.Minute) {
|
||||
t.Error("ValidateToken should reject token with tampered timestamp")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue