package auth import ( "context" "errors" "fmt" "net/http" "strings" "time" "github.com/golang-jwt/jwt/v5" ) var ( ErrInvalidToken = errors.New("invalid or expired token") ErrNoToken = errors.New("no authorization token") ) // Claims for our JWT (minimal, matching original style). type Claims struct { UserID int `json:"user_id"` Email string `json:"email"` Name string `json:"name"` Roles []string `json:"roles"` jwt.RegisteredClaims } // Context key for user info. type contextKey string const UserContextKey contextKey = "user" // JWTManager handles signing and validation. type JWTManager struct { secret []byte } // NewJWTManager creates a manager. In real use, load secret from secure store or env (never commit real secret). // For demo we accept a secret; in production rotate and use env/JWT_SECRET or db meta. func NewJWTManager(secret string) *JWTManager { if secret == "" { secret = "dev-only-insecure-secret-change-in-prod" } return &JWTManager{secret: []byte(secret)} } // GenerateToken creates a JWT for the given user (1 hour expiry like typical). func (m *JWTManager) GenerateToken(userID int, email, name string, roles []string) (string, error) { now := time.Now() if roles == nil { roles = []string{} } claims := Claims{ UserID: userID, Email: email, Name: name, Roles: roles, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(now.Add(1 * time.Hour)), IssuedAt: jwt.NewNumericDate(now), NotBefore: jwt.NewNumericDate(now), Issuer: "helix-proxy", }, } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) return token.SignedString(m.secret) } // ValidateToken parses and validates the token, returns claims. func (m *JWTManager) ValidateToken(tokenStr string) (*Claims, error) { token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (interface{}, error) { if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) } return m.secret, nil }) if err != nil { return nil, ErrInvalidToken } if claims, ok := token.Claims.(*Claims); ok && token.Valid { return claims, nil } return nil, ErrInvalidToken } // Middleware returns a chi/http middleware that validates Bearer token and injects user into context. // Skips if no token (for public endpoints like /login we handle separately). // On failure returns 401. func (m *JWTManager) Middleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { authHeader := r.Header.Get("Authorization") if authHeader == "" { // No token: let handler decide (some endpoints public) next.ServeHTTP(w, r) return } parts := strings.SplitN(authHeader, " ", 2) if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { http.Error(w, "invalid authorization header", http.StatusUnauthorized) return } claims, err := m.ValidateToken(parts[1]) if err != nil { http.Error(w, "invalid token", http.StatusUnauthorized) return } // Inject into context ctx := context.WithValue(r.Context(), UserContextKey, claims) next.ServeHTTP(w, r.WithContext(ctx)) }) } // GetUserFromContext extracts claims if present (for handlers that require auth). func GetUserFromContext(ctx context.Context) (*Claims, bool) { claims, ok := ctx.Value(UserContextKey).(*Claims) return claims, ok } // RequireAuth is a simple helper that can be used inside handlers for protected routes // (alternative to middleware if you want per-route). func RequireAuth(w http.ResponseWriter, r *http.Request) (*Claims, bool) { claims, ok := GetUserFromContext(r.Context()) if !ok { http.Error(w, "authentication required", http.StatusUnauthorized) return nil, false } return claims, true }