Files
Cyrene_v2/internal/auth/jwt_test.go
T

225 lines
6.2 KiB
Go

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 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))
}