/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
:                                                                               :
:   █▀ █ █▀▀   ·   Blazing-fast pentesting suite                                :
:   ▄█ █ █▀    ·   BSD 3-Clause License                                         :
:                                                                               :
:   (c) 2022-2026 vmfunc, xyzeva,                                               :
:                 lunchcat alumni & contributors                                :
:                                                                               :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/

package scan

import (
	"net/http"
	"net/http/httptest"
	"testing"
	"time"
)

// fixed jwt fixtures, generated offline. each exercises a distinct weakness.
const (
	// header {alg:none}, payload {sub:admin}, empty signature - forgeable.
	jwtNone = "eyJhbGciOiAibm9uZSIsICJ0eXAiOiAiSldUIn0." +
		"eyJzdWIiOiAiYWRtaW4iLCAicm9sZSI6ICJ1c2VyIn0."

	// HS256, no exp claim, signed with the bundled weak secret "secret".
	jwtWeakHS256 = "eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9." +
		"eyJzdWIiOiAiMTIzNDU2Nzg5MCIsICJuYW1lIjogInRlc3RlciJ9." +
		"JOjVfLa8gp3cvFkNVgOnmdrI1MCHZRA_ChBmCPF-Z8w"

	// HS256, exp in 2001 (long past), signed with a secret not in the wordlist.
	jwtExpired = "eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9." +
		"eyJzdWIiOiAieCIsICJleHAiOiAxMDAwMDAwMDAwfQ." +
		"gr28Ffm4wJkonHGSKmMD5Rj7e1pTt2o_EwG6lMWQeSc"

	// HS256 carrying a plaintext password claim (jwt bodies are not encrypted).
	jwtSensitive = "eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9." +
		"eyJzdWIiOiAieCIsICJwYXNzd29yZCI6ICJodW50ZXIyIiwgImV4cCI6IDk5OTk5OTk5OTl9." +
		"rjEf0CUa7_qppuINi6zL9vupJIX0rzSBhul7kKM9uSA"

	// HS384, signed with the bundled weak secret "secret".
	jwtWeakHS384 = "eyJhbGciOiJIUzM4NCIsInR5cCI6IkpXVCJ9." +
		"eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6InRlc3RlciJ9." +
		"OXNTuzKiGLxnpUjL24vvKlQzdOD-YKMinN8eu_v5luTXDUF65bHAQnz-M3VG2TVh"

	// HS512, signed with the bundled weak secret "secret".
	jwtWeakHS512 = "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9." +
		"eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6InRlc3RlciJ9." +
		"CXcZz0F9TTPg--B4WV1Vzty3gG_wcDG86H5QDSRe94MpcVXIcRTBK6H7OmqFyG4nNWYNXPOODCu426bgQMOzRQ"

	// HS384/HS512 signed with a strong secret absent from the wordlist; these
	// must never be cracked (no false positive on the wide-digest path).
	jwtStrongHS384 = "eyJhbGciOiJIUzM4NCIsInR5cCI6IkpXVCJ9." +
		"eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6InRlc3RlciJ9." +
		"vHhjZPoXZnnZEvVYxX64J2wm8qWk-e6y_T20qTy_Su6sPmoUSMHS4tv6_D-hfwrY"
	jwtStrongHS512 = "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9." +
		"eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6InRlc3RlciJ9." +
		"80ueFa0oI88ftySkn_MJ12GAd1r2cahXt_ICtCfWx58wJoAvEocbjBPC_efzOp8vm_39GlcCCDLeb6cFix3DBw"
)

// hasIssue reports whether the analyzed token carries an issue of the given kind.
func hasIssue(token *JWTToken, kind string) bool {
	for i := 0; i < len(token.Issues); i++ {
		if token.Issues[i].Kind == kind {
			return true
		}
	}
	return false
}

func TestJWT_AlgNoneAndMissingExpFlagged(t *testing.T) {
	// serve the alg:none token in the Authorization header echo.
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		w.Header().Set("Authorization", "Bearer "+jwtNone)
		w.WriteHeader(http.StatusOK)
	}))
	defer srv.Close()

	result, err := JWT(srv.URL, 5*time.Second, "")
	if err != nil {
		t.Fatalf("JWT: %v", err)
	}
	if result == nil || len(result.Tokens) != 1 {
		t.Fatalf("expected exactly one analyzed token, got %+v", result)
	}

	token := &result.Tokens[0]
	if !hasIssue(token, "alg:none") {
		t.Errorf("expected alg:none to be flagged, got issues %+v", token.Issues)
	}
	if !hasIssue(token, "missing exp") {
		t.Errorf("expected missing exp to be flagged, got issues %+v", token.Issues)
	}
	// the preview must never carry the whole token.
	if len(token.Preview) >= len(jwtNone) {
		t.Errorf("preview should be trimmed, got full token %q", token.Preview)
	}
}

