package auth import ( "crypto/hmac" "crypto/sha256" "encoding/base64" "encoding/hex" "errors" "fmt" "strconv" "strings" "time" "github.com/google/uuid" ) var ErrTokenInvalid = errors.New("token invalid or expired") // IssueSetPasswordToken creates a signed, time-limited token (no DB row). // secret must come from env (TOKEN_SIGNING_SECRET); never commit secrets. func IssueSetPasswordToken(secret string, userID uuid.UUID, ttl time.Duration) (string, error) { if strings.TrimSpace(secret) == "" { return "", errors.New("token signing secret not configured") } if ttl <= 0 { ttl = 72 * time.Hour } exp := time.Now().Add(ttl).Unix() nonce, err := RandomToken(8) if err != nil { return "", err } payload := fmt.Sprintf("%s.%d.%s", userID.String(), exp, nonce) mac := hmac.New(sha256.New, []byte(secret)) _, _ = mac.Write([]byte(payload)) sig := hex.EncodeToString(mac.Sum(nil)) raw := payload + "." + sig return base64.RawURLEncoding.EncodeToString([]byte(raw)), nil } func ParseSetPasswordToken(secret, token string) (uuid.UUID, error) { if strings.TrimSpace(secret) == "" || strings.TrimSpace(token) == "" { return uuid.Nil, ErrTokenInvalid } raw, err := base64.RawURLEncoding.DecodeString(token) if err != nil { return uuid.Nil, ErrTokenInvalid } parts := strings.Split(string(raw), ".") if len(parts) != 4 { return uuid.Nil, ErrTokenInvalid } userID, err := uuid.Parse(parts[0]) if err != nil { return uuid.Nil, ErrTokenInvalid } exp, err := strconv.ParseInt(parts[1], 10, 64) if err != nil || time.Now().Unix() > exp { return uuid.Nil, ErrTokenInvalid } payload := parts[0] + "." + parts[1] + "." + parts[2] mac := hmac.New(sha256.New, []byte(secret)) _, _ = mac.Write([]byte(payload)) expected := hex.EncodeToString(mac.Sum(nil)) if !hmac.Equal([]byte(expected), []byte(parts[3])) { return uuid.Nil, ErrTokenInvalid } return userID, nil }