Head of Product & Engineering 9995027093
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
feat: per-location Cast API key and unified admin config API
Each GHL location can now have its own Cast API key and sender ID stored
in MongoDB. Falls back to global CAST_API_KEY / CAST_SENDER_ID env vars
when not set per-location.

Admin endpoints (all require Authorization: Bearer <INBOUND_API_KEY>):
  GET  /api/admin/locations                        — list all locations
  GET  /api/admin/locations/{locationId}/config    — get location config
  PUT  /api/admin/locations/{locationId}/config    — set sender_id + cast_api_key

Cast API key is masked in GET responses (first 12 chars + "...").
Replaces the /sender-id endpoint deployed in the previous commit.

Also adds FUTURE_DEV.md documenting the migration path to Infisical
for secret management, plus MongoDB security hardening checklist.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-06 14:08:29 +02:00

359 lines
13 KiB
Go

package ghl
import (
"context"
"encoding/json"
"errors"
"fmt"
"html/template"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
"git.sds.dev/CAST/cast-ghl-plugin/internal/store"
)
const (
ghlTokenURL = "https://services.leadconnectorhq.com/oauth/token"
ghlLocationTokenURL = "https://services.leadconnectorhq.com/oauth/locationToken"
ghlInstalledLocationsURL = "https://services.leadconnectorhq.com/oauth/installedLocations"
ghlLocationAPIVersion = "2021-07-28"
)
// TokenStore is the interface OAuthHandler uses for token persistence.
type TokenStore interface {
SaveToken(ctx context.Context, record *store.TokenRecord) error
GetToken(ctx context.Context, locationID string) (*store.TokenRecord, error)
UpdateToken(ctx context.Context, locationID, accessToken, refreshToken string, expiresAt time.Time) error
UpdateLocationConfig(ctx context.Context, locationID, senderID, castAPIKey string) error
ListTokens(ctx context.Context) ([]*store.TokenRecord, error)
DeleteToken(ctx context.Context, locationID string) error
}
type OAuthHandler struct {
clientID string
clientSecret string
baseURL string
providerID string
store TokenStore
httpClient *http.Client
tokenURL string
}
func NewOAuthHandler(clientID, clientSecret, baseURL, providerID string, s TokenStore) *OAuthHandler {
return &OAuthHandler{
clientID: clientID,
clientSecret: clientSecret,
baseURL: baseURL,
providerID: providerID,
store: s,
httpClient: &http.Client{Timeout: 30 * time.Second},
tokenURL: ghlTokenURL,
}
}
func (h *OAuthHandler) HandleInstall(w http.ResponseWriter, r *http.Request) {
redirectURI := h.baseURL + "/oauth-callback"
scopes := strings.Join([]string{
"conversations/message.write",
"conversations/message.readonly",
"conversations.write",
"conversations.readonly",
"contacts.readonly",
"contacts.write",
"locations.readonly",
}, " ")
authURL := fmt.Sprintf(
"https://marketplace.gohighlevel.com/oauth/chooselocation?response_type=code&redirect_uri=%s&client_id=%s&scope=%s",
url.QueryEscape(redirectURI),
url.QueryEscape(h.clientID),
url.QueryEscape(scopes),
)
slog.Info("ghl oauth install initiated", "redirect_uri", redirectURI)
http.Redirect(w, r, authURL, http.StatusFound)
}
func (h *OAuthHandler) HandleCallback(w http.ResponseWriter, r *http.Request) {
if errParam := r.URL.Query().Get("error"); errParam != "" {
slog.Warn("ghl oauth denied by user", "error", errParam)
http.Error(w, "authorization denied: "+errParam, http.StatusBadRequest)
return
}
code := r.URL.Query().Get("code")
if code == "" {
http.Error(w, "missing authorization code", http.StatusBadRequest)
return
}
slog.Info("ghl oauth callback query params", "params", r.URL.RawQuery)
ctx := r.Context()
tokenResp, err := h.exchangeCode(ctx, code)
if err != nil {
slog.Error("ghl oauth code exchange failed", "err", err)
http.Error(w, "token exchange failed: "+err.Error(), http.StatusInternalServerError)
return
}
// Company-level (bulk) install: exchange company token for per-location tokens.
if tokenResp.UserType == "Company" && tokenResp.LocationID == "" {
slog.Info("ghl oauth company install — exchanging for location tokens", "company_id", tokenResp.CompanyID)
installed, err := h.installAllLocations(ctx, tokenResp)
if err != nil {
slog.Error("ghl oauth location token exchange failed", "company_id", tokenResp.CompanyID, "err", err)
http.Error(w, "failed to exchange company token for location tokens: "+err.Error(), http.StatusInternalServerError)
return
}
slog.Info("ghl oauth bulk install complete", "company_id", tokenResp.CompanyID, "locations_installed", installed)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
tmpl := template.Must(template.New("ok").Parse(`<!DOCTYPE html><html><body><h2>Cast SMS installed successfully!</h2><p>Connected {{.}} location(s). You can close this tab.</p></body></html>`))
_ = tmpl.Execute(w, installed)
return
}
if tokenResp.LocationID == "" {
slog.Error("ghl oauth token missing locationId", "company_id", tokenResp.CompanyID, "user_type", tokenResp.UserType)
http.Error(w, "GHL token response did not include a locationId", http.StatusBadRequest)
return
}
expiresAt := time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
record := &store.TokenRecord{
LocationID: tokenResp.LocationID,
CompanyID: tokenResp.CompanyID,
AccessToken: tokenResp.AccessToken,
RefreshToken: tokenResp.RefreshToken,
ExpiresAt: expiresAt,
InstalledAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := h.store.SaveToken(ctx, record); err != nil {
slog.Error("ghl oauth token save failed", "location_id", tokenResp.LocationID, "err", err)
http.Error(w, "failed to save token", http.StatusInternalServerError)
return
}
slog.Info("ghl oauth install complete", "location_id", tokenResp.LocationID)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = fmt.Fprint(w, `<!DOCTYPE html><html><body><h2>Cast SMS installed successfully!</h2><p>You can close this tab.</p></body></html>`)
}
func (h *OAuthHandler) RefreshToken(ctx context.Context, locationID string) (*store.TokenRecord, error) {
record, err := h.store.GetToken(ctx, locationID)
if err != nil {
return nil, err
}
if record == nil {
return nil, errors.New("no token for location: " + locationID)
}
data := url.Values{}
data.Set("client_id", h.clientID)
data.Set("client_secret", h.clientSecret)
data.Set("grant_type", "refresh_token")
data.Set("refresh_token", record.RefreshToken)
tokenResp, err := h.postToken(ctx, data)
if err != nil {
return nil, fmt.Errorf("refresh token failed: %w", err)
}
expiresAt := time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
if err := h.store.UpdateToken(ctx, locationID, tokenResp.AccessToken, tokenResp.RefreshToken, expiresAt); err != nil {
return nil, fmt.Errorf("failed to update token in store: %w", err)
}
record.AccessToken = tokenResp.AccessToken
record.RefreshToken = tokenResp.RefreshToken
record.ExpiresAt = expiresAt
slog.Info("ghl token refreshed", "location_id", locationID)
return record, nil
}
func (h *OAuthHandler) GetValidToken(ctx context.Context, locationID string) (string, error) {
record, err := h.store.GetToken(ctx, locationID)
if err != nil {
return "", err
}
if record == nil {
return "", errors.New("no token for location: " + locationID)
}
if time.Until(record.ExpiresAt) < 5*time.Minute {
record, err = h.RefreshToken(ctx, locationID)
if err != nil {
return "", err
}
}
return record.AccessToken, nil
}
func (h *OAuthHandler) exchangeCode(ctx context.Context, code string) (*TokenResponse, error) {
data := url.Values{}
data.Set("client_id", h.clientID)
data.Set("client_secret", h.clientSecret)
data.Set("grant_type", "authorization_code")
data.Set("code", code)
data.Set("redirect_uri", h.baseURL+"/oauth-callback")
return h.postToken(ctx, data)
}
func (h *OAuthHandler) postToken(ctx context.Context, data url.Values) (*TokenResponse, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, h.tokenURL, strings.NewReader(data.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := h.httpClient.Do(req)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("token endpoint returned %d: %s", resp.StatusCode, string(body))
}
var tokenResp TokenResponse
if err := json.Unmarshal(body, &tokenResp); err != nil {
return nil, fmt.Errorf("failed to parse token response: %w", err)
}
slog.Info("ghl token response fields",
"location_id", tokenResp.LocationID,
"company_id", tokenResp.CompanyID,
"user_type", tokenResp.UserType,
"installed_locations", tokenResp.InstalledLocations,
"raw_body", string(body),
)
return &tokenResp, nil
}
// installAllLocations exchanges the company token for per-location tokens and stores them.
// It first tries to use the InstalledLocations list from the token response (provided by GHL
// for bulk installs). Falls back to GET /locations/search if that list is empty.
func (h *OAuthHandler) installAllLocations(ctx context.Context, companyToken *TokenResponse) (int, error) {
// Build location ID list from token response when GHL provides it (bulk install path).
locationIDs := companyToken.InstalledLocations
if len(locationIDs) == 0 {
slog.Info("ghl bulk install: no installedLocations in token, falling back to locations search", "company_id", companyToken.CompanyID)
locations, err := h.getCompanyLocations(ctx, companyToken.AccessToken, companyToken.CompanyID)
if err != nil {
return 0, fmt.Errorf("list locations: %w", err)
}
if len(locations) == 0 {
return 0, fmt.Errorf("no locations found for company %s", companyToken.CompanyID)
}
for _, loc := range locations {
locationIDs = append(locationIDs, loc.ID)
}
} else {
slog.Info("ghl bulk install: using installedLocations from token", "company_id", companyToken.CompanyID, "count", len(locationIDs))
}
installed := 0
for _, locID := range locationIDs {
locToken, err := h.exchangeForLocationToken(ctx, companyToken.AccessToken, companyToken.CompanyID, locID)
if err != nil {
slog.Warn("ghl location token exchange failed", "location_id", locID, "err", err)
continue
}
expiresAt := time.Now().Add(time.Duration(locToken.ExpiresIn) * time.Second)
record := &store.TokenRecord{
LocationID: locID,
CompanyID: companyToken.CompanyID,
AccessToken: locToken.AccessToken,
RefreshToken: locToken.RefreshToken,
ExpiresAt: expiresAt,
InstalledAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := h.store.SaveToken(ctx, record); err != nil {
slog.Warn("ghl location token save failed", "location_id", locID, "err", err)
continue
}
slog.Info("ghl location installed", "location_id", locID)
installed++
}
return installed, nil
}
// getCompanyLocations lists installed locations for a company using /oauth/installedLocations.
// This endpoint requires the oauth.readonly scope, which is present on all Company-level tokens.
func (h *OAuthHandler) getCompanyLocations(ctx context.Context, companyAccessToken, companyID string) ([]LocationInfo, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, ghlInstalledLocationsURL, nil)
if err != nil {
return nil, err
}
// appId expects the 24-hex MongoDB ObjectId portion of the client ID (before any "-" suffix).
appID := strings.SplitN(h.clientID, "-", 2)[0]
q := req.URL.Query()
q.Set("companyId", companyID)
q.Set("appId", appID)
q.Set("isInstalled", "true")
req.URL.RawQuery = q.Encode()
req.Header.Set("Authorization", "Bearer "+companyAccessToken)
req.Header.Set("Version", ghlLocationAPIVersion)
resp, err := h.httpClient.Do(req)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("installedLocations endpoint returned %d: %s", resp.StatusCode, string(body))
}
var locResp InstalledLocationsResponse
if err := json.Unmarshal(body, &locResp); err != nil {
return nil, fmt.Errorf("failed to parse installedLocations response: %w", err)
}
slog.Info("ghl installed locations fetched", "company_id", companyID, "count", len(locResp.Locations))
return locResp.Locations, nil
}
// exchangeForLocationToken converts a company-scoped token into a location-scoped token.
func (h *OAuthHandler) exchangeForLocationToken(ctx context.Context, companyAccessToken, companyID, locationID string) (*TokenResponse, error) {
payload := fmt.Sprintf(`{"companyId":%q,"locationId":%q}`, companyID, locationID)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, ghlLocationTokenURL, strings.NewReader(payload))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+companyAccessToken)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Version", ghlLocationAPIVersion)
resp, err := h.httpClient.Do(req)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return nil, fmt.Errorf("locationToken endpoint returned %d: %s", resp.StatusCode, string(body))
}
var tokenResp TokenResponse
if err := json.Unmarshal(body, &tokenResp); err != nil {
return nil, fmt.Errorf("failed to parse location token response: %w", err)
}
return &tokenResp, nil
}