The frontend's cross-tab coordination (auth.svelte.ts REFRESH_LOCK_TTL_MS=15s + 20s wait-for-timeout) guarantees only ONE tab rotates and every sibling adopts the rotated pair, so the only legitimately-arriving replays are same-tick races (sub-second). The old 60s window handed a stolen refresh token a full minute of freshness before reuse detection fired; 20s keeps comfortable margin over the coordination bound while cutting the undetected-theft window to a third. The ideal fix (kill only when the replay's IP/UA differs) still needs rotation-origin persistence the locked schema cannot express.
985 lines
34 KiB
Go
985 lines
34 KiB
Go
//go:build test
|
|
|
|
package auth
|
|
|
|
// Package auth contains tests for JWT generation, verification, and JTI revocation.
|
|
//
|
|
// Test Coverage:
|
|
// - generateJTI: UUID v4 format validation, uniqueness
|
|
// - GenerateToken: returns non-empty token and JTI, JTI matches claim
|
|
// - VerifyToken: returns correct user_id/role/JTI, rejects revoked/missing JTI
|
|
// - RevokeJTI: adds to revoked set, IsJTIRevoked reflects changes
|
|
// - CleanupRevokedJTIs: removes expired JTIs, keeps valid ones
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"crussell/clock"
|
|
"crussell/db"
|
|
"crussell/testutils/fixtures"
|
|
"crussell/testutils/testtx"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// =============================================================================
|
|
// generateJTI Tests
|
|
// =============================================================================
|
|
|
|
// TestGenerateJTI_Format verifies that generateJTI returns a valid UUID v4 string
|
|
// in the format: 8-4-4-4-12 hexadecimal digits with version and variant bits set.
|
|
func TestGenerateJTI_Format(t *testing.T) {
|
|
jti, err := generateJTI()
|
|
if err != nil {
|
|
t.Fatalf("generateJTI() failed: %v", err)
|
|
}
|
|
|
|
// UUID v4 format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
|
|
parts := strings.Split(jti, "-")
|
|
if len(parts) != 5 {
|
|
t.Errorf("expected 5 parts in UUID, got %d: %s", len(parts), jti)
|
|
}
|
|
|
|
// Check each part length: 8-4-4-4-12
|
|
expectedLengths := []int{8, 4, 4, 4, 12}
|
|
for i, part := range parts {
|
|
if len(part) != expectedLengths[i] {
|
|
t.Errorf("part %d expected length %d, got %d (jti=%s)", i, expectedLengths[i], len(part), jti)
|
|
}
|
|
}
|
|
|
|
// Check version nibble (4xxx) in the third group
|
|
if len(parts[2]) > 0 && parts[2][0] != '4' {
|
|
t.Errorf("expected version 4 UUID, got version %c in jti=%s", parts[2][0], jti)
|
|
}
|
|
|
|
// Check variant bits (8xxx, 9xxx, axxx, or bxxx) in the fourth group
|
|
if len(parts[3]) > 0 {
|
|
c := parts[3][0]
|
|
if c != '8' && c != '9' && c != 'a' && c != 'b' {
|
|
t.Errorf("expected variant bits 10xx, got %c in jti=%s", c, jti)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestGenerateJTI_Unique generates 100 JTIs and verifies all are unique.
|
|
func TestGenerateJTI_Unique(t *testing.T) {
|
|
seen := make(map[string]bool)
|
|
for i := 0; i < 100; i++ {
|
|
jti, err := generateJTI()
|
|
if err != nil {
|
|
t.Fatalf("generateJTI() failed at iteration %d: %v", i, err)
|
|
}
|
|
if seen[jti] {
|
|
t.Errorf("duplicate JTI generated at iteration %d: %s", i, jti)
|
|
}
|
|
seen[jti] = true
|
|
}
|
|
|
|
if len(seen) != 100 {
|
|
t.Errorf("expected 100 unique JTIs, got %d", len(seen))
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// GenerateToken Tests
|
|
// =============================================================================
|
|
|
|
// TestGenerateToken_ReturnsJTI verifies that GenerateToken returns both a
|
|
// non-empty token string and a non-empty JTI string.
|
|
func TestGenerateToken_ReturnsJTI(t *testing.T) {
|
|
token, jti, err := GenerateToken("user-001", "verified_email")
|
|
if err != nil {
|
|
t.Fatalf("GenerateToken() failed: %v", err)
|
|
}
|
|
if token == "" {
|
|
t.Error("expected non-empty token")
|
|
}
|
|
if jti == "" {
|
|
t.Error("expected non-empty JTI")
|
|
}
|
|
}
|
|
|
|
// TestGenerateToken_JTIInClaims decodes the generated token and verifies
|
|
// the "jti" claim matches the returned JTI value.
|
|
func TestGenerateToken_JTIInClaims(t *testing.T) {
|
|
token, jti, err := GenerateToken("user-002", "admin")
|
|
if err != nil {
|
|
t.Fatalf("GenerateToken() failed: %v", err)
|
|
}
|
|
|
|
decoded, err := TokenAuth.Decode(token)
|
|
if err != nil {
|
|
t.Fatalf("failed to decode token: %v", err)
|
|
}
|
|
|
|
var claimJTI string
|
|
if err := decoded.Get("jti", &claimJTI); err != nil {
|
|
t.Fatalf("failed to get jti claim: %v", err)
|
|
}
|
|
|
|
if claimJTI != jti {
|
|
t.Errorf("expected jti claim %q, got %q", jti, claimJTI)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// VerifyToken Tests
|
|
// =============================================================================
|
|
|
|
// TestVerifyToken_ReturnsJTI creates a token, verifies it, and checks the
|
|
// returned user_id, role, and JTI match the expected values.
|
|
func TestVerifyToken_ReturnsJTI(t *testing.T) {
|
|
ctx, _ := testtx.SetupTestTx(t)
|
|
token, jti, err := GenerateToken("user-003", "verified_email")
|
|
if err != nil {
|
|
t.Fatalf("GenerateToken() failed: %v", err)
|
|
}
|
|
|
|
userID, role, returnedJTI, err := VerifyToken(token, ctx)
|
|
if err != nil {
|
|
t.Fatalf("VerifyToken() failed: %v", err)
|
|
}
|
|
|
|
if userID != "user-003" {
|
|
t.Errorf("expected userID 'user-003', got %q", userID)
|
|
}
|
|
if role != "verified_email" {
|
|
t.Errorf("expected role 'verified_email', got %q", role)
|
|
}
|
|
if returnedJTI != jti {
|
|
t.Errorf("expected JTI %q, got %q", jti, returnedJTI)
|
|
}
|
|
}
|
|
|
|
// TestVerifyToken_RevokedJTI creates a token, revokes its JTI, and verifies
|
|
// that VerifyToken returns an error containing "token revoked".
|
|
func TestVerifyToken_RevokedJTI(t *testing.T) {
|
|
ctx, _ := testtx.SetupTestTx(t)
|
|
token, jti, err := GenerateToken("user-004", "verified_email")
|
|
if err != nil {
|
|
t.Fatalf("GenerateToken() failed: %v", err)
|
|
}
|
|
|
|
if err := RevokeJTI(ctx, jti, clock.Now().Add(30*24*time.Hour)); err != nil {
|
|
t.Fatalf("RevokeJTI() failed: %v", err)
|
|
}
|
|
|
|
_, _, _, err = VerifyToken(token, ctx)
|
|
if err == nil {
|
|
t.Fatal("expected error for revoked JTI, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "token revoked") {
|
|
t.Errorf("expected 'token revoked' error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestVerifyToken_ExpiredToken creates a token whose "exp" claim is in the past
|
|
// and verifies that VerifyToken rejects it with an error containing "expired".
|
|
func TestVerifyToken_ExpiredToken(t *testing.T) {
|
|
_, tokenString, err := TokenAuth.Encode(map[string]interface{}{
|
|
"user_id": "user-expired",
|
|
"role": "verified_email",
|
|
"jti": "test-jti-expired",
|
|
"exp": clock.Now().Add(-1 * time.Hour).Unix(),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
_, _, _, err = VerifyToken(tokenString, context.Background())
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "expired")
|
|
}
|
|
|
|
// TestVerifyToken_MissingJTI creates a token without a "jti" claim (using
|
|
// TokenAuth.Encode directly) and verifies that VerifyToken returns an error
|
|
// containing "invalid jti claim".
|
|
func TestVerifyToken_MissingJTI(t *testing.T) {
|
|
_, tokenString, err := TokenAuth.Encode(map[string]interface{}{
|
|
"user_id": "user-005",
|
|
"role": "verified_email",
|
|
"exp": clock.Now().Add(30 * 24 * time.Hour).Unix(),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("failed to create token without JTI: %v", err)
|
|
}
|
|
|
|
_, _, _, err = VerifyToken(tokenString, context.Background())
|
|
if err == nil {
|
|
t.Fatal("expected error for missing JTI, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "invalid jti claim") {
|
|
t.Errorf("expected 'invalid jti claim' error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestIsJTIRevoked_QueryError verifies that IsJTIRevoked returns false when
|
|
// the context is cancelled (causing the QueryRow to fail).
|
|
func TestIsJTIRevoked_QueryError(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
if IsJTIRevoked(ctx, "test-jti") {
|
|
t.Error("expected false on cancelled context query error")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// RevokeJTI / IsJTIRevoked Tests
|
|
// =============================================================================
|
|
|
|
// TestRevokeJTI_AddsToSet verifies that calling RevokeJTI adds the JTI to the
|
|
// revoked set, and IsJTIRevoked returns true for it.
|
|
func TestRevokeJTI_AddsToSet(t *testing.T) {
|
|
ctx, _ := testtx.SetupTestTx(t)
|
|
_, jti, err := GenerateToken("user-006", "verified_email")
|
|
if err != nil {
|
|
t.Fatalf("GenerateToken() failed: %v", err)
|
|
}
|
|
|
|
if IsJTIRevoked(ctx, jti) {
|
|
t.Fatal("JTI should not be revoked before calling RevokeJTI")
|
|
}
|
|
|
|
if err := RevokeJTI(ctx, jti, clock.Now().Add(30*24*time.Hour)); err != nil {
|
|
t.Fatalf("RevokeJTI() failed: %v", err)
|
|
}
|
|
|
|
if !IsJTIRevoked(ctx, jti) {
|
|
t.Error("expected IsJTIRevoked to return true after RevokeJTI")
|
|
}
|
|
}
|
|
|
|
// TestIsJTIRevoked_NonExistent verifies that checking a non-existent JTI
|
|
// returns false.
|
|
func TestIsJTIRevoked_NonExistent(t *testing.T) {
|
|
ctx, _ := testtx.SetupTestTx(t)
|
|
if IsJTIRevoked(ctx, "nonexistent-jti-12345") {
|
|
t.Error("expected IsJTIRevoked to return false for non-existent JTI")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// CleanupRevokedJTIs Tests
|
|
// =============================================================================
|
|
|
|
// TestCleanupRevokedJTIs_RemovesExpired adds a JTI with a past expiry time,
|
|
// runs CleanupRevokedJTIs, and verifies the JTI is removed from the set.
|
|
func TestCleanupRevokedJTIs_RemovesExpired(t *testing.T) {
|
|
ctx, tx := testtx.SetupTestTx(t)
|
|
_, jti, err := GenerateToken("user-007", "verified_email")
|
|
if err != nil {
|
|
t.Fatalf("GenerateToken() failed: %v", err)
|
|
}
|
|
|
|
// Add with future expiry so IsJTIRevoked sees it
|
|
if err := RevokeJTI(ctx, jti, clock.Now().Add(1*time.Hour)); err != nil {
|
|
t.Fatalf("RevokeJTI() failed: %v", err)
|
|
}
|
|
|
|
if !IsJTIRevoked(ctx, jti) {
|
|
t.Fatal("JTI should be in revoked set after RevokeJTI")
|
|
}
|
|
|
|
// Directly update the DB to set expiry in the past
|
|
_, err = tx.Exec(ctx,
|
|
"UPDATE revoked_jtis SET expires_at = NOW() - INTERVAL '1 hour' WHERE jti = $1", jti)
|
|
if err != nil {
|
|
t.Fatalf("failed to expire JTI: %v", err)
|
|
}
|
|
|
|
CleanupRevokedJTIs(ctx)
|
|
|
|
if IsJTIRevoked(ctx, jti) {
|
|
t.Error("expected expired JTI to be removed after cleanup")
|
|
}
|
|
}
|
|
|
|
// TestCleanupRevokedJTIs_KeepsValid adds a JTI with a future expiry time,
|
|
// runs CleanupRevokedJTIs, and verifies the JTI is still in the set.
|
|
func TestCleanupRevokedJTIs_KeepsValid(t *testing.T) {
|
|
ctx, _ := testtx.SetupTestTx(t)
|
|
_, jti, err := GenerateToken("user-008", "verified_email")
|
|
if err != nil {
|
|
t.Fatalf("GenerateToken() failed: %v", err)
|
|
}
|
|
|
|
// Add with future expiry
|
|
if err := RevokeJTI(ctx, jti, clock.Now().Add(30*24*time.Hour)); err != nil {
|
|
t.Fatalf("RevokeJTI() failed: %v", err)
|
|
}
|
|
|
|
if !IsJTIRevoked(ctx, jti) {
|
|
t.Fatal("JTI should be in revoked set before cleanup")
|
|
}
|
|
|
|
CleanupRevokedJTIs(ctx)
|
|
|
|
if !IsJTIRevoked(ctx, jti) {
|
|
t.Error("expected valid (future expiry) JTI to remain after cleanup")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// generateRefreshTokenString Tests
|
|
// =============================================================================
|
|
|
|
// TestGenerateRefreshTokenString_Format verifies that generateRefreshTokenString
|
|
// returns a 64-character hex string.
|
|
func TestGenerateRefreshTokenString_Format(t *testing.T) {
|
|
token, err := generateRefreshTokenString()
|
|
if err != nil {
|
|
t.Fatalf("generateRefreshTokenString() failed: %v", err)
|
|
}
|
|
if len(token) != 64 {
|
|
t.Errorf("expected 64-character hex string, got length %d: %s", len(token), token)
|
|
}
|
|
// Verify all characters are valid lowercase hex
|
|
for _, c := range token {
|
|
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
|
|
t.Errorf("non-hex character %c in token %s", c, token)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestGenerateRefreshTokenString_Unique generates 100 refresh token strings
|
|
// and verifies all are unique.
|
|
func TestGenerateRefreshTokenString_Unique(t *testing.T) {
|
|
seen := make(map[string]bool)
|
|
for i := 0; i < 100; i++ {
|
|
token, err := generateRefreshTokenString()
|
|
if err != nil {
|
|
t.Fatalf("generateRefreshTokenString() failed at iteration %d: %v", i, err)
|
|
}
|
|
if seen[token] {
|
|
t.Errorf("duplicate refresh token at iteration %d: %s", i, token)
|
|
}
|
|
seen[token] = true
|
|
}
|
|
if len(seen) != 100 {
|
|
t.Errorf("expected 100 unique tokens, got %d", len(seen))
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// GenerateRefreshToken Tests
|
|
// =============================================================================
|
|
|
|
// TestGenerateRefreshToken_Success calls GenerateRefreshToken and verifies a
|
|
// row was inserted in the refresh_tokens table with the correct user_id and role.
|
|
func TestGenerateRefreshToken_Success(t *testing.T) {
|
|
ctx, tx := testtx.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
|
|
token, _, err := GenerateRefreshToken(ctx, userID, "verified_email")
|
|
if err != nil {
|
|
t.Fatalf("GenerateRefreshToken() failed: %v", err)
|
|
}
|
|
if token == "" {
|
|
t.Error("expected non-empty token")
|
|
}
|
|
|
|
// Verify row was inserted in refresh_tokens
|
|
var dbUserID string
|
|
var dbRole string
|
|
err = tx.QueryRow(ctx,
|
|
`SELECT user_id, role FROM refresh_tokens WHERE token_hash = encode(sha256($1::bytea), 'hex')`,
|
|
token).Scan(&dbUserID, &dbRole)
|
|
if err != nil {
|
|
t.Fatalf("failed to query refresh_tokens: %v", err)
|
|
}
|
|
if dbUserID != userID {
|
|
t.Errorf("expected user_id %q, got %q", userID, dbUserID)
|
|
}
|
|
if dbRole != "verified_email" {
|
|
t.Errorf("expected role 'verified_email', got %q", dbRole)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// VerifyRefreshToken Tests
|
|
// =============================================================================
|
|
|
|
// TestVerifyRefreshToken_Success generates a refresh token, verifies it, and
|
|
// asserts the returned userID and role match. Then confirms the token was
|
|
// consumed (second call fails with "invalid or expired").
|
|
func TestVerifyRefreshToken_Success(t *testing.T) {
|
|
ctx, tx := testtx.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
|
|
token, _, err := GenerateRefreshToken(ctx, userID, "verified_email")
|
|
if err != nil {
|
|
t.Fatalf("GenerateRefreshToken() failed: %v", err)
|
|
}
|
|
|
|
// First verify should succeed
|
|
retUserID, retRole, _, err := VerifyRefreshToken(ctx, token)
|
|
if err != nil {
|
|
t.Fatalf("VerifyRefreshToken() failed: %v", err)
|
|
}
|
|
if retUserID != userID {
|
|
t.Errorf("expected user_id %q, got %q", userID, retUserID)
|
|
}
|
|
if retRole != "verified_email" {
|
|
t.Errorf("expected role 'verified_email', got %q", retRole)
|
|
}
|
|
|
|
// Second verify with same token must fail (rotation — token consumed)
|
|
_, _, _, err = VerifyRefreshToken(ctx, token)
|
|
if err == nil {
|
|
t.Fatal("expected error for consumed token, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "invalid or expired") {
|
|
t.Errorf("expected 'invalid or expired' error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestVerifyRefreshToken_Rotation verifies the token rotation mechanism:
|
|
// first call succeeds, second call with the same token fails.
|
|
func TestVerifyRefreshToken_Rotation(t *testing.T) {
|
|
ctx, tx := testtx.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
|
|
token, _, err := GenerateRefreshToken(ctx, userID, "verified_email")
|
|
if err != nil {
|
|
t.Fatalf("GenerateRefreshToken() failed: %v", err)
|
|
}
|
|
|
|
// First call should succeed
|
|
_, _, _, err = VerifyRefreshToken(ctx, token)
|
|
if err != nil {
|
|
t.Fatalf("first verification should succeed, got: %v", err)
|
|
}
|
|
|
|
// Second call with the same token must fail
|
|
_, _, _, err = VerifyRefreshToken(ctx, token)
|
|
if err == nil {
|
|
t.Fatal("expected error for rotated token, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "invalid or expired") {
|
|
t.Errorf("expected 'invalid or expired' error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestVerifyRefreshToken_ReuseRevokesFamilyAndAlerts verifies the reuse
|
|
// detection: generate a token → rotate it once (minting a descendant in the
|
|
// SAME family via GenerateRefreshTokenInFamily) → present the ORIGINAL token
|
|
// again. The replay must (i) delete the ENTIRE rotation family (the descendant
|
|
// included) from refresh_tokens and (ii) insert an admin_notifications row with
|
|
// reason 'refresh_token_reuse' for the user.
|
|
func TestVerifyRefreshToken_ReuseRevokesFamilyAndAlerts(t *testing.T) {
|
|
ctx, tx := testtx.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
|
|
// 1. Generate a refresh token (new family) and rotate it once.
|
|
original, _, err := GenerateRefreshToken(ctx, userID, "verified_email")
|
|
if err != nil {
|
|
t.Fatalf("GenerateRefreshToken() failed: %v", err)
|
|
}
|
|
|
|
_, _, familyID, err := VerifyRefreshToken(ctx, original)
|
|
if err != nil {
|
|
t.Fatalf("first verification should succeed, got: %v", err)
|
|
}
|
|
if familyID == "" {
|
|
t.Fatal("expected non-empty family_id from rotation")
|
|
}
|
|
|
|
// 2. Mint the descendant in the SAME family (as RefreshTokenHandler does).
|
|
descendant, err := GenerateRefreshTokenInFamily(ctx, userID, "verified_email", familyID)
|
|
if err != nil {
|
|
t.Fatalf("GenerateRefreshTokenInFamily() failed: %v", err)
|
|
}
|
|
|
|
var famCount int
|
|
if err := tx.QueryRow(ctx,
|
|
`SELECT COUNT(*) FROM refresh_tokens WHERE family_id = $1`, familyID).Scan(&famCount); err != nil {
|
|
t.Fatalf("failed to count family rows: %v", err)
|
|
}
|
|
if famCount != 2 {
|
|
t.Fatalf("expected 2 refresh tokens in family, got %d", famCount)
|
|
}
|
|
|
|
// Reuse grace window: backdate the original's used_at past the reuse grace
|
|
// so the replay below is genuine theft. WITHOUT this, a replay
|
|
// moments after rotation is a benign two-tab concurrent refresh and the
|
|
// family must NOT be killed.
|
|
if _, err := tx.Exec(ctx, `
|
|
UPDATE refresh_tokens
|
|
SET used_at = NOW() - make_interval(secs => 120)
|
|
WHERE token_hash = encode(sha256($1::bytea), 'hex')
|
|
`, original); err != nil {
|
|
t.Fatalf("failed to backdate used_at for reuse test: %v", err)
|
|
}
|
|
|
|
// 3. Replay the ORIGINAL token — theft.
|
|
_, _, _, err = VerifyRefreshToken(ctx, original)
|
|
if err == nil {
|
|
t.Fatal("expected error for replayed token, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "invalid or expired") {
|
|
t.Errorf("expected 'invalid or expired' error, got: %v", err)
|
|
}
|
|
|
|
// (i) The entire family is revoked: the used original AND the descendant.
|
|
var famAfter int
|
|
if err := tx.QueryRow(ctx,
|
|
`SELECT COUNT(*) FROM refresh_tokens WHERE family_id = $1`, familyID).Scan(&famAfter); err != nil {
|
|
t.Fatalf("failed to count family rows after replay: %v", err)
|
|
}
|
|
if famAfter != 0 {
|
|
t.Errorf("expected 0 refresh tokens in family after reuse (descendant killed), got %d", famAfter)
|
|
}
|
|
|
|
var descHashCount int
|
|
if err := tx.QueryRow(ctx,
|
|
`SELECT COUNT(*) FROM refresh_tokens WHERE token_hash = encode(sha256($1::bytea), 'hex')`,
|
|
descendant).Scan(&descHashCount); err != nil {
|
|
t.Fatalf("failed to check descendant: %v", err)
|
|
}
|
|
if descHashCount != 0 {
|
|
t.Errorf("expected descendant to be deleted, got %d rows", descHashCount)
|
|
}
|
|
|
|
// (ii) An admin alert with reason 'refresh_token_reuse' exists for the user.
|
|
var alertCount int
|
|
if err := tx.QueryRow(ctx,
|
|
`SELECT COUNT(*) FROM admin_notifications WHERE reason = 'refresh_token_reuse' AND user_id = $1`,
|
|
userID).Scan(&alertCount); err != nil {
|
|
t.Fatalf("failed to query admin_notifications: %v", err)
|
|
}
|
|
if alertCount != 1 {
|
|
t.Errorf("expected 1 'refresh_token_reuse' alert, got %d", alertCount)
|
|
}
|
|
}
|
|
|
|
// TestVerifyRefreshToken_ReplayWithinGrace_IsBenign verifies the reuse
|
|
// hardening: a used-token replay WITHIN the grace window (two tabs sharing
|
|
// one localStorage refresh token both refreshing on load) is a benign
|
|
// concurrent refresh — the generic error is returned, but the rotation family
|
|
// survives and no refresh_token_reuse alert is raised.
|
|
func TestVerifyRefreshToken_ReplayWithinGrace_IsBenign(t *testing.T) {
|
|
ctx, tx := testtx.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
|
|
original, _, err := GenerateRefreshToken(ctx, userID, "verified_email")
|
|
if err != nil {
|
|
t.Fatalf("GenerateRefreshToken() failed: %v", err)
|
|
}
|
|
|
|
_, _, familyID, err := VerifyRefreshToken(ctx, original)
|
|
if err != nil {
|
|
t.Fatalf("first verification should succeed, got: %v", err)
|
|
}
|
|
if familyID == "" {
|
|
t.Fatal("expected non-empty family_id from rotation")
|
|
}
|
|
|
|
// Mint the descendant in the same family (as RefreshTokenHandler does).
|
|
if _, err := GenerateRefreshTokenInFamily(ctx, userID, "verified_email", familyID); err != nil {
|
|
t.Fatalf("GenerateRefreshTokenInFamily() failed: %v", err)
|
|
}
|
|
|
|
// Replay the original IMMEDIATELY — inside the grace window → benign.
|
|
_, _, _, err = VerifyRefreshToken(ctx, original)
|
|
if err == nil {
|
|
t.Fatal("expected error for within-grace replay, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "invalid or expired") {
|
|
t.Errorf("expected 'invalid or expired' error, got: %v", err)
|
|
}
|
|
|
|
// The family survives: used original + descendant both still present.
|
|
var famCount int
|
|
if err := tx.QueryRow(ctx,
|
|
`SELECT COUNT(*) FROM refresh_tokens WHERE family_id = $1`, familyID).Scan(&famCount); err != nil {
|
|
t.Fatalf("failed to count family rows: %v", err)
|
|
}
|
|
if famCount != 2 {
|
|
t.Errorf("expected 2 refresh tokens in family after within-grace replay, got %d", famCount)
|
|
}
|
|
|
|
// No theft alert.
|
|
var alertCount int
|
|
if err := tx.QueryRow(ctx,
|
|
`SELECT COUNT(*) FROM admin_notifications WHERE reason = 'refresh_token_reuse' AND user_id = $1`,
|
|
userID).Scan(&alertCount); err != nil {
|
|
t.Fatalf("failed to query admin_notifications: %v", err)
|
|
}
|
|
if alertCount != 0 {
|
|
t.Errorf("expected 0 'refresh_token_reuse' alerts for a within-grace replay, got %d", alertCount)
|
|
}
|
|
}
|
|
|
|
// TestVerifyRefreshToken_GraceBoundary pins the reuse-grace boundary (LOW-2,
|
|
// reduced 60s → 20s): a used-token replay just INSIDE the grace window is a
|
|
// benign concurrent refresh — the family survives — while a replay just past
|
|
// the window is genuine theft and revokes the ENTIRE rotation family. The
|
|
// exact boundary is refreshTokenReuseGraceSecs (derived from
|
|
// refreshTokenReuseGrace), so this test holds the reduced value honest.
|
|
func TestVerifyRefreshToken_GraceBoundary(t *testing.T) {
|
|
ctx, tx := testtx.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
require.NoError(t, err)
|
|
|
|
original, _, err := GenerateRefreshToken(ctx, userID, "verified_email")
|
|
require.NoError(t, err)
|
|
_, _, familyID, err := VerifyRefreshToken(ctx, original)
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, familyID)
|
|
_, err = GenerateRefreshTokenInFamily(ctx, userID, "verified_email", familyID)
|
|
require.NoError(t, err)
|
|
|
|
backdate := func(secs int64) {
|
|
t.Helper()
|
|
_, err = tx.Exec(ctx, `
|
|
UPDATE refresh_tokens
|
|
SET used_at = NOW() - make_interval(secs => $2)
|
|
WHERE token_hash = encode(sha256($1::bytea), 'hex')
|
|
`, original, secs)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// Replay just INSIDE the grace window (grace - 1s) → benign: the family
|
|
// (used original + descendant) survives and no theft alert is raised.
|
|
backdate(refreshTokenReuseGraceSecs - 1)
|
|
_, _, _, err = VerifyRefreshToken(ctx, original)
|
|
require.Error(t, err, "a within-grace replay must still return the generic error")
|
|
require.Contains(t, err.Error(), "invalid or expired")
|
|
var famCount int
|
|
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM refresh_tokens WHERE family_id = $1`, familyID).Scan(&famCount))
|
|
require.Equal(t, 2, famCount, "a within-grace replay must NOT kill the rotation family")
|
|
|
|
// Replay just OUTSIDE the grace window (grace + 1s) → theft: the ENTIRE
|
|
// family is revoked (descendant included) and a critical alert is raised.
|
|
backdate(refreshTokenReuseGraceSecs + 1)
|
|
_, _, _, err = VerifyRefreshToken(ctx, original)
|
|
require.Error(t, err, "a post-grace replay must return the generic error")
|
|
require.Contains(t, err.Error(), "invalid or expired")
|
|
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM refresh_tokens WHERE family_id = $1`, familyID).Scan(&famCount))
|
|
require.Equal(t, 0, famCount, "a post-grace replay must revoke the ENTIRE rotation family")
|
|
}
|
|
|
|
// TestAccessTokenKilledWithRotationFamily verifies the HIGH 1 fix: an access
|
|
// token minted at rotation is bound (family_id claim) to the rotation family,
|
|
// so when reuse detection DELETEs the family the access token — which the
|
|
// attacker was handed at rotation — stops verifying immediately instead of
|
|
// staying valid for its 1-hour TTL.
|
|
func TestAccessTokenKilledWithRotationFamily(t *testing.T) {
|
|
ctx, tx := testtx.SetupTestTx(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test user: %v", err)
|
|
}
|
|
|
|
original, _, err := GenerateRefreshToken(ctx, userID, "verified_email")
|
|
if err != nil {
|
|
t.Fatalf("GenerateRefreshToken() failed: %v", err)
|
|
}
|
|
|
|
_, _, familyID, err := VerifyRefreshToken(ctx, original)
|
|
if err != nil {
|
|
t.Fatalf("first verification should succeed, got: %v", err)
|
|
}
|
|
|
|
// The rotation response: a descendant refresh token in the same family and
|
|
// an access token minted in that SAME family (as RefreshTokenHandler does).
|
|
if _, err := GenerateRefreshTokenInFamily(ctx, userID, "verified_email", familyID); err != nil {
|
|
t.Fatalf("GenerateRefreshTokenInFamily() failed: %v", err)
|
|
}
|
|
attackerToken, attackerJTI, err := GenerateTokenForFamily(userID, "verified_email", familyID)
|
|
if err != nil {
|
|
t.Fatalf("GenerateTokenForFamily() failed: %v", err)
|
|
}
|
|
if attackerJTI == "" {
|
|
t.Fatal("expected non-empty JTI")
|
|
}
|
|
|
|
// While its family is alive the access token verifies (JTI not revoked).
|
|
if _, _, _, err := VerifyToken(attackerToken, ctx); err != nil {
|
|
t.Fatalf("access token must verify while its rotation family is alive: %v", err)
|
|
}
|
|
|
|
// Backdate used_at past the grace window, then replay the original — theft
|
|
// detected, whole family DELETEd.
|
|
if _, err := tx.Exec(ctx, `
|
|
UPDATE refresh_tokens
|
|
SET used_at = NOW() - make_interval(secs => 120)
|
|
WHERE token_hash = encode(sha256($1::bytea), 'hex')
|
|
`, original); err != nil {
|
|
t.Fatalf("failed to backdate used_at: %v", err)
|
|
}
|
|
_, _, _, err = VerifyRefreshToken(ctx, original)
|
|
if err == nil {
|
|
t.Fatal("expected theft detection on replayed token, got nil")
|
|
}
|
|
|
|
var famCount int
|
|
if err := tx.QueryRow(ctx,
|
|
`SELECT COUNT(*) FROM refresh_tokens WHERE family_id = $1`, familyID).Scan(&famCount); err != nil {
|
|
t.Fatalf("failed to count family rows: %v", err)
|
|
}
|
|
if famCount != 0 {
|
|
t.Fatalf("expected 0 refresh tokens in family after reuse, got %d", famCount)
|
|
}
|
|
|
|
// The attacker's access token is now dead even though its JTI was never
|
|
// revoked — the family-alive check rejects it (HIGH 1).
|
|
if _, _, _, err := VerifyToken(attackerToken, ctx); err == nil {
|
|
t.Fatal("access token must be rejected once its rotation family is killed")
|
|
} else if !strings.Contains(err.Error(), "token revoked") {
|
|
t.Fatalf("expected 'token revoked' error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestVerifyRefreshToken_InvalidToken calls VerifyRefreshToken with a fake
|
|
// token string and expects it to fail with "invalid or expired".
|
|
func TestVerifyRefreshToken_InvalidToken(t *testing.T) {
|
|
ctx, _ := testtx.SetupTestTx(t)
|
|
|
|
_, _, _, err := VerifyRefreshToken(ctx, "this-is-a-completely-fake-token-string")
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid token, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "invalid or expired") {
|
|
t.Errorf("expected 'invalid or expired' error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestVerifyToken_EmptyJTI creates a token with an empty "jti" claim and
|
|
// verifies that VerifyToken returns an error containing "invalid jti claim".
|
|
func TestVerifyToken_EmptyJTI(t *testing.T) {
|
|
_, tokenStr, err := TokenAuth.Encode(map[string]any{
|
|
"user_id": "user-test",
|
|
"role": "verified_email",
|
|
"jti": "",
|
|
"exp": clock.Now().Add(1 * time.Hour).Unix(),
|
|
})
|
|
require.NoError(t, err)
|
|
_, _, _, err = VerifyToken(tokenStr, context.Background())
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "invalid jti claim")
|
|
}
|
|
|
|
// =============================================================================
|
|
// Nil db.Conn Tests
|
|
// =============================================================================
|
|
|
|
// TestRevokeJTI_NilConn verifies that RevokeJTI does not panic when db.Conn is nil.
|
|
func TestRevokeJTI_NilConn(t *testing.T) {
|
|
savedConn := db.Conn
|
|
db.Conn = nil
|
|
t.Cleanup(func() { db.Conn = savedConn })
|
|
|
|
// Should return an error when db.Conn is nil
|
|
if err := RevokeJTI(context.Background(), "test-jti", time.Now()); err == nil {
|
|
t.Error("expected error when db.Conn is nil, got nil")
|
|
}
|
|
}
|
|
|
|
// TestIsJTIRevoked_NilConn verifies that IsJTIRevoked returns false when db.Conn is nil.
|
|
func TestIsJTIRevoked_NilConn(t *testing.T) {
|
|
savedConn := db.Conn
|
|
db.Conn = nil
|
|
t.Cleanup(func() { db.Conn = savedConn })
|
|
|
|
if IsJTIRevoked(context.Background(), "test-jti") {
|
|
t.Error("expected IsJTIRevoked to return false when db.Conn is nil")
|
|
}
|
|
}
|
|
|
|
// TestCleanupRevokedJTIs_NilConn verifies CleanupRevokedJTIs returns (0, nil) when db.Conn is nil.
|
|
func TestCleanupRevokedJTIs_NilConn(t *testing.T) {
|
|
savedConn := db.Conn
|
|
db.Conn = nil
|
|
t.Cleanup(func() { db.Conn = savedConn })
|
|
|
|
n, err := CleanupRevokedJTIs(context.Background())
|
|
if err != nil {
|
|
t.Errorf("expected no error, got: %v", err)
|
|
}
|
|
if n != 0 {
|
|
t.Errorf("expected 0 rows, got %d", n)
|
|
}
|
|
}
|
|
|
|
// Note: crypto error paths in generateJTI() and generateRefreshTokenString()
|
|
// are unreachable on Go 1.26+ because crypto/rand.Read() calls runtime.fatal()
|
|
// instead of returning an error (see https://go.dev/issue/66821). The error
|
|
// return exists for backward compatibility with older Go versions.
|
|
|
|
// =============================================================================
|
|
// VerifyToken Edge Case Tests
|
|
// =============================================================================
|
|
|
|
// TestVerifyToken_MissingUserID creates a token without user_id claim and verifies
|
|
// VerifyToken returns an error containing "invalid user_id claim".
|
|
func TestVerifyToken_MissingUserID(t *testing.T) {
|
|
_, tokenString, err := TokenAuth.Encode(map[string]any{
|
|
"role": "admin",
|
|
"jti": "test-jti-001",
|
|
"exp": clock.Now().Add(1 * time.Hour).Unix(),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("failed to encode token: %v", err)
|
|
}
|
|
|
|
_, _, _, err = VerifyToken(tokenString, context.Background())
|
|
if err == nil {
|
|
t.Fatal("expected error for missing user_id claim, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "invalid user_id claim") {
|
|
t.Errorf("expected 'invalid user_id claim' error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestVerifyToken_WrongUserIDType creates a token with user_id as an integer (wrong type)
|
|
// and verifies VerifyToken returns an error containing "invalid user_id claim".
|
|
func TestVerifyToken_WrongUserIDType(t *testing.T) {
|
|
_, tokenString, err := TokenAuth.Encode(map[string]any{
|
|
"user_id": 12345,
|
|
"role": "admin",
|
|
"jti": "test-jti-002",
|
|
"exp": clock.Now().Add(1 * time.Hour).Unix(),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("failed to encode token: %v", err)
|
|
}
|
|
|
|
_, _, _, err = VerifyToken(tokenString, context.Background())
|
|
if err == nil {
|
|
t.Fatal("expected error for wrong user_id type, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "invalid user_id claim") {
|
|
t.Errorf("expected 'invalid user_id claim' error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestVerifyToken_MissingRole creates a token without role claim and verifies
|
|
// VerifyToken returns an error containing "invalid role claim".
|
|
func TestVerifyToken_MissingRole(t *testing.T) {
|
|
_, tokenString, err := TokenAuth.Encode(map[string]any{
|
|
"user_id": "user-001",
|
|
"jti": "test-jti-003",
|
|
"exp": clock.Now().Add(1 * time.Hour).Unix(),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("failed to encode token: %v", err)
|
|
}
|
|
|
|
_, _, _, err = VerifyToken(tokenString, context.Background())
|
|
if err == nil {
|
|
t.Fatal("expected error for missing role claim, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "invalid role claim") {
|
|
t.Errorf("expected 'invalid role claim' error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestVerifyToken_WrongRoleType creates a token with role as an integer (wrong type)
|
|
// and verifies VerifyToken returns an error containing "invalid role claim".
|
|
func TestVerifyToken_WrongRoleType(t *testing.T) {
|
|
_, tokenString, err := TokenAuth.Encode(map[string]any{
|
|
"user_id": "user-001",
|
|
"role": 12345,
|
|
"jti": "test-jti-004",
|
|
"exp": clock.Now().Add(1 * time.Hour).Unix(),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("failed to encode token: %v", err)
|
|
}
|
|
|
|
_, _, _, err = VerifyToken(tokenString, context.Background())
|
|
if err == nil {
|
|
t.Fatal("expected error for wrong role type, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "invalid role claim") {
|
|
t.Errorf("expected 'invalid role claim' error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestInvalidateFamilyAliveByUser pins LOW 5: dropping the cached family-alive
|
|
// verdicts for a USER (password change / GDPR erasure) removes every family of
|
|
// that user while leaving other users' families untouched.
|
|
func TestInvalidateFamilyAliveByUser(t *testing.T) {
|
|
t.Cleanup(func() {
|
|
familyAliveCache.mu.Lock()
|
|
familyAliveCache.m = make(map[string]familyAliveCacheEntry)
|
|
familyAliveCache.mu.Unlock()
|
|
})
|
|
|
|
familyAliveStore("family-a|user-1", true)
|
|
familyAliveStore("family-b|user-1", true)
|
|
familyAliveStore("family-c|user-2", true)
|
|
|
|
InvalidateFamilyAliveByUser("user-1")
|
|
|
|
_, okA := familyAliveLookup("family-a|user-1")
|
|
_, okB := familyAliveLookup("family-b|user-1")
|
|
_, okC := familyAliveLookup("family-c|user-2")
|
|
require.False(t, okA, "user-1's family-a verdict must be dropped")
|
|
require.False(t, okB, "user-1's family-b verdict must be dropped")
|
|
require.True(t, okC, "user-2's family-c verdict must survive")
|
|
|
|
// An empty user id is a no-op, never a panic.
|
|
InvalidateFamilyAliveByUser("")
|
|
}
|
|
|
|
// TestInvalidateFamilyAliveBatch pins the LOW finding-3 contract: the batch
|
|
// form drops every cached verdict for the affected families (used by the
|
|
// scheduled cleanup when expired refresh tokens kill whole families) while
|
|
// leaving unrelated families untouched. Empty and blank ids are no-ops.
|
|
func TestInvalidateFamilyAliveBatch(t *testing.T) {
|
|
t.Cleanup(func() {
|
|
familyAliveCache.mu.Lock()
|
|
familyAliveCache.m = make(map[string]familyAliveCacheEntry)
|
|
familyAliveCache.mu.Unlock()
|
|
})
|
|
|
|
familyAliveStore("family-a|user-1", true)
|
|
familyAliveStore("family-b|user-1", true)
|
|
familyAliveStore("family-c|user-2", true)
|
|
familyAliveStore("family-a|user-2", true)
|
|
|
|
InvalidateFamilyAliveBatch([]string{"family-a", "family-b"})
|
|
|
|
_, okA1 := familyAliveLookup("family-a|user-1")
|
|
_, okB1 := familyAliveLookup("family-b|user-1")
|
|
_, okA2 := familyAliveLookup("family-a|user-2")
|
|
_, okC2 := familyAliveLookup("family-c|user-2")
|
|
require.False(t, okA1, "family-a's verdict for user-1 must be dropped")
|
|
require.False(t, okB1, "family-b's verdict for user-1 must be dropped")
|
|
require.False(t, okA2, "family-a's verdict for user-2 must be dropped")
|
|
require.True(t, okC2, "family-c's verdict must survive")
|
|
|
|
// Empty, blank and nil inputs are no-ops, never a panic.
|
|
InvalidateFamilyAliveBatch(nil)
|
|
InvalidateFamilyAliveBatch([]string{""})
|
|
InvalidateFamilyAlive("")
|
|
}
|