235 lines
6.1 KiB
Go
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)
|
|
}
|
|
}
|