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

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