package authproxy import ( "bytes" "context" "encoding/gob" "io" "net/http" "net/http/httputil" "net/url" "os" "regexp" "strconv" "strings" "time" "github.com/gin-contrib/cors" "github.com/gin-contrib/requestid" "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" "github.com/go-logr/logr" "edge-infra.dev/pkg/edge/api/middleware" "edge-infra.dev/pkg/edge/audit" "edge-infra.dev/pkg/edge/auth-proxy/handlers" "edge-infra.dev/pkg/edge/auth-proxy/interceptor" edgestore "edge-infra.dev/pkg/edge/auth-proxy/store" "edge-infra.dev/pkg/edge/auth-proxy/types" logger "edge-infra.dev/pkg/lib/logging" "edge-infra.dev/pkg/lib/runtime/manager" "edge-infra.dev/pkg/lib/uuid" "edge-infra.dev/pkg/x/tonic" ) const ( accessControlAllowOriginHeader = "Access-Control-Allow-Origin" credentialsHeader = "Credentials" authorizationHeader = "Authorization" setCookieHeader = "Set-Cookie" emergencyAccessPathRegex = `^/ea/` ) func init() { // register other types for cookie codec gob.Register(time.Time{}) } func RunManager() { log := logger.NewLogger().Logger.WithName("auth-proxy") cfg, err := NewConfig(os.Args[1:]) if err != nil { log.Error(err, "failed to parse startup configuration") os.Exit(1) } db, err := cfg.connectDatabase() if err != nil { log.Error(err, "failed to parse startup configuration") os.Exit(1) } mgr, err := manager.New(manager.Options{MetricsBindAddress: cfg.MetricsPort}) if err != nil { log.Error(err, "failed to create a new manager for auth-proxy") os.Exit(1) } auditLog := audit.New("auth-proxy") pgstore, err := edgestore.NewStore(db, log, auditLog, []byte(cfg.SessionSecret)) if err != nil { log.Error(err, "failed to create a new manager for auth-proxy") os.Exit(1) } sameSite := http.SameSiteNoneMode if cfg.Mode == gin.ReleaseMode { sameSite = http.SameSiteStrictMode } // max size of bytea type in DB is 1 GB if err := pgstore.MaxLength(cfg.SessionLength); err != nil { log.Error(err, "failed to set session value maximum length") os.Exit(1) } pgstore.Options(sessions.Options{ Path: "/", HttpOnly: true, SameSite: sameSite, Secure: true, MaxAge: 60 * 14, }) // cleanup expired sessions every 10 minutes quit, done := pgstore.Cleanup(10 * time.Minute) defer pgstore.StopCleanup(quit, done) uiProxyServer := setupServer(log, auditLog, cfg, pgstore) if err := mgr.Add(uiProxyServer); err != nil { log.Error(err, "failed to add ui proxy server") os.Exit(1) } if err := mgr.Start(context.Background()); err != nil { log.Error(err, "auth-proxy mgr start failed") os.Exit(1) } } func corsMiddleware(allowedOrigins []string) gin.HandlerFunc { corsConfig := cors.DefaultConfig() corsConfig.AllowAllOrigins = false corsConfig.ExposeHeaders = append(corsConfig.AllowHeaders, accessControlAllowOriginHeader, credentialsHeader, authorizationHeader, setCookieHeader) corsConfig.AllowOrigins = allowedOrigins corsConfig.AllowHeaders = append(corsConfig.AllowHeaders, accessControlAllowOriginHeader, credentialsHeader, authorizationHeader, setCookieHeader) corsConfig.AllowCredentials = true return cors.New(corsConfig) } func setupServer(log logr.Logger, auditLog *audit.Sink, cfg *ProxyConfig, store edgestore.Store) *tonic.Server { allowedOrigins := cfg.allowedOrigins() log.Info("configuring server with these allowed origins", "origins", allowedOrigins) uiProxyServer := tonic.NewWithOptions(cfg.UIProxyPort, cfg.Mode). SetLogger(log). SetMiddlewares(middleware.SetRequestContext(), requestid.New(), middleware.RequestLogger(log), gin.Recovery(), corsMiddleware(allowedOrigins), sessions.Sessions(edgestore.SessionIdentifier, store)). SetRoutes(tonic.Route{ Path: "/*proxyPath", Action: tonic.MethodAny, Handlers: []gin.HandlerFunc{proxyHandler(log, auditLog, cfg)}, }).With404Route() return uiProxyServer } func proxyHandler(log logr.Logger, auditLog *audit.Sink, cfg *ProxyConfig) gin.HandlerFunc { return func(c *gin.Context) { proxy(c, log, auditLog, cfg) } } func proxy(c *gin.Context, log logr.Logger, auditLog *audit.Sink, cfg *ProxyConfig) { session := sessions.Default(c) apiEndpoint := getAPIEndpoint(c, log, cfg) correlationID := uuid.New().UUID auditLog.SetCorrelationID(correlationID) if filterEmergencyAccessURL(apiEndpoint.Path) && !validSession(session) { // All emergencyaccess services must be protected by a validSession. // If the session is invalid return immediately without proxying the request. log.Info("Abandoning emergencyaccess request due to invalid session", "url", apiEndpoint) c.AbortWithStatus(http.StatusForbidden) return } log.Info("proxying request...", "url", apiEndpoint) handler := handlers.New(c, log, session, cfg.TokenSecret, cfg.SessionDuration, false, correlationID) proxy := httputil.NewSingleHostReverseProxy(apiEndpoint) proxy.Rewrite = func(r *httputil.ProxyRequest) { r.In.Host = apiEndpoint.Host r.Out.URL = apiEndpoint r.Out.RequestURI = r.Out.URL.String() r.Out.RemoteAddr = r.In.RemoteAddr r.Out.Header.Set(audit.AuditIdentifierHeader, correlationID) // TODO(pa250194_ncrvoyix): refactor and make cleaner // remove nolint if r.Out.Body != nil { //nolint body, err := io.ReadAll(r.Out.Body) if err != nil { log.Error(err, "error reading proxied response") return } if err := r.Out.Body.Close(); err != nil { log.Error(err, "error closing proxy response body") return } r.Out.Body = io.NopCloser((bytes.NewBuffer(body))) intercpt := interceptor.New(c.ClientIP(), correlationID, session) intercpt.Query("schemaIntrospection", handler.Default, `\{"__schema":\{`, true) intercpt.Query("typeIntrospection", handler.Default, `\{"__type":\{`, true) intercpt.Query("typenameIntrospection", handler.Default, `\{"__typename":\{`, true) intercpt.Mutation("login", handler.Login, `\{"login":\{`, true) intercpt.Mutation("loginWithOkta", handler.LoginWithOkta, `\{"loginWithOkta":\{`, true) intercpt.Query("tenantsForOktaToken", handler.Default, `\{"tenantsForOktaToken":\{`, true) intercpt.Mutation("logout", handler.Logout, `\{"logout":true\}`, true) intercpt.Mutation("sessionRefresh", handler.SessionRefresh, `\{"sessionRefresh":`, validSession(session)) intercpt.Query("sessionUserEdgeRole", handler.SessionDefault, `\{"sessionUserEdgeRole":`, validSession(session)) intercpt.Path("sessionUserDetails", handler.SessionUserDetails, emergencyAccessPathRegex, true) intercpt.Default(handler.SessionDefault, true) req, _, err := intercpt.Intercept(r.Out, body, true) if err != nil { log.Error(err, "an error occurred intercepting request body") return } r.Out = req } } proxy.Transport = New(apiEndpoint) proxy.Director = nil proxy.ModifyResponse = func(r *http.Response) error { // If the path is of the form /api/ea/*, we want to immediately return without doing anything. // This is because an emergency access session is a long running connection, and reading the body // on some emergency access API requests would block until the session is closed. if filterEmergencyAccessURL(r.Request.URL.Path) { return nil } //TODO(pa250194_ncrvoyix): only Access-Control-Allow-Origin delete header if the vals already exist r.Header.Del(accessControlAllowOriginHeader) body, err := io.ReadAll(r.Body) if err != nil { log.Error(err, "error reading proxied response") return err } if err := r.Body.Close(); err != nil { log.Error(err, "error closing proxy response body") return err } intercpt := interceptor.New(c.ClientIP(), correlationID, session) intercpt.Query("schemaIntrospection", handler.Default, `\{"__schema":\{`, true) intercpt.Query("typeIntrospection", handler.Default, `\{"__type":\{`, true) intercpt.Query("typenameIntrospection", handler.Default, `\{"__typename":\{`, true) intercpt.Mutation("login", handler.Login, `\{"login":\{`, true) intercpt.Query("tenantsForOktaToken", handler.Default, `\{"tenantsForOktaToken":\{`, true) intercpt.Mutation("loginWithOkta", handler.LoginWithOkta, `\{"loginWithOkta":\{`, true) intercpt.Mutation("logout", handler.Logout, `\{"logout":true\}`, true) intercpt.Mutation("sessionRefresh", handler.SessionRefresh, `\{"sessionRefresh":`, validSession(session)) intercpt.Query("sessionUserEdgeRole", handler.SessionUserEdgeRole, `\{"sessionUserEdgeRole":`, validSession(session)) intercpt.Default(handler.SessionDefault, true) reqHeader := r.Request.Clone(r.Request.Context()) hdr, responseBody, err := intercpt.Intercept(reqHeader, body, false) if err != nil { return err } r.Request = hdr r.Body = io.NopCloser(bytes.NewReader(responseBody)) r.ContentLength = int64(len(responseBody)) r.Header.Set("Content-Length", strconv.Itoa(len(responseBody))) return err } proxy.ServeHTTP(c.Writer, c.Request) } func filterEmergencyAccessURL(path string) bool { re := regexp.MustCompile(emergencyAccessPathRegex) return re.MatchString(path) } func validSession(session sessions.Session) bool { expiresAt := session.Get(types.SessionExpirationField) if expiresAt != nil { expirationTime := expiresAt.(time.Time) return !expirationTime.Before(time.Now().UTC()) && session.Get(types.SessionIDField) != nil } return false } func getAPIEndpoint(c *gin.Context, log logr.Logger, cfg *ProxyConfig) *url.URL { cfgEndpoint := cfg.BffEndpoint path := c.Request.URL.Path if strings.HasPrefix(c.Request.URL.Path, "/api/ea/") { cfgEndpoint = cfg.EAGatewayEndpoint path = strings.TrimPrefix(path, "/api/") } apiEndpointString, err := url.JoinPath(cfgEndpoint, path) if err != nil { log.Error(err, "Error joining url and path for reverse proxy", "url", cfgEndpoint, "path", path) return nil } apiEndpoint, err := url.Parse(apiEndpointString) if err != nil { log.Error(err, "Error parsing Edge API target URL for reverse proxy", "url", apiEndpoint, "path", path) return nil } return apiEndpoint }