cast-ghl-plugin/.claude/tasks/05-ghl-oauth.md
Head of Product & Engineering a40a4aa626
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
feat: initial implementation of Cast GHL Provider
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>
2026-04-04 17:27:05 +02:00

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

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)
  1. 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
    
  2. HTTP 302 redirect to the URL

HandleCallback — GET /oauth-callback

func (h *OAuthHandler) HandleCallback(w http.ResponseWriter, r *http.Request)
  1. Extract code from query params
  2. If no code: return 400 "missing authorization code"
  3. 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
    
  4. Parse TokenResponse
  5. Calculate expires_at = time.Now().Add(time.Duration(resp.ExpiresIn) * time.Second)
  6. Save to MongoDB via store.SaveToken()
  7. Return 200 with success HTML page (simple "Cast SMS installed successfully! You can close this tab.")
  8. 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)
  1. Get current token from store
  2. If not found: return error "no token for location"
  3. POST to token endpoint with grant_type=refresh_token
    POST 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}
    
  4. Parse response
  5. Update token in store via store.UpdateToken()
  6. Return updated record

GetValidToken — internal method

func (h *OAuthHandler) GetValidToken(ctx context.Context, locationID string) (string, error)
  1. Get token from store
  2. If not found: return error
  3. If expires_at is within 5 minutes of now: call RefreshToken first
  4. 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)
  • ExpiresIn is 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
  • /install redirects to correct GHL authorization URL with all scopes
  • /oauth-callback exchanges code for tokens via POST
  • Token exchange uses application/x-www-form-urlencoded content type
  • Tokens stored in MongoDB keyed by locationId
  • RefreshToken sends refresh_token grant and updates store
  • GetValidToken auto-refreshes if within 5 minutes of expiry
  • Missing code in callback returns 400
  • Token exchange failure returns 500
  • All operations use context.Context