// Copyright (c) 2025-2026 Probo Inc . // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. package console_v1 import ( "errors" "fmt" "net/http" "go.gearno.de/kit/httpserver" "go.gearno.de/kit/log" "go.probo.inc/probo/pkg/connector" "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/iam" "go.probo.inc/probo/pkg/probo" "go.probo.inc/probo/pkg/server/api/authn" ) var errInvalidReconnectConnector = errors.New("invalid reconnect connector") func handleConnectorInitiate( logger *log.Logger, proboSvc *probo.Service, iamSvc *iam.Service, connectorRegistry *connector.ConnectorRegistry, ) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { provider := r.URL.Query().Get("provider") if provider == "" { httpserver.RenderError(w, http.StatusBadRequest, fmt.Errorf("missing provider parameter")) return } if _, err := connectorRegistry.Get(provider); err != nil { httpserver.RenderError(w, http.StatusBadRequest, fmt.Errorf("unsupported provider: %q", provider)) return } organizationID, err := gid.ParseGID(r.URL.Query().Get("organization_id")) if err != nil { httpserver.RenderError(w, http.StatusBadRequest, fmt.Errorf("invalid organization_id parameter")) return } if authn.APIKeyFromContext(r.Context()) != nil { httpserver.RenderError(w, http.StatusBadRequest, fmt.Errorf("api key authentication cannot be used for this endpoint")) return } identity := authn.IdentityFromContext(r.Context()) if identity == nil { httpserver.RenderError(w, http.StatusUnauthorized, fmt.Errorf("authentication required")) return } session := authn.SessionFromContext(r.Context()) if session == nil { httpserver.RenderError(w, http.StatusUnauthorized, fmt.Errorf("authentication required")) return } scope, err := iamSvc.Authorizer.Authorize(r.Context(), iam.AuthorizeParams{ Principal: identity.ID, Resource: organizationID, Session: &session.ID, Action: probo.ActionConnectorInitiate, }) if err != nil { httpserver.RenderError(w, http.StatusForbidden, err) return } requestedScopes := r.URL.Query()["scope"] prb := proboSvc // Look up any existing connector so we can union its stored scopes // into the new auth request. Cross-org/provider/protocol mismatches // are caught inside Reconnect at callback time; this handler only // needs the scope set. existing, err := loadExistingConnector(r, prb, scope, organizationID, provider) if err != nil { if errors.Is(err, coredata.ErrResourceNotFound) { httpserver.RenderError(w, http.StatusBadRequest, fmt.Errorf("cannot reconnect: connector not found")) return } if errors.Is(err, errInvalidReconnectConnector) { httpserver.RenderError(w, http.StatusBadRequest, err) return } logger.ErrorCtx(r.Context(), "cannot look up existing connector", log.Error(err)) httpserver.RenderError(w, http.StatusInternalServerError, fmt.Errorf("internal error")) return } // Always request the union of (old granted ∪ new requested). // Union-not-delta because most providers replace rather than // merge. No short-circuit: every reconnect runs the full OAuth // flow so revoked or stale tokens are never silently reused. opts := connector.InitiateOptions{Scopes: requestedScopes, Site: r.URL.Query().Get("site")} if existing != nil { opts.Scopes = connector.UnionScopes(existing.Connection.Scopes(), requestedScopes) opts.IncludeGrantedScopes = true opts.ConnectorID = existing.ID.String() } redirectURL, err := connectorRegistry.Initiate(r.Context(), provider, organizationID, opts, r) if err != nil { logger.ErrorCtx(r.Context(), "cannot initiate connector", log.Error(err)) httpserver.RenderError(w, http.StatusInternalServerError, fmt.Errorf("internal error")) return } http.Redirect(w, r, redirectURL, http.StatusSeeOther) } } // loadExistingConnector returns the connector the initiate handler // should reconnect, or nil if this is a fresh install. An explicit // `connector_id` query parameter selects a specific row; otherwise the // handler falls back to the widest-scope (org, provider) row. Callers // must distinguish ErrResourceNotFound (explicit id not found — 400) // from nil (no existing row — fresh install path). func loadExistingConnector( r *http.Request, prb *probo.Service, scope coredata.Scoper, organizationID gid.GID, provider string, ) (*coredata.Connector, error) { if explicitID := r.URL.Query().Get("connector_id"); explicitID != "" { parsedID, err := gid.ParseGID(explicitID) if err != nil { return nil, fmt.Errorf("%w: cannot parse connector id: %w", errInvalidReconnectConnector, err) } found, err := prb.Connectors.GetWithConnection(r.Context(), scope, parsedID) if err != nil { return nil, err } return found, nil } found, err := prb.Connectors.GetByOrganizationIDAndProvider( r.Context(), scope, organizationID, coredata.ConnectorProvider(provider), ) if errors.Is(err, coredata.ErrResourceNotFound) { return nil, nil } return found, err }