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>
144 lines
4.5 KiB
Markdown
144 lines
4.5 KiB
Markdown
# 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`)
|
|
|
|
```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
|
|
|
|
```go
|
|
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`
|
|
|
|
```go
|
|
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`
|
|
|
|
```go
|
|
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)
|
|
|
|
```go
|
|
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
|
|
|
|
```go
|
|
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
|