package logic import ( "context" "errors" "testing" "time" "github.com/golang-jwt/jwt/v5" "spark-server/internal/pkg/errs" ) func testJWT(t *testing.T, clock func() time.Time) *JWT { t.Helper() j, err := NewJWT(&Options{ Secret: "test-secret-test-secret", Issuer: "spark-im", TTL: time.Hour, Clock: clock, }) if err != nil { t.Fatal(err) } return j } const jwtTestAppID uint32 = 1 func TestJWTIssueAuthenticate(t *testing.T) { ctx := context.Background() j := testJWT(t, nil) tok, err := j.Issue(ctx, 42, 7, jwtTestAppID) if err != nil { t.Fatal(err) } if tok.UserID != 42 || tok.DeviceID != 7 || tok.ExpiresIn != 3600 || tok.AccessToken == "" || tok.RefreshToken == "" || tok.RefreshExpiresIn != 2592000 { t.Fatalf("%+v", tok) } p, err := j.Authenticate(ctx, tok.AccessToken) if err != nil { t.Fatal(err) } if p.UserID != 42 || p.DeviceID != 7 || p.AppID != jwtTestAppID || p.JTI == "" { t.Fatalf("%+v", p) } } func TestJWTRejectsBadToken(t *testing.T) { ctx := context.Background() j := testJWT(t, nil) if _, err := j.Authenticate(ctx, ""); !errors.Is(err, errs.ErrUnauthorized) { t.Fatalf("empty: %v", err) } if _, err := j.Authenticate(ctx, "not-a-jwt"); !errors.Is(err, errs.ErrUnauthorized) { t.Fatalf("garbage: %v", err) } other, err := NewJWT(&Options{Secret: "other-secret-other-secret", Issuer: "spark-im"}) if err != nil { t.Fatal(err) } tok, err := other.Issue(ctx, 1, 1, jwtTestAppID) if err != nil { t.Fatal(err) } if _, err := j.Authenticate(ctx, tok.AccessToken); !errors.Is(err, errs.ErrUnauthorized) { t.Fatalf("wrong secret: %v", err) } } func TestJWTRejectsExpiredAndWrongIssuer(t *testing.T) { ctx := context.Background() now := time.Now() clock := func() time.Time { return now } j := testJWT(t, clock) tok, err := j.Issue(ctx, 1, 2, jwtTestAppID) if err != nil { t.Fatal(err) } j.clock = func() time.Time { return now.Add(2 * time.Hour) } if _, err := j.Authenticate(ctx, tok.AccessToken); !errors.Is(err, errs.ErrUnauthorized) { t.Fatalf("expired: %v", err) } wrongIss, err := NewJWT(&Options{Secret: "test-secret-test-secret", Issuer: "other", TTL: time.Hour, Clock: clock}) if err != nil { t.Fatal(err) } issTok, err := wrongIss.Issue(ctx, 1, 2, jwtTestAppID) if err != nil { t.Fatal(err) } if _, err := j.Authenticate(ctx, issTok.AccessToken); !errors.Is(err, errs.ErrUnauthorized) { t.Fatalf("issuer: %v", err) } } func TestJWTRefreshRotatesAndRejectsOld(t *testing.T) { ctx := context.Background() j := testJWT(t, nil) tok, err := j.Issue(ctx, 5, 8, jwtTestAppID) if err != nil { t.Fatal(err) } if _, err := j.Authenticate(ctx, tok.RefreshToken); !errors.Is(err, errs.ErrUnauthorized) { t.Fatalf("refresh as access: %v", err) } next, err := j.Refresh(ctx, tok.RefreshToken) if err != nil { t.Fatal(err) } if next.AccessToken == tok.AccessToken || next.RefreshToken == tok.RefreshToken { t.Fatal("expected new pair") } if _, err := j.Authenticate(ctx, tok.AccessToken); !errors.Is(err, errs.ErrUnauthorized) { t.Fatalf("old access: %v", err) } if _, err := j.Refresh(ctx, tok.RefreshToken); !errors.Is(err, errs.ErrUnauthorized) { t.Fatalf("old refresh: %v", err) } if _, err := j.Authenticate(ctx, next.AccessToken); err != nil { t.Fatal(err) } } func TestJWTLogoutRevokesPair(t *testing.T) { ctx := context.Background() j := testJWT(t, nil) tok, err := j.Issue(ctx, 4, 2, jwtTestAppID) if err != nil { t.Fatal(err) } p, err := j.Authenticate(ctx, tok.AccessToken) if err != nil { t.Fatal(err) } if err := j.Logout(ctx, p.UserID, p.DeviceID, p.AppID, p.JTI, p.ExpiresAt); err != nil { t.Fatal(err) } if _, err := j.Authenticate(ctx, tok.AccessToken); !errors.Is(err, errs.ErrUnauthorized) { t.Fatalf("access after logout: %v", err) } if _, err := j.Refresh(ctx, tok.RefreshToken); !errors.Is(err, errs.ErrUnauthorized) { t.Fatalf("refresh after logout: %v", err) } } func TestJWTLogoutAllRevokesEveryDevice(t *testing.T) { ctx := context.Background() j := testJWT(t, nil) first, err := j.Issue(ctx, 8, 1, jwtTestAppID) if err != nil { t.Fatal(err) } second, err := j.Issue(ctx, 8, 2, jwtTestAppID) if err != nil { t.Fatal(err) } if err := j.LogoutAll(ctx, 8); err != nil { t.Fatal(err) } if _, err := j.Authenticate(ctx, first.AccessToken); !errors.Is(err, errs.ErrUnauthorized) { t.Fatalf("first access: %v", err) } if _, err := j.Authenticate(ctx, second.AccessToken); !errors.Is(err, errs.ErrUnauthorized) { t.Fatalf("second access: %v", err) } if _, err := j.Refresh(ctx, first.RefreshToken); !errors.Is(err, errs.ErrUnauthorized) { t.Fatalf("first refresh: %v", err) } } func TestJWTDenylistRevoke(t *testing.T) { ctx := context.Background() j := testJWT(t, nil) tok, err := j.Issue(ctx, 9, 3, jwtTestAppID) if err != nil { t.Fatal(err) } if err := j.Revoke(ctx, tok.AccessToken); err != nil { t.Fatal(err) } if _, err := j.Authenticate(ctx, tok.AccessToken); !errors.Is(err, errs.ErrUnauthorized) { t.Fatalf("revoked: %v", err) } } func TestJWTIssueRejectsBadIDs(t *testing.T) { j := testJWT(t, nil) if _, err := j.Issue(context.Background(), 0, 1, jwtTestAppID); !errors.Is(err, errs.ErrInvalidArgument) { t.Fatalf("user: %v", err) } if _, err := j.Issue(context.Background(), 1, 0, jwtTestAppID); !errors.Is(err, errs.ErrInvalidArgument) { t.Fatalf("device: %v", err) } if _, err := j.Issue(context.Background(), 1, 1, 0); !errors.Is(err, errs.ErrInvalidArgument) { t.Fatalf("app: %v", err) } } func TestJWTRejectsNoneAlg(t *testing.T) { j := testJWT(t, nil) tok := jwt.NewWithClaims(jwt.SigningMethodNone, &claims{ DeviceID: 1, RegisteredClaims: jwt.RegisteredClaims{ Subject: "1", Issuer: "spark-im", IssuedAt: jwt.NewNumericDate(time.Now()), ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), ID: "jti-none", }, }) raw, err := tok.SignedString(jwt.UnsafeAllowNoneSignatureType) if err != nil { t.Fatal(err) } if _, err := j.Authenticate(context.Background(), raw); !errors.Is(err, errs.ErrUnauthorized) { t.Fatalf("none: %v", err) } } func TestJWTPairModeSingleSessionPerDevice(t *testing.T) { ctx := context.Background() pairs := NewMemoryPairsMode(PairKeyPair) j, err := NewJWT(&Options{Secret: "test-secret-test-secret", Pairs: pairs, PairKeyMode: PairKeyPair}) if err != nil { t.Fatal(err) } first, err := j.Issue(ctx, 3, 9, 1) if err != nil { t.Fatal(err) } second, err := j.Issue(ctx, 3, 9, 2) if err != nil { t.Fatal(err) } if _, err := j.Refresh(ctx, first.RefreshToken); !errors.Is(err, errs.ErrUnauthorized) { t.Fatalf("old app refresh should fail: %v", err) } if _, err := j.Refresh(ctx, second.RefreshToken); err != nil { t.Fatal(err) } } func TestMemoryPairsListByUser(t *testing.T) { ctx := context.Background() p := NewMemoryPairsMode(PairKeyTriple) if err := p.Put(ctx, 2, 5, 1, TokenPair{Access: "a1", Refresh: "r1"}, time.Minute); err != nil { t.Fatal(err) } if err := p.Put(ctx, 2, 5, 2, TokenPair{Access: "a2", Refresh: "r2"}, time.Minute); err != nil { t.Fatal(err) } rows, err := p.ListByUser(ctx, 2) if err != nil || len(rows) != 2 { t.Fatalf("list %+v %v", rows, err) } if err := p.DeleteAllOnDevice(ctx, 2, 5); err != nil { t.Fatal(err) } rows, err = p.ListByUser(ctx, 2) if err != nil || len(rows) != 0 { t.Fatalf("after delete %+v %v", rows, err) } } func TestNewJWTRequiresSecret(t *testing.T) { if _, err := NewJWT(&Options{}); err == nil { t.Fatal("expected secret error") } } func TestMemoryDenylistExpiry(t *testing.T) { d := NewMemoryDenylist() ctx := context.Background() if err := d.Add(ctx, "x", time.Millisecond); err != nil { t.Fatal(err) } time.Sleep(5 * time.Millisecond) ok, err := d.Has(ctx, "x") if err != nil || ok { t.Fatalf("expired still present: %v %v", ok, err) } } func TestRedisStoresRequireClient(t *testing.T) { ctx := t.Context() d := NewRedisDenylist(nil, "") if _, err := d.Has(ctx, "j"); err == nil { t.Fatal("denylist has") } if err := d.Add(ctx, "j", time.Second); err == nil { t.Fatal("denylist add") } p := NewRedisPairs(nil, "") if err := p.Put(ctx, 1, 1, jwtTestAppID, TokenPair{Access: "a"}, time.Second); err == nil { t.Fatal("pairs put") } if _, err := p.Get(ctx, 1, 1, jwtTestAppID); err == nil { t.Fatal("pairs get") } if err := p.Delete(ctx, 1, 1, jwtTestAppID); err == nil { t.Fatal("pairs del") } if _, err := p.ListByUser(ctx, 1); err == nil { t.Fatal("pairs list") } }