// Package s3 provides S3/SigV4 authentication via JWT conversion. package s3 import ( "fmt" "io" "log" "net/http" "net/http/httputil" "net/url" "time" "github.com/golang-jwt/jwt/v5" "forgejo.riotpiao.com/rock/homelab-frontend/internal/auth" ) // SigV4Handler converts JWT Bearer tokens to SigV4 signatures for MinIO/S3 access. // Acts as a proxy layer between JWT-authenticated clients and S3-compatible APIs. type SigV4Handler struct { minioEndpoint string // e.g., "https://minio-api.riotpiao.com" minioAccessKey string // Service account access key minioSecretKey string // Service account secret key jwtValidator *auth.Validator reverseProxy *httputil.ReverseProxy } // NewSigV4Handler creates a new S3/SigV4 proxy handler. func NewSigV4Handler(minioEndpoint, accessKey, secretKey string, jwtValidator *auth.Validator) (*SigV4Handler, error) { upstreamURL, err := url.Parse(minioEndpoint) if err != nil { return nil, fmt.Errorf("invalid minio endpoint: %w", err) } h := &SigV4Handler{ minioEndpoint: minioEndpoint, minioAccessKey: accessKey, minioSecretKey: secretKey, jwtValidator: jwtValidator, } // Create reverse proxy h.reverseProxy = &httputil.ReverseProxy{ Director: h.director, Transport: h.transport(), ErrorHandler: h.errorHandler, ModifyResponse: h.modifyResponse, } // Test connectivity to MinIO client := &http.Client{Timeout: 5 * time.Second} resp, err := client.Head(upstreamURL.Scheme + "://" + upstreamURL.Host + "/minio/health/live") if err != nil { log.Printf("warning: could not reach MinIO at %s: %v", minioEndpoint, err) } else { resp.Body.Close() log.Printf("SigV4 handler connected to MinIO at %s", minioEndpoint) } return h, nil } // ServeHTTP implements http.Handler interface. // Validates JWT, generates SigV4 signature, forwards to MinIO. func (h *SigV4Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Validate JWT from Authorization header authHeader := r.Header.Get("Authorization") if authHeader == "" { http.Error(w, "Missing Authorization header", http.StatusUnauthorized) log.Printf("S3 access denied: no authorization header") return } claims, err := h.jwtValidator.ValidateBearerToken(authHeader) if err != nil { http.Error(w, fmt.Sprintf("JWT validation failed: %v", err), http.StatusUnauthorized) log.Printf("S3 JWT validation failed: %v", err) return } // Log access var user, subject string if azp, ok := claims["azp"].(string); ok { user = azp } if sub, ok := claims["sub"].(string); ok { subject = sub } log.Printf("S3 access: user=%s subject=%s path=%s method=%s", user, subject, r.URL.Path, r.Method) // Validate user has S3 permission in claims if !h.hasS3Permission(jwt.MapClaims(claims)) { http.Error(w, "Insufficient permissions for S3 access", http.StatusForbidden) log.Printf("S3 permission denied for user %s", claims.Get("sub")) return } // Clone request for MinIO minioReq := r.Clone(r.Context()) minioReq.Header.Set("User-Agent", "homelab-s3-gateway/1.0") // Generate SigV4 signature // The reverse proxy will use the Director to set the URL and auth headers h.reverseProxy.ServeHTTP(w, minioReq) } // director rewrites the request URL and adds SigV4 signature. func (h *SigV4Handler) director(r *http.Request) { // Rewrite URL to MinIO backend upstreamURL, _ := url.Parse(h.minioEndpoint) r.URL.Scheme = upstreamURL.Scheme r.URL.Host = upstreamURL.Host r.URL.Path = r.URL.Path // Keep original path (bucket/key) // Remove Authorization header (will be replaced with SigV4) r.Header.Del("Authorization") r.Header.Del("X-Amz-Date") r.Header.Del("X-Amz-Security-Token") // TODO: Generate and sign SigV4 signature // This requires AWS SDK or custom implementation // For now, forward as-is and rely on MinIO service account permissions // Signature would be: AWS4-HMAC-SHA256 Credential=..., SignedHeaders=..., Signature=... r.RequestURI = "" // Required for client requests r.Host = upstreamURL.Host } // transport returns an HTTP transport for MinIO connections. func (h *SigV4Handler) transport() *http.Transport { return &http.Transport{ MaxIdleConns: 100, MaxIdleConnsPerHost: 10, IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 10 * time.Second, DisableKeepAlives: false, } } // errorHandler logs proxy errors. func (h *SigV4Handler) errorHandler(w http.ResponseWriter, r *http.Request, err error) { log.Printf("S3 proxy error: %v (path=%s method=%s)", err, r.URL.Path, r.Method) http.Error(w, fmt.Sprintf("S3 proxy error: %v", err), http.StatusBadGateway) } // modifyResponse logs successful S3 responses. func (h *SigV4Handler) modifyResponse(resp *http.Response) error { log.Printf("S3 response: status=%d content-length=%d", resp.StatusCode, resp.ContentLength) return nil } // hasS3Permission checks if JWT claims grant S3 access. func (h *SigV4Handler) hasS3Permission(claims jwt.MapClaims) bool { // Check for "s3:read" or "s3:write" in permissions claim if perms, ok := claims["permissions"]; ok { switch permsVal := perms.(type) { case []interface{}: for _, p := range permsVal { if perm, ok := p.(string); ok { if perm == "s3:read" || perm == "s3:write" || perm == "*" { return true } } } case []string: for _, perm := range permsVal { if perm == "s3:read" || perm == "s3:write" || perm == "*" { return true } } } } // Check for "s3:*" in roles claim (service accounts) if roles, ok := claims["roles"]; ok { switch rolesVal := roles.(type) { case []interface{}: for _, r := range rolesVal { if role, ok := r.(string); ok { if role == "s3:read" || role == "s3:write" || role == "*" { return true } } } case []string: for _, role := range rolesVal { if role == "s3:read" || role == "s3:write" || role == "*" { return true } } } } return false }