spark-server/internal/module/auth/logic/jwt_test.go
2026-09-20 17:49:09 +08:00

235 lines
6.1 KiB
Go

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
}
func TestJWTIssueAuthenticate(t *testing.T) {
ctx := context.Background()
j := testJWT(t, nil)
tok, err := j.Issue(ctx, 42, 7)
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.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)
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)
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)
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)
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)
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.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)
if err != nil {
t.Fatal(err)
}
second, err := j.Issue(ctx, 8, 2)
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)
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); !errors.Is(err, errs.ErrInvalidArgument) {
t.Fatalf("user: %v", err)
}
if _, err := j.Issue(context.Background(), 1, 0); !errors.Is(err, errs.ErrInvalidArgument) {
t.Fatalf("device: %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 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)
}
}