func TestJWT_WeakSecretCracked(t *testing.T) {
	// token rides in a Set-Cookie this time.
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		http.SetCookie(w, &http.Cookie{Name: "session", Value: jwtWeakHS256})
		w.WriteHeader(http.StatusOK)
	}))
	defer srv.Close()

	result, err := JWT(srv.URL, 5*time.Second, "")
	if err != nil {
		t.Fatalf("JWT: %v", err)
	}
	if result == nil || len(result.Tokens) != 1 {
		t.Fatalf("expected one token, got %+v", result)
	}

	token := &result.Tokens[0]
	if token.WeakKey != "secret" {
		t.Errorf("expected weak secret 'secret' to be cracked, got %q", token.WeakKey)
	}
	if !hasIssue(token, "weak hmac secret") {
		t.Errorf("expected weak hmac secret issue, got %+v", token.Issues)
	}
	if !hasIssue(token, "rs256->hs256 confusion surface") {
		t.Errorf("expected hmac confusion surface to be flagged, got %+v", token.Issues)
	}
}

func TestJWT_WeakSecretCrackedHS384HS512(t *testing.T) {
	cases := []struct {
		name  string
		token string
	}{
		{"HS384", jwtWeakHS384},
		{"HS512", jwtWeakHS512},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
				http.SetCookie(w, &http.Cookie{Name: "session", Value: tc.token})
				w.WriteHeader(http.StatusOK)
			}))
			defer srv.Close()

			result, err := JWT(srv.URL, 5*time.Second, "")
			if err != nil {
				t.Fatalf("JWT: %v", err)
			}
			if result == nil || len(result.Tokens) != 1 {
				t.Fatalf("expected one token, got %+v", result)
			}

			token := &result.Tokens[0]
			if token.WeakKey != "secret" {
				t.Errorf("expected weak secret 'secret' cracked on %s, got %q", tc.name, token.WeakKey)
			}
			if !hasIssue(token, "weak hmac secret") {
				t.Errorf("expected weak hmac secret issue on %s, got %+v", tc.name, token.Issues)
			}
		})
	}
}

func TestJWT_StrongSecretNotCrackedHS384HS512(t *testing.T) {
	cases := []struct {
		name  string
		token string
	}{
		{"HS384", jwtStrongHS384},
		{"HS512", jwtStrongHS512},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
				http.SetCookie(w, &http.Cookie{Name: "session", Value: tc.token})
				w.WriteHeader(http.StatusOK)
			}))
			defer srv.Close()

			result, err := JWT(srv.URL, 5*time.Second, "")
			if err != nil {
				t.Fatalf("JWT: %v", err)
			}
			if result == nil || len(result.Tokens) != 1 {
				t.Fatalf("expected one token, got %+v", result)
			}

			token := &result.Tokens[0]
			if token.WeakKey != "" {
				t.Errorf("strong-secret %s token must not be cracked, got %q", tc.name, token.WeakKey)
			}
			if hasIssue(token, "weak hmac secret") {
				t.Errorf("strong-secret %s token must not raise a weak-secret issue, got %+v", tc.name, token.Issues)
			}
		})
	}
}

func TestHMACHash(t *testing.T) {
	cases := []struct {
		alg      string
		wantOK   bool
		wantSize int // digest bytes when ok
	}{
		{"HS256", true, 32},
		{"HS384", true, 48},
		{"HS512", true, 64},
		{"hs256", true, 32}, // alg match is case-insensitive
		{"", false, 0},
		{"none", false, 0},
		{"RS256", false, 0},
		{"ES256", false, 0},
		{"HS1", false, 0},
		{"HS", false, 0},
	}
	for _, tc := range cases {
		newHash, ok := hmacHash(tc.alg)
		if ok != tc.wantOK {
			t.Errorf("hmacHash(%q) ok = %v, want %v", tc.alg, ok, tc.wantOK)
			continue
		}
		if ok && newHash().Size() != tc.wantSize {
			t.Errorf("hmacHash(%q) digest size = %d, want %d", tc.alg, newHash().Size(), tc.wantSize)
		}
	}
}

func TestCrackHMAC_RejectsMalformedAndNonHMAC(t *testing.T) {
	cases := []struct {
		name string
		raw  string
		alg  string
	}{
		{"non-hmac alg", jwtWeakHS384, "RS256"},
		{"unknown hs alg", jwtWeakHS384, "HS1"},
		{"too few parts", "only.two", "HS256"},
		{"non-base64 signature", "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ4In0.!!!notb64!!!", "HS256"},
	}
	for _, tc := range cases {
		if secret, ok := crackHMAC(tc.raw, tc.alg); ok || secret != "" {
			t.Errorf("%s: crackHMAC = (%q, %v), want (\"\", false)", tc.name, secret, ok)
		}
	}
}

func TestJWT_ExpiredFlagged(t *testing.T) {
	// token in the response body.
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		_, _ = w.Write([]byte(`{"token":"` + jwtExpired + `"}`))
	}))
	defer srv.Close()

	result, err := JWT(srv.URL, 5*time.Second, "")
	if err != nil {
		t.Fatalf("JWT: %v", err)
	}
	if result == nil || len(result.Tokens) != 1 {
		t.Fatalf("expected one token, got %+v", result)
	}
	if !hasIssue(&result.Tokens[0], "expired token") {
		t.Errorf("expected expired token to be flagged, got %+v", result.Tokens[0].Issues)
	}
	// a strong, unguessed secret must not be cracked.
	if result.Tokens[0].WeakKey != "" {
		t.Errorf("did not expect a cracked key on the strong-secret token, got %q", result.Tokens[0].WeakKey)
	}
}

func TestJWT_SensitiveClaimFlagged(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		_, _ = w.Write([]byte(jwtSensitive))
	}))
	defer srv.Close()

	result, err := JWT(srv.URL, 5*time.Second, "")
	if err != nil {
		t.Fatalf("JWT: %v", err)
	}
	if result == nil || len(result.Tokens) != 1 {
		t.Fatalf("expected one token, got %+v", result)
	}
	if !hasIssue(&result.Tokens[0], "sensitive plaintext claim") {
		t.Errorf("expected sensitive claim to be flagged, got %+v", result.Tokens[0].Issues)
	}
}

func TestNumericClaim_AcceptsStringEncodedExp(t *testing.T) {
	// some jwt stacks (older php/java libs) serialize NumericDate as a quoted
	// string instead of a json number. a stringly-typed exp must still count
	// as present, or a genuinely expired token reads as "missing exp" instead.
	cases := []struct {
		name    string
		payload map[string]any
		wantF   float64
		wantOK  bool
	}{
		{"string past exp", map[string]any{"exp": "1000000000"}, 1000000000, true},
		{"string future exp", map[string]any{"exp": "9999999999"}, 9999999999, true},
		{"float exp still works", map[string]any{"exp": float64(1000000000)}, 1000000000, true},
		{"garbage string exp", map[string]any{"exp": "not-a-number"}, 0, false},
		{"missing exp", map[string]any{}, 0, false},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			f, ok := numericClaim(tc.payload, "exp")
			if ok != tc.wantOK || f != tc.wantF {
				t.Errorf("numericClaim(%+v) = (%v, %v), want (%v, %v)", tc.payload, f, ok, tc.wantF, tc.wantOK)
			}
		})
	}
}

func TestJWT_StringExpStillDetectsExpiry(t *testing.T) {
	// header {alg:none}, payload {sub:x, exp:"1000000000"} (string-typed, long past).
	// exp is a valid number, just quoted, so this must flag "expired token" and
	// must NOT also claim the expiry is missing.
	jwtStringExpPast := "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0." +
		"eyJzdWIiOiJ4IiwiZXhwIjoiMTAwMDAwMDAwMCJ9."

	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		_, _ = w.Write([]byte(jwtStringExpPast))
	}))
	defer srv.Close()

	result, err := JWT(srv.URL, 5*time.Second, "")
	if err != nil {
		t.Fatalf("JWT: %v", err)
	}
	if result == nil || len(result.Tokens) != 1 {
		t.Fatalf("expected one token, got %+v", result)
	}

	token := &result.Tokens[0]
	if !hasIssue(token, "expired token") {
		t.Errorf("expected string-encoded past exp to be flagged expired, got %+v", token.Issues)
	}
	if hasIssue(token, "missing exp") {
		t.Errorf("string-encoded exp must not be reported as missing, got %+v", token.Issues)
	}
}

func TestJWT_NoTokens(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		_, _ = w.Write([]byte("nothing to see here"))
	}))
	defer srv.Close()

	result, err := JWT(srv.URL, 5*time.Second, "")
	if err != nil {
		t.Fatalf("JWT: %v", err)
	}
	if result != nil {
		t.Errorf("expected nil result when no tokens present, got %+v", result)
	}
}

func TestJWTResult_ResultType(t *testing.T) {
	r := &JWTResult{}
	if r.ResultType() != "jwt" {
		t.Errorf("expected result type 'jwt', got %q", r.ResultType())
	}
}
