// Package logic is the auth application use-case layer (JWT, login, denylist). package logic import ( "context" "strconv" "strings" "time" "github.com/golang-jwt/jwt/v5" authentity "spark-server/internal/module/auth/entity" "spark-server/internal/pkg/errs" "spark-server/internal/pkg/reqctx" ) // Service validates an access token. HTTP middleware and TCP AUTH share this. type Service interface { Authenticate(ctx context.Context, token string) (*authentity.Principal, error) } // Issuer mints, refreshes, and revokes access tokens. type Issuer interface { Issue(ctx context.Context, userID int64, deviceID uint64) (*authentity.IssuedToken, error) Refresh(ctx context.Context, refreshToken string) (*authentity.IssuedToken, error) Logout(ctx context.Context, userID int64, deviceID uint64, accessJTI string, accessExp time.Time) error Revoke(ctx context.Context, token string) error } // TokenService is JWT: authenticate, issue, and revoke. type TokenService interface { Service Issuer } const ( defaultIssuer = "spark-im" defaultTTL = 7 * 24 * time.Hour defaultRefreshTTL = 30 * 24 * time.Hour ) // Options configure HS256 JWT issuance and validation. type Options struct { Secret string Issuer string TTL time.Duration RefreshTTL time.Duration Denylist Denylist Pairs PairStore Clock func() time.Time } type claims struct { DeviceID uint64 `json:"did"` Use string `json:"use,omitempty"` jwt.RegisteredClaims } // JWT implements TokenService with HS256 and an optional jti denylist. type JWT struct { secret []byte issuer string ttl time.Duration refreshTTL time.Duration denylist Denylist pairs PairStore clock func() time.Time } func NewJWT(opt *Options) (*JWT, error) { if opt == nil || strings.TrimSpace(opt.Secret) == "" { return nil, errs.ErrInner.WithInnerMsg("jwt: secret is required") } issuer := opt.Issuer if issuer == "" { issuer = defaultIssuer } ttl := opt.TTL if ttl <= 0 { ttl = defaultTTL } refreshTTL := opt.RefreshTTL if refreshTTL <= 0 { refreshTTL = defaultRefreshTTL } dl := opt.Denylist if dl == nil { dl = NewMemoryDenylist() } pairs := opt.Pairs if pairs == nil { pairs = NewMemoryPairs() } clock := opt.Clock if clock == nil { clock = time.Now } return &JWT{ secret: []byte(opt.Secret), issuer: issuer, ttl: ttl, refreshTTL: refreshTTL, denylist: dl, pairs: pairs, clock: clock, }, nil } func (j *JWT) Issue(ctx context.Context, userID int64, deviceID uint64) (*authentity.IssuedToken, error) { if err := ctx.Err(); err != nil { return nil, errs.FromRPCError("jwt", err) } if userID <= 0 || deviceID == 0 { return nil, errs.ErrInvalidArgument } access, err := j.sign(userID, deviceID, tokenUseAccess, j.ttl) if err != nil { return nil, err } refresh, err := j.sign(userID, deviceID, tokenUseRefresh, j.refreshTTL) if err != nil { return nil, err } if err := j.replacePair(ctx, userID, deviceID, access.jti, refresh.jti); err != nil { return nil, err } return &authentity.IssuedToken{ AccessToken: access.raw, RefreshToken: refresh.raw, ExpiresIn: int64(j.ttl / time.Second), RefreshExpiresIn: int64(j.refreshTTL / time.Second), UserID: userID, DeviceID: deviceID, }, nil } type signedToken struct { raw string jti string } func (j *JWT) sign(userID int64, deviceID uint64, use string, ttl time.Duration) (signedToken, error) { now := j.clock() jti := reqctx.NewID() tok := jwt.NewWithClaims(jwt.SigningMethodHS256, &claims{ DeviceID: deviceID, Use: use, RegisteredClaims: jwt.RegisteredClaims{ Subject: strconv.FormatInt(userID, 10), Issuer: j.issuer, IssuedAt: jwt.NewNumericDate(now), ExpiresAt: jwt.NewNumericDate(now.Add(ttl)), ID: jti, }, }) raw, err := tok.SignedString(j.secret) if err != nil { return signedToken{}, errs.ErrInner.WithError("jwt", err) } return signedToken{raw: raw, jti: jti}, nil } func (j *JWT) replacePair(ctx context.Context, userID int64, deviceID uint64, accessJTI, refreshJTI string) error { old, err := j.pairs.Get(ctx, userID, deviceID) if err != nil { return errs.ErrAvailable.WithError("jwt-refresh", err) } if old.Access != "" { _ = j.denylist.Add(ctx, old.Access, j.ttl) } if old.Refresh != "" { _ = j.denylist.Add(ctx, old.Refresh, j.refreshTTL) } if err := j.pairs.Put(ctx, userID, deviceID, TokenPair{Access: accessJTI, Refresh: refreshJTI}, j.refreshTTL); err != nil { return errs.ErrAvailable.WithError("jwt-refresh", err) } return nil } func (j *JWT) Refresh(ctx context.Context, refreshToken string) (*authentity.IssuedToken, error) { c, err := j.parse(refreshToken, false) if err != nil { return nil, err } if c.Use != tokenUseRefresh { return nil, errs.ErrUnauthorized } denied, err := j.denylist.Has(ctx, c.ID) if err != nil { return nil, errs.ErrAvailable.WithError("jwt-denylist", err) } if denied { return nil, errs.ErrUnauthorized } uid, err := strconv.ParseInt(c.Subject, 10, 64) if err != nil || uid <= 0 || c.DeviceID == 0 { return nil, errs.ErrUnauthorized } stored, err := j.pairs.Get(ctx, uid, c.DeviceID) if err != nil { return nil, errs.ErrAvailable.WithError("jwt-refresh", err) } if stored.Refresh == "" || stored.Refresh != c.ID { return nil, errs.ErrUnauthorized } return j.Issue(ctx, uid, c.DeviceID) } func (j *JWT) Logout(ctx context.Context, userID int64, deviceID uint64, accessJTI string, accessExp time.Time) error { if accessJTI != "" { remain := accessExp.Sub(j.clock()) if remain > 0 { if err := j.denylist.Add(ctx, accessJTI, remain); err != nil { return errs.ErrAvailable.WithError("jwt-denylist", err) } } } old, err := j.pairs.Get(ctx, userID, deviceID) if err != nil { return errs.ErrAvailable.WithError("jwt-refresh", err) } if old.Access != "" && old.Access != accessJTI { _ = j.denylist.Add(ctx, old.Access, j.ttl) } if old.Refresh != "" { _ = j.denylist.Add(ctx, old.Refresh, j.refreshTTL) } if err := j.pairs.Delete(ctx, userID, deviceID); err != nil { return errs.ErrAvailable.WithError("jwt-refresh", err) } return nil } func (j *JWT) Authenticate(ctx context.Context, token string) (*authentity.Principal, error) { c, err := j.parse(token, false) if err != nil { return nil, err } denied, err := j.denylist.Has(ctx, c.ID) if err != nil { return nil, errs.ErrAvailable.WithError("jwt-denylist", err) } if denied { return nil, errs.ErrUnauthorized } if c.Use != "" && c.Use != tokenUseAccess { return nil, errs.ErrUnauthorized } uid, err := strconv.ParseInt(c.Subject, 10, 64) if err != nil || uid <= 0 || c.DeviceID == 0 { return nil, errs.ErrUnauthorized } exp := time.Time{} if c.ExpiresAt != nil { exp = c.ExpiresAt.Time } return &authentity.Principal{UserID: uid, DeviceID: c.DeviceID, JTI: c.ID, ExpiresAt: exp}, nil } func (j *JWT) Revoke(ctx context.Context, token string) error { c, err := j.parse(token, true) if err != nil { return err } if c.ExpiresAt == nil { return nil } remain := c.ExpiresAt.Time.Sub(j.clock()) if remain <= 0 { return nil } if err := j.denylist.Add(ctx, c.ID, remain); err != nil { return errs.ErrAvailable.WithError("jwt-denylist", err) } return nil } func (j *JWT) parse(token string, allowExpired bool) (*claims, error) { if token == "" { return nil, errs.ErrUnauthorized } opts := []jwt.ParserOption{ jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}), jwt.WithTimeFunc(j.clock), } if allowExpired { opts = append(opts, jwt.WithoutClaimsValidation()) } else { opts = append(opts, jwt.WithIssuer(j.issuer), jwt.WithIssuedAt(), jwt.WithExpirationRequired()) } parsed, err := jwt.ParseWithClaims(token, &claims{}, func(t *jwt.Token) (any, error) { if t.Method != jwt.SigningMethodHS256 { return nil, jwt.ErrSignatureInvalid } return j.secret, nil }, opts...) if err != nil || parsed == nil || !parsed.Valid { return nil, errs.ErrUnauthorized } c, ok := parsed.Claims.(*claims) if !ok || c.ID == "" { return nil, errs.ErrUnauthorized } if allowExpired && c.Issuer != j.issuer { return nil, errs.ErrUnauthorized } return c, nil }