package auth import ( "errors" "fmt" "time" "github.com/golang-jwt/jwt/v5" ) // SessionLifetime is how long an issued JWT remains valid. const SessionLifetime = 24 * time.Hour // ErrInvalidJWT is returned when a JWT fails parsing, signature // verification, or claim validation. var ErrInvalidJWT = errors.New("invalid jwt") // Claims is the JWT payload we sign for authenticated sessions. type Claims struct { UserID string `json:"sub"` Email string `json:"email"` jwt.RegisteredClaims } // Signer issues and verifies session JWTs using HS256. type Signer struct { secret []byte } // NewSigner builds a Signer from a shared secret (>= 32 bytes recommended). func NewSigner(secret []byte) *Signer { return &Signer{secret: secret} } // Issue creates a signed JWT for the given user. func (s *Signer) Issue(userID, email string) (string, error) { now := time.Now().UTC() claims := Claims{ UserID: userID, Email: email, RegisteredClaims: jwt.RegisteredClaims{ IssuedAt: jwt.NewNumericDate(now), ExpiresAt: jwt.NewNumericDate(now.Add(SessionLifetime)), NotBefore: jwt.NewNumericDate(now), Issuer: "librenotes", Subject: userID, }, } tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) signed, err := tok.SignedString(s.secret) if err != nil { return "", fmt.Errorf("sign: %w", err) } return signed, nil } // Verify parses and validates a JWT, returning the claims on success. func (s *Signer) Verify(token string) (*Claims, error) { claims := &Claims{} parsed, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (any, error) { if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) } return s.secret, nil }, jwt.WithValidMethods([]string{"HS256"})) if err != nil || !parsed.Valid { return nil, ErrInvalidJWT } if claims.UserID == "" { return nil, ErrInvalidJWT } return claims, nil }