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 UpdateSenderID(ctx context.Context, locationID, senderID string) 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(`

Cast SMS installed successfully!

Connected {{.}} location(s). You can close this tab.

`)) _ = 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, `

Cast SMS installed successfully!

You can close this tab.

`) } 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 }