mirror of
https://github.com/luxfi/mpc.git
synced 2026-09-24 00:26:24 +00:00
- handlers_kms_test.go: input validation, orgID from context not header - auth_test.go: JWT iss/aud enforcement, refresh/access token separation - ratelimit_test.go: clientIP uses RemoteAddr, ignores XFF - server_test.go: maxBodySize middleware enforcement
317 lines
7.8 KiB
Go
317 lines
7.8 KiB
Go
package api
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
)
|
|
|
|
func newTestServer(secret string) *Server {
|
|
return &Server{
|
|
jwtSecret: []byte(secret),
|
|
}
|
|
}
|
|
|
|
func TestGenerateAndValidateJWT(t *testing.T) {
|
|
s := newTestServer("test-secret-key-for-jwt")
|
|
|
|
token, err := s.generateJWT("user-123", "org-456", "admin")
|
|
if err != nil {
|
|
t.Fatalf("generateJWT: %v", err)
|
|
}
|
|
if token == "" {
|
|
t.Fatal("generateJWT returned empty token")
|
|
}
|
|
|
|
claims, err := s.validateJWT(token)
|
|
if err != nil {
|
|
t.Fatalf("validateJWT: %v", err)
|
|
}
|
|
|
|
if claims.UserID != "user-123" {
|
|
t.Errorf("UserID = %q, want %q", claims.UserID, "user-123")
|
|
}
|
|
if claims.OrgID != "org-456" {
|
|
t.Errorf("OrgID = %q, want %q", claims.OrgID, "org-456")
|
|
}
|
|
if claims.Role != "admin" {
|
|
t.Errorf("Role = %q, want %q", claims.Role, "admin")
|
|
}
|
|
|
|
// Check expiry is ~24h from now
|
|
if claims.ExpiresAt == nil {
|
|
t.Fatal("ExpiresAt is nil")
|
|
}
|
|
expDelta := time.Until(claims.ExpiresAt.Time)
|
|
if expDelta < 23*time.Hour || expDelta > 25*time.Hour {
|
|
t.Errorf("ExpiresAt delta = %v, want ~24h", expDelta)
|
|
}
|
|
}
|
|
|
|
func TestHashPassword(t *testing.T) {
|
|
password := "correct-horse-battery-staple"
|
|
|
|
hash, err := hashPassword(password)
|
|
if err != nil {
|
|
t.Fatalf("hashPassword: %v", err)
|
|
}
|
|
if hash == "" {
|
|
t.Fatal("hashPassword returned empty hash")
|
|
}
|
|
if hash == password {
|
|
t.Fatal("hash must not equal plaintext password")
|
|
}
|
|
|
|
// Correct password verifies
|
|
if !checkPassword(hash, password) {
|
|
t.Error("checkPassword(hash, correct) = false, want true")
|
|
}
|
|
|
|
// Wrong password does not verify
|
|
if checkPassword(hash, "wrong-password") {
|
|
t.Error("checkPassword(hash, wrong) = true, want false")
|
|
}
|
|
}
|
|
|
|
func TestGenerateAPIKey(t *testing.T) {
|
|
key, hash, err := generateAPIKeyToken()
|
|
if err != nil {
|
|
t.Fatalf("generateAPIKeyToken: %v", err)
|
|
}
|
|
|
|
// Key has "lux_" prefix
|
|
if !strings.HasPrefix(key, "lux_") {
|
|
t.Errorf("key prefix = %q, want lux_", key[:4])
|
|
}
|
|
|
|
// Key is lux_ + 64 hex chars = 68 chars total
|
|
if len(key) != 68 {
|
|
t.Errorf("key length = %d, want 68", len(key))
|
|
}
|
|
|
|
// Hash is a 64-char hex string (SHA-256)
|
|
if len(hash) != 64 {
|
|
t.Errorf("hash length = %d, want 64", len(hash))
|
|
}
|
|
|
|
// Two calls produce different keys
|
|
key2, hash2, err := generateAPIKeyToken()
|
|
if err != nil {
|
|
t.Fatalf("generateAPIKeyToken (2nd call): %v", err)
|
|
}
|
|
if key == key2 {
|
|
t.Error("two calls produced identical keys")
|
|
}
|
|
if hash == hash2 {
|
|
t.Error("two calls produced identical hashes")
|
|
}
|
|
}
|
|
|
|
func TestInvalidJWT_WrongSecret(t *testing.T) {
|
|
s1 := newTestServer("secret-one")
|
|
s2 := newTestServer("secret-two")
|
|
|
|
token, err := s1.generateJWT("user-1", "org-1", "admin")
|
|
if err != nil {
|
|
t.Fatalf("generateJWT: %v", err)
|
|
}
|
|
|
|
// Validate with different secret must fail
|
|
_, err = s2.validateJWT(token)
|
|
if err == nil {
|
|
t.Fatal("validateJWT with wrong secret should fail")
|
|
}
|
|
}
|
|
|
|
func TestInvalidJWT_Expired(t *testing.T) {
|
|
s := newTestServer("test-secret")
|
|
|
|
// Manually create an expired token
|
|
claims := JWTClaims{
|
|
UserID: "user-exp",
|
|
OrgID: "org-exp",
|
|
Role: "viewer",
|
|
RegisteredClaims: jwt.RegisteredClaims{
|
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(-1 * time.Hour)),
|
|
IssuedAt: jwt.NewNumericDate(time.Now().Add(-2 * time.Hour)),
|
|
},
|
|
}
|
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
|
tokenStr, err := token.SignedString(s.jwtSecret)
|
|
if err != nil {
|
|
t.Fatalf("sign expired token: %v", err)
|
|
}
|
|
|
|
_, err = s.validateJWT(tokenStr)
|
|
if err == nil {
|
|
t.Fatal("validateJWT should reject expired token")
|
|
}
|
|
}
|
|
|
|
func TestInvalidJWT_Malformed(t *testing.T) {
|
|
s := newTestServer("test-secret")
|
|
|
|
_, err := s.validateJWT("not-a-jwt-token")
|
|
if err == nil {
|
|
t.Fatal("validateJWT should reject malformed token")
|
|
}
|
|
}
|
|
|
|
func TestInvalidJWT_EmptyString(t *testing.T) {
|
|
s := newTestServer("test-secret")
|
|
|
|
_, err := s.validateJWT("")
|
|
if err == nil {
|
|
t.Fatal("validateJWT should reject empty string")
|
|
}
|
|
}
|
|
|
|
// === JWT Issuer / Audience regression tests ===
|
|
|
|
func TestAccessToken_IssuerAndAudience(t *testing.T) {
|
|
s := newTestServer("iss-aud-secret")
|
|
|
|
tokenStr, err := s.generateJWT("u1", "o1", "admin")
|
|
if err != nil {
|
|
t.Fatalf("generateJWT: %v", err)
|
|
}
|
|
|
|
// Parse without validation to inspect raw claims.
|
|
parser := jwt.NewParser(jwt.WithoutClaimsValidation())
|
|
token, _, err := parser.ParseUnverified(tokenStr, &JWTClaims{})
|
|
if err != nil {
|
|
t.Fatalf("ParseUnverified: %v", err)
|
|
}
|
|
claims := token.Claims.(*JWTClaims)
|
|
|
|
if claims.Issuer != "mpc.lux.network" {
|
|
t.Errorf("access token Issuer = %q, want %q", claims.Issuer, "mpc.lux.network")
|
|
}
|
|
found := false
|
|
for _, aud := range claims.Audience {
|
|
if aud == "mpc-api" {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("access token Audience = %v, want to contain %q", claims.Audience, "mpc-api")
|
|
}
|
|
}
|
|
|
|
func TestRefreshToken_IssuerAndAudience(t *testing.T) {
|
|
s := newTestServer("iss-aud-secret")
|
|
|
|
tokenStr, err := s.generateRefreshToken("u1", "o1", "admin")
|
|
if err != nil {
|
|
t.Fatalf("generateRefreshToken: %v", err)
|
|
}
|
|
|
|
parser := jwt.NewParser(jwt.WithoutClaimsValidation())
|
|
token, _, err := parser.ParseUnverified(tokenStr, &JWTClaims{})
|
|
if err != nil {
|
|
t.Fatalf("ParseUnverified: %v", err)
|
|
}
|
|
claims := token.Claims.(*JWTClaims)
|
|
|
|
if claims.Issuer != "mpc.lux.network" {
|
|
t.Errorf("refresh token Issuer = %q, want %q", claims.Issuer, "mpc.lux.network")
|
|
}
|
|
found := false
|
|
for _, aud := range claims.Audience {
|
|
if aud == "mpc-refresh" {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("refresh token Audience = %v, want to contain %q", claims.Audience, "mpc-refresh")
|
|
}
|
|
}
|
|
|
|
func TestValidateJWT_RejectsWrongIssuer(t *testing.T) {
|
|
s := newTestServer("iss-test-secret")
|
|
|
|
// Create a token with wrong issuer.
|
|
claims := JWTClaims{
|
|
UserID: "u1",
|
|
OrgID: "o1",
|
|
Role: "admin",
|
|
RegisteredClaims: jwt.RegisteredClaims{
|
|
Issuer: "evil.example.com",
|
|
Audience: jwt.ClaimStrings{"mpc-api"},
|
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
|
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
|
},
|
|
}
|
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
|
tokenStr, err := token.SignedString(s.jwtSecret)
|
|
if err != nil {
|
|
t.Fatalf("sign token: %v", err)
|
|
}
|
|
|
|
_, err = s.validateJWT(tokenStr)
|
|
if err == nil {
|
|
t.Fatal("validateJWT should reject token with wrong issuer")
|
|
}
|
|
}
|
|
|
|
func TestValidateJWT_RejectsWrongAudience(t *testing.T) {
|
|
s := newTestServer("aud-test-secret")
|
|
|
|
// Create a token with wrong audience.
|
|
claims := JWTClaims{
|
|
UserID: "u1",
|
|
OrgID: "o1",
|
|
Role: "admin",
|
|
RegisteredClaims: jwt.RegisteredClaims{
|
|
Issuer: "mpc.lux.network",
|
|
Audience: jwt.ClaimStrings{"wrong-audience"},
|
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
|
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
|
},
|
|
}
|
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
|
tokenStr, err := token.SignedString(s.jwtSecret)
|
|
if err != nil {
|
|
t.Fatalf("sign token: %v", err)
|
|
}
|
|
|
|
_, err = s.validateJWT(tokenStr)
|
|
if err == nil {
|
|
t.Fatal("validateJWT should reject token with wrong audience")
|
|
}
|
|
}
|
|
|
|
func TestValidateJWT_RejectsRefreshToken(t *testing.T) {
|
|
s := newTestServer("cross-token-secret")
|
|
|
|
// A refresh token (audience "mpc-refresh") must NOT pass validateJWT
|
|
// (which expects audience "mpc-api").
|
|
refreshStr, err := s.generateRefreshToken("u1", "o1", "admin")
|
|
if err != nil {
|
|
t.Fatalf("generateRefreshToken: %v", err)
|
|
}
|
|
|
|
_, err = s.validateJWT(refreshStr)
|
|
if err == nil {
|
|
t.Fatal("validateJWT should reject a refresh token (audience mpc-refresh)")
|
|
}
|
|
}
|
|
|
|
func TestValidateRefreshToken_RejectsAccessToken(t *testing.T) {
|
|
s := newTestServer("cross-token-secret")
|
|
|
|
// An access token (audience "mpc-api") must NOT pass validateRefreshToken
|
|
// (which expects audience "mpc-refresh").
|
|
accessStr, err := s.generateJWT("u1", "o1", "admin")
|
|
if err != nil {
|
|
t.Fatalf("generateJWT: %v", err)
|
|
}
|
|
|
|
_, err = s.validateRefreshToken(accessStr)
|
|
if err == nil {
|
|
t.Fatal("validateRefreshToken should reject an access token (audience mpc-api)")
|
|
}
|
|
}
|