fix: exchange company token for per-location tokens on bulk OAuth install
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed

GHL issues a Company-scoped token (userType=Company) for bulk/agency
installs even when Target User=Sub-account. This fix handles that case:

1. Detect userType=Company in HandleCallback
2. Call GET /locations/search to enumerate all company locations
3. For each location call POST /oauth/locationToken to get a
   Location-scoped token (userType=Location, includes locationId)
4. Store each location token individually in MongoDB

This allows webhook delivery and status updates to work per-location
without requiring the agency admin to re-install per sub-account.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Head of Product & Engineering 2026-04-06 10:02:42 +02:00
parent f01138474d
commit f97f31c8ac
2 changed files with 135 additions and 3 deletions

View File

@ -15,7 +15,12 @@ import (
"git.sds.dev/CAST/cast-ghl-plugin/internal/store"
)
const ghlTokenURL = "https://services.leadconnectorhq.com/oauth/token"
const (
ghlTokenURL = "https://services.leadconnectorhq.com/oauth/token"
ghlLocationTokenURL = "https://services.leadconnectorhq.com/oauth/locationToken"
ghlLocationsURL = "https://services.leadconnectorhq.com/locations/search"
ghlLocationAPIVersion = "2021-07-28"
)
// TokenStore is the interface OAuthHandler uses for token persistence.
type TokenStore interface {
@ -91,9 +96,25 @@ func (h *OAuthHandler) HandleCallback(w http.ResponseWriter, r *http.Request) {
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)
_, _ = fmt.Fprintf(w, `<!DOCTYPE html><html><body><h2>Cast SMS installed successfully!</h2><p>Connected %d location(s). You can close this tab.</p></body></html>`, 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 — ensure you selected a sub-account (Location), not an Agency, during authorization", http.StatusBadRequest)
http.Error(w, "GHL token response did not include a locationId", http.StatusBadRequest)
return
}
@ -203,6 +224,108 @@ func (h *OAuthHandler) postToken(ctx context.Context, data url.Values) (*TokenRe
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, "raw_body", string(body))
slog.Info("ghl token response fields", "location_id", tokenResp.LocationID, "company_id", tokenResp.CompanyID, "user_type", tokenResp.UserType)
return &tokenResp, nil
}
// installAllLocations fetches all locations for the company and exchanges the company token
// for a per-location token for each, storing them all. Returns the count installed.
func (h *OAuthHandler) installAllLocations(ctx context.Context, companyToken *TokenResponse) (int, error) {
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)
}
installed := 0
for _, loc := range locations {
locToken, err := h.exchangeForLocationToken(ctx, companyToken.AccessToken, companyToken.CompanyID, loc.ID)
if err != nil {
slog.Warn("ghl location token exchange failed", "location_id", loc.ID, "location_name", loc.Name, "err", err)
continue
}
expiresAt := time.Now().Add(time.Duration(locToken.ExpiresIn) * time.Second)
record := &store.TokenRecord{
LocationID: loc.ID,
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", loc.ID, "err", err)
continue
}
slog.Info("ghl location installed", "location_id", loc.ID, "location_name", loc.Name)
installed++
}
return installed, nil
}
// getCompanyLocations lists all locations for a company using the company-scoped token.
func (h *OAuthHandler) getCompanyLocations(ctx context.Context, companyAccessToken, companyID string) ([]LocationInfo, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, ghlLocationsURL, nil)
if err != nil {
return nil, err
}
q := req.URL.Query()
q.Set("companyId", companyID)
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("locations endpoint returned %d: %s", resp.StatusCode, string(body))
}
var locResp LocationsResponse
if err := json.Unmarshal(body, &locResp); err != nil {
return nil, fmt.Errorf("failed to parse locations response: %w", err)
}
slog.Info("ghl company 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 {
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
}

View File

@ -10,6 +10,15 @@ type TokenResponse struct {
UserType string `json:"userType"`
}
type LocationInfo struct {
ID string `json:"id"`
Name string `json:"name"`
}
type LocationsResponse struct {
Locations []LocationInfo `json:"locations"`
}
type OutboundMessageWebhook struct {
ContactID string `json:"contactId"`
LocationID string `json:"locationId"`