package auth import ( "errors" "fmt" "time" "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" ) var ( ErrInvalidJWT = errors.New("invalid token") ErrExpiredJWT = errors.New("token expired") ) type AccessClaims struct { UserID uuid.UUID `json:"sub_uuid"` jwt.RegisteredClaims } type JWTSigner struct { secret []byte ttl time.Duration issuer string parser *jwt.Parser } func NewJWTSigner(secret string, ttl time.Duration, issuer string) (*JWTSigner, error) { if len(secret) < 32 { return nil, fmt.Errorf("jwt secret must be at least 32 bytes") } if ttl <= 0 { return nil, fmt.Errorf("jwt ttl must be positive") } return &JWTSigner{ secret: []byte(secret), ttl: ttl, issuer: issuer, parser: jwt.NewParser( jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}), jwt.WithIssuer(issuer), jwt.WithExpirationRequired(), ), }, nil } func (s *JWTSigner) Issue(userID uuid.UUID, now time.Time) (string, time.Time, error) { exp := now.Add(s.ttl) claims := AccessClaims{ UserID: userID, RegisteredClaims: jwt.RegisteredClaims{ Issuer: s.issuer, Subject: userID.String(), IssuedAt: jwt.NewNumericDate(now), NotBefore: jwt.NewNumericDate(now.Add(-1 * time.Second)), ExpiresAt: jwt.NewNumericDate(exp), ID: uuid.NewString(), }, } tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) signed, err := tok.SignedString(s.secret) if err != nil { return "", time.Time{}, err } return signed, exp, nil } func (s *JWTSigner) Parse(raw string) (*AccessClaims, error) { claims := &AccessClaims{} tok, err := s.parser.ParseWithClaims(raw, claims, func(t *jwt.Token) (any, error) { return s.secret, nil }) if err != nil { if errors.Is(err, jwt.ErrTokenExpired) { return nil, ErrExpiredJWT } return nil, ErrInvalidJWT } if !tok.Valid { return nil, ErrInvalidJWT } if claims.UserID == uuid.Nil { // Fallback for tokens that only carry Subject (defensive — we always // set UserID on issue). parsed, perr := uuid.Parse(claims.Subject) if perr != nil { return nil, ErrInvalidJWT } claims.UserID = parsed } return claims, nil } func (s *JWTSigner) TTL() time.Duration { return s.ttl }