package auth import ( "crypto/hmac" "crypto/sha256" "encoding/base64" "encoding/json" "errors" "strings" "testing" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" ) func testConfig(secret string) config.AuthConfig { if secret == "" { secret = strings.Repeat("s", 40) } return config.AuthConfig{JWTSecret: secret} } func TestNew_ValidSecret(t *testing.T) { t.Parallel() svc := New(testConfig("")) if svc == nil { t.Fatal("New returned nil") } } func TestNew_ShortSecretPanics(t *testing.T) { t.Parallel() defer func() { r := recover() if r == nil { t.Fatal("expected panic for short JWTSecret, got none") } if !errors.Is(r.(error), ErrInvalidSecret) { t.Fatalf("panic = %v, want ErrInvalidSecret", r) } }() New(config.AuthConfig{JWTSecret: "short"}) } func TestValidate_SecretLength(t *testing.T) { t.Parallel() cases := []struct { name string secret string ok bool }{ {"32 chars", strings.Repeat("a", 32), true}, {"40 chars", strings.Repeat("a", 40), true}, {"31 chars", strings.Repeat("a", 31), false}, {"empty", "", false}, } for _, tc := range cases { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() err := Validate(config.AuthConfig{JWTSecret: tc.secret}) if tc.ok && err != nil { t.Fatalf("Validate(%d chars) error: %v", len(tc.secret), err) } if !tc.ok && !errors.Is(err, ErrInvalidSecret) { t.Fatalf("Validate(%d chars) err = %v, want ErrInvalidSecret", len(tc.secret), err) } }) } } func TestJWTSignVerify_Roundtrip(t *testing.T) { t.Parallel() svc := New(testConfig("")) before := time.Now().Unix() token, err := svc.Sign("user-1", RoleAdmin, time.Hour) if err != nil { t.Fatalf("Sign error: %v", err) } if token == "" { t.Fatal("Sign returned empty token") } claims, err := svc.Verify(token) if err != nil { t.Fatalf("Verify error: %v", err) } if claims.Sub != "user-1" { t.Errorf("Sub = %q, want user-1", claims.Sub) } if claims.Role != RoleAdmin { t.Errorf("Role = %q, want admin", claims.Role) } if claims.IssuedAt < before || claims.IssuedAt > before+1 { t.Errorf("IssuedAt = %d, want within [%d, %d]", claims.IssuedAt, before, before+1) } if claims.Exp != claims.IssuedAt+3600 { t.Errorf("Exp = %d, want IssuedAt+3600 (%d)", claims.Exp, claims.IssuedAt+3600) } } func TestJWTSign_InvalidRole(t *testing.T) { t.Parallel() svc := New(testConfig("")) if _, err := svc.Sign("u", Role("god"), time.Hour); !errors.Is(err, ErrInvalidRole) { t.Fatalf("Sign invalid role err = %v, want ErrInvalidRole", err) } } func TestJWTVerify_Expired(t *testing.T) { t.Parallel() svc := New(testConfig("")) token, err := svc.Sign("u", RoleViewer, -time.Minute) if err != nil { t.Fatalf("Sign error: %v", err) } if _, err := svc.Verify(token); !errors.Is(err, ErrTokenExpired) { t.Fatalf("Verify expired err = %v, want ErrTokenExpired", err) } } func TestJWTVerify_TamperedPayload(t *testing.T) { t.Parallel() svc := New(testConfig("")) token, err := svc.Sign("u", RoleViewer, time.Hour) if err != nil { t.Fatalf("Sign error: %v", err) } parts := strings.Split(token, ".") // Flip a character in a bit-significant position of the payload (not the // final trailing base64 group, whose low bits are padding and may not change // the decoded bytes). The signature was computed over the original payload, // so it no longer matches. payload := parts[1] flip := byte('A') if payload[3] == 'A' { flip = 'B' } tampered := parts[0] + "." + payload[:3] + string(flip) + payload[4:] + "." + parts[2] if tampered == token { t.Fatal("tampered token unchanged, test ineffective") } if _, err := svc.Verify(tampered); !errors.Is(err, ErrInvalidSignature) { t.Fatalf("Verify tampered err = %v, want ErrInvalidSignature", err) } } func TestJWTVerify_WrongSecret(t *testing.T) { t.Parallel() signer := New(testConfig(strings.Repeat("a", 40))) verifier := New(testConfig(strings.Repeat("b", 40))) token, err := signer.Sign("u", RoleViewer, time.Hour) if err != nil { t.Fatalf("Sign error: %v", err) } if _, err := verifier.Verify(token); !errors.Is(err, ErrInvalidSignature) { t.Fatalf("Verify wrong secret err = %v, want ErrInvalidSignature", err) } } func TestJWTVerify_UnsupportedAlg(t *testing.T) { t.Parallel() secret := strings.Repeat("k", 40) svc := New(config.AuthConfig{JWTSecret: secret}) token := craftSignedToken(t, secret, "HS512", Claims{ Sub: "u", Role: RoleViewer, IssuedAt: time.Now().Unix(), Exp: time.Now().Add(time.Hour).Unix(), }) if _, err := svc.Verify(token); !errors.Is(err, ErrUnsupportedAlg) { t.Fatalf("Verify HS512 err = %v, want ErrUnsupportedAlg", err) } } func TestJWTVerify_InvalidRoleClaim(t *testing.T) { t.Parallel() secret := strings.Repeat("k", 40) svc := New(config.AuthConfig{JWTSecret: secret}) token := craftSignedToken(t, secret, "HS256", Claims{ Sub: "u", Role: Role("god"), IssuedAt: time.Now().Unix(), Exp: time.Now().Add(time.Hour).Unix(), }) if _, err := svc.Verify(token); !errors.Is(err, ErrInvalidRole) { t.Fatalf("Verify god role err = %v, want ErrInvalidRole", err) } } func TestJWTService_RotateSecret(t *testing.T) { t.Parallel() svc := New(testConfig(strings.Repeat("a", 40))) token, err := svc.Sign("u", RoleOwner, time.Hour) if err != nil { t.Fatalf("Sign error: %v", err) } if _, err := svc.Verify(token); err != nil { t.Fatalf("Verify before rotation error: %v", err) } if err := svc.RotateSecret(strings.Repeat("b", 40)); err != nil { t.Fatalf("RotateSecret error: %v", err) } // The previously issued token no longer verifies under the new secret. if _, err := svc.Verify(token); !errors.Is(err, ErrInvalidSignature) { t.Fatalf("Verify after rotation err = %v, want ErrInvalidSignature", err) } // A token signed under the new secret verifies fine. newToken, err := svc.Sign("u", RoleOwner, time.Hour) if err != nil { t.Fatalf("Sign after rotation error: %v", err) } if _, err := svc.Verify(newToken); err != nil { t.Fatalf("Verify under new secret error: %v", err) } } func TestJWTService_RotateSecretShort(t *testing.T) { t.Parallel() svc := New(testConfig(strings.Repeat("a", 40))) if err := svc.RotateSecret("short"); !errors.Is(err, ErrInvalidSecret) { t.Fatalf("RotateSecret(short) err = %v, want ErrInvalidSecret", err) } // A rejected short secret leaves the previous secret intact. token, err := svc.Sign("u", RoleOwner, time.Hour) if err != nil { t.Fatalf("Sign error: %v", err) } if _, err := svc.Verify(token); err != nil { t.Fatalf("Verify after rejected rotation error: %v", err) } } func TestJWTVerify_Malformed(t *testing.T) { t.Parallel() svc := New(testConfig("")) input := map[string]string{ "no segments": "token", "barely two": "a.b", "bad base64 sig": "a.b.c", } for name, tok := range input { tok := tok t.Run(name, func(t *testing.T) { t.Parallel() if _, err := svc.Verify(tok); !errors.Is(err, ErrMalformedToken) { t.Fatalf("Verify(%q) err = %v, want ErrMalformedToken", tok, err) } }) } } // craftSignedToken builds an HS256-signed token with an arbitrary header alg, // used to probe alg policy and role validation. func craftSignedToken(t *testing.T, secret, alg string, claims Claims) string { t.Helper() headerB64 := createSegment(t, `{"alg":"`+alg+`","typ":"JWT"}`) payloadB, err := json.Marshal(claims) if err != nil { t.Fatalf("marshal claims: %v", err) } payloadB64 := base64.RawURLEncoding.EncodeToString(payloadB) signingInput := headerB64 + "." + payloadB64 mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(signingInput)) return signingInput + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) } func createSegment(t *testing.T, raw string) string { t.Helper() return base64.RawURLEncoding.EncodeToString([]byte(raw)) }