Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Complete MVP implementation of the Cast GHL Conversation Provider bridge: - Go module setup with chi router and mongo-driver dependencies - Config loading with env var validation and defaults - MongoDB token store with upsert, get, update, delete operations - Cast.ph SMS client with 429 retry logic and typed errors - Phone number normalization (E.164 ↔ Philippine local format) - GHL OAuth 2.0 install/callback/refresh flow - GHL webhook handler with ECDSA signature verification (async dispatch) - GHL API client for message status updates and inbound message stubs - Multi-stage Dockerfile, docker-compose with MongoDB, Woodpecker CI pipeline - Unit tests for phone normalization, Cast client, GHL webhook, and OAuth handlers Co-Authored-By: SideKx <sidekx.ai@sds.dev>
4.5 KiB
4.5 KiB
Task 05: GHL OAuth Flow
Objective
Build internal/ghl/oauth.go — handles the OAuth 2.0 install flow for GHL Marketplace apps.
Reference
- GHL OAuth docs: https://marketplace.gohighlevel.com/docs/Authorization/authorization_doc
- selfhostsim
ghl/service (architectural reference)
Flow
1. Agency admin visits GET /install
2. Redirect to GHL authorization URL
3. User approves, GHL redirects to GET /oauth-callback?code=xxx
4. Bridge exchanges code for access_token + refresh_token
5. Store tokens in MongoDB keyed by locationId
6. Redirect user to success page or GHL
Types (internal/ghl/types.go)
type TokenResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"` // seconds
TokenType string `json:"token_type"`
LocationID string `json:"locationId"`
CompanyID string `json:"companyId"`
UserType string `json:"userType"`
}
OAuthHandler struct
type OAuthHandler struct {
clientID string
clientSecret string
baseURL string // public URL of this service
providerID string
store *store.Store
}
func NewOAuthHandler(clientID, clientSecret, baseURL, providerID string, store *store.Store) *OAuthHandler
Handlers
HandleInstall — GET /install
func (h *OAuthHandler) HandleInstall(w http.ResponseWriter, r *http.Request)
- Build GHL authorization URL:
https://marketplace.gohighlevel.com/oauth/chooselocation? response_type=code& redirect_uri={BASE_URL}/oauth-callback& client_id={GHL_CLIENT_ID}& scope=conversations/message.write conversations/message.readonly conversations.write conversations.readonly contacts.readonly contacts.write - HTTP 302 redirect to the URL
HandleCallback — GET /oauth-callback
func (h *OAuthHandler) HandleCallback(w http.ResponseWriter, r *http.Request)
- Extract
codefrom query params - If no code: return 400 "missing authorization code"
- Exchange code for tokens:
POST https://services.leadconnectorhq.com/oauth/token Content-Type: application/x-www-form-urlencoded client_id={CLIENT_ID}& client_secret={CLIENT_SECRET}& grant_type=authorization_code& code={CODE}& redirect_uri={BASE_URL}/oauth-callback - Parse
TokenResponse - Calculate
expires_at=time.Now().Add(time.Duration(resp.ExpiresIn) * time.Second) - Save to MongoDB via
store.SaveToken() - Return 200 with success HTML page (simple "Cast SMS installed successfully! You can close this tab.")
- On any error: return 500 with error message
RefreshToken — internal method (not an HTTP handler)
func (h *OAuthHandler) RefreshToken(ctx context.Context, locationID string) (*store.TokenRecord, error)
- Get current token from store
- If not found: return error "no token for location"
- POST to token endpoint with
grant_type=refresh_tokenPOST https://services.leadconnectorhq.com/oauth/token Content-Type: application/x-www-form-urlencoded client_id={CLIENT_ID}& client_secret={CLIENT_SECRET}& grant_type=refresh_token& refresh_token={REFRESH_TOKEN} - Parse response
- Update token in store via
store.UpdateToken() - Return updated record
GetValidToken — internal method
func (h *OAuthHandler) GetValidToken(ctx context.Context, locationID string) (string, error)
- Get token from store
- If not found: return error
- If
expires_atis within 5 minutes of now: callRefreshTokenfirst - Return the (possibly refreshed) access token
Key behaviors
- GHL API base for token exchange:
https://services.leadconnectorhq.com - Content-Type for token exchange is
application/x-www-form-urlencoded(NOT JSON) ExpiresInis typically 86400 (24 hours)- Refresh token before it expires (5-minute buffer)
- Store tokens per
locationId— each GHL sub-account has its own - Log all OAuth events with
slog.Info(install, callback, refresh)
Acceptance Criteria
go build ./cmd/server/succeeds/installredirects to correct GHL authorization URL with all scopes/oauth-callbackexchanges code for tokens via POST- Token exchange uses
application/x-www-form-urlencodedcontent type - Tokens stored in MongoDB keyed by locationId
RefreshTokensends refresh_token grant and updates storeGetValidTokenauto-refreshes if within 5 minutes of expiry- Missing code in callback returns 400
- Token exchange failure returns 500
- All operations use context.Context