// Copyright 2025 Stefan Prodan.
// SPDX-License-Identifier: AGPL-3.0
package auth
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/go-logr/logr"
"sigs.k8s.io/controller-runtime/pkg/log"
fluxcdv1 "github.com/controlplaneio-fluxcd/flux-operator/api/v1"
"github.com/controlplaneio-fluxcd/flux-operator/internal/web/kubeclient"
"github.com/controlplaneio-fluxcd/flux-operator/internal/web/user"
)
const (
authPathLogout = "/logout"
oauth2PathAuthorize = "/oauth2/authorize"
oauth2PathCallback = "/oauth2/callback"
authQueryParamOriginalPath = "originalPath"
)
// NewMiddleware creates a new authentication middleware for HTTP handlers.
// It returns the middleware, a closer function that must be called to release
// resources (e.g. stop background goroutines), and an error. The closer
// accepts a context to limit the time spent waiting for cleanup, similar
// to http.Server.Shutdown.
func NewMiddleware(conf *fluxcdv1.WebConfigSpec, kubeClient *kubeclient.Client,
initLog logr.Logger) (func(next http.Handler) http.Handler, func(context.Context) error, error) {
// Build middleware according to the authentication type.
var middleware func(next http.Handler) http.Handler
var closer func(context.Context) error
var provider string
switch {
case conf.Authentication == nil:
middleware = newDefaultMiddleware()
closer = func(context.Context) error { return nil }
provider = "None"
case conf.Authentication.Anonymous != nil:
var err error
middleware, err = newAnonymousMiddleware(conf, kubeClient)
if err != nil {
return nil, nil, fmt.Errorf("failed to create anonymous authentication middleware: %w", err)
}
closer = func(context.Context) error { return nil }
provider = fluxcdv1.AuthenticationTypeAnonymous
case conf.Authentication.OAuth2 != nil:
var err error
middleware, closer, err = newOAuth2Middleware(conf, kubeClient)
if err != nil {
return nil, nil, fmt.Errorf("failed to create OAuth2 authentication middleware: %w", err)
}
provider = fmt.Sprintf("%s/%s", fluxcdv1.AuthenticationTypeOAuth2, conf.Authentication.OAuth2.Provider)
default:
return nil, nil, fmt.Errorf("unsupported authentication method")
}
initLog.Info("authentication initialized successfully", "authProvider", provider)
// Enhance middleware with logout handling and logger.
return func(next http.Handler) http.Handler {
next = middleware(next)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case authPathLogout:
// Only allow POST for logout to prevent CSRF attacks.
// GET requests to /logout could be triggered by malicious links.
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
deleteAuthStorage(w)
http.Redirect(w, r, "/", http.StatusSeeOther)
default:
// Inject logger into context.
l := log.FromContext(r.Context()).WithValues("authProvider", provider)
ctx := log.IntoContext(r.Context(), l)
r = r.WithContext(ctx)
next.ServeHTTP(w, r)
}
})
}, closer, nil
}
// newDefaultMiddleware creates a default authentication middleware
// that allows all requests without authentication.
func newDefaultMiddleware() func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
SetAnonymousAuthProviderCookie(w)
next.ServeHTTP(w, r)
})
}
}
// newAnonymousMiddleware creates an anonymous authentication middleware.
func newAnonymousMiddleware(conf *fluxcdv1.WebConfigSpec, kubeClient *kubeclient.Client) (func(next http.Handler) http.Handler, error) {
anonConf := conf.Authentication.Anonymous
username := anonConf.Username
groups := anonConf.Groups
details := user.Details{
Impersonation: user.Impersonation{
Username: username,
Groups: groups,
},
}
client, err := kubeClient.GetUserClientFromCache(details.Impersonation)
if err != nil {
return nil, err
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
SetAnonymousAuthProviderCookie(w)
ctx := user.StoreSession(r.Context(), details, client)
next.ServeHTTP(w, r.WithContext(ctx))
})
}, nil
}
// newOAuth2Middleware creates an OAuth2 authentication middleware.
func newOAuth2Middleware(conf *fluxcdv1.WebConfigSpec, kubeClient *kubeclient.Client) (func(next http.Handler) http.Handler, func(context.Context) error, error) {
// Build the OAuth2 provider.
var provider oauth2Provider
var err error
switch conf.Authentication.OAuth2.Provider {
case fluxcdv1.OAuth2ProviderOIDC:
provider, err = newOIDCProvider(conf)
if err != nil {
return nil, nil, err
}
default:
return nil, nil, fmt.Errorf("unsupported OAuth2 provider: %s", conf.Authentication.OAuth2.Provider)
}
// Build the OAuth2 authenticator.
authenticator, err := newOAuth2Authenticator(conf, kubeClient, provider)
if err != nil {
closeCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if closeErr := provider.close(closeCtx); closeErr != nil {
return nil, nil, errors.Join(err, fmt.Errorf("failed to close OAuth2 provider: %w", closeErr))
}
return nil, nil, err
}
// Return the middleware and its closer.
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == oauth2PathAuthorize:
authenticator.serveAuthorize(w, r)
case r.URL.Path == oauth2PathCallback:
authenticator.serveCallback(w, r)
case strings.HasPrefix(r.URL.Path, "/api/"):
authenticator.serveAPI(w, r, next)
case r.URL.Path == "/":
authenticator.serveIndex(w, r, next)
default:
next.ServeHTTP(w, r)
}
})
}, provider.close, nil
}