# 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