package router import ( "encoding/json" "fmt" "io" "net/http" "net/url" "os" "strconv" "strings" "time" "github.com/gin-gonic/gin" "github.com/gochat/gochat/internal/model" channelmodel "github.com/gochat/gochat/internal/model/channel" "github.com/gochat/gochat/internal/oauth/credentialstore" "github.com/golang-jwt/jwt/v5" "gorm.io/gorm" ) func googleEmailCallback(db *gorm.DB) gin.HandlerFunc { return emailOAuthCallback(db, emailCallbackConfig{ Provider: "google", ClientIDEnv: "GOOGLE_OAUTH_CLIENT_ID", SecretEnv: "GOOGLE_OAUTH_CLIENT_SECRET", TokenURLEnv: "GOOGLE_OAUTH_TOKEN_URL", DefaultURL: "https://oauth2.googleapis.com/token", IMAPAddress: "imap.gmail.com", StateSecretEnv: "GOOGLE_OAUTH_CLIENT_SECRET", }) } func microsoftEmailCallback(db *gorm.DB) gin.HandlerFunc { return emailOAuthCallback(db, emailCallbackConfig{ Provider: "microsoft", ClientIDEnv: "AZURE_APP_ID", SecretEnv: "AZURE_APP_SECRET", TokenURLEnv: "MICROSOFT_OAUTH_TOKEN_URL", DefaultURL: "https://login.microsoftonline.com/common/oauth2/v2.0/token", IMAPAddress: "outlook.office365.com", StateSecretEnv: "AZURE_APP_SECRET", }) } type emailCallbackConfig struct { Provider string ClientIDEnv string SecretEnv string TokenURLEnv string DefaultURL string IMAPAddress string StateSecretEnv string } func emailOAuthCallback(db *gorm.DB, cfg emailCallbackConfig) gin.HandlerFunc { return func(c *gin.Context) { accountID, ok := callbackAccountID(c.Query("state"), os.Getenv(cfg.StateSecretEnv)) if !ok || db == nil || c.Query("code") == "" || !accountExists(c, db, accountID) { c.Redirect(http.StatusFound, frontendBaseURL()) return } body, err := exchangeOAuthToken(c, oauthTokenExchangeRequest{ TokenURL: envOrDefault(cfg.TokenURLEnv, cfg.DefaultURL), ClientID: os.Getenv(cfg.ClientIDEnv), ClientSecret: os.Getenv(cfg.SecretEnv), Code: c.Query("code"), RedirectURI: frontendBaseURL() + "/" + cfg.Provider + "/callback", }) claims, claimErr := parseJWTClaimsUnverified(body["id_token"]) if err != nil || claimErr != nil || strings.TrimSpace(claimString(claims, "email")) == "" { c.Redirect(http.StatusFound, frontendBaseURL()) return } email := claimString(claims, "email") login := email if cfg.Provider == "microsoft" { if value := firstNonBlank(claimString(claims, "preferred_username"), claimString(claims, "upn")); value != "" { login = value } } name := firstNonBlank(claimString(claims, "name"), strings.Split(email, "@")[0]) inbox, existed, err := upsertEmailOAuthInbox(c, db, accountID, email, login, name, cfg.IMAPAddress, cfg.Provider, body) if err != nil { c.Redirect(http.StatusFound, frontendBaseURL()) return } if existed { c.Redirect(http.StatusFound, inboxSettingsURL(accountID, inbox.ID)) return } c.Redirect(http.StatusFound, inboxAgentsURL(accountID, inbox.ID)) } } func instagramChannelCallback(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { // First try to verify state with per-inbox credentials (nonce-based) // If that fails, fall back to env-based verification stateSecret := os.Getenv("INSTAGRAM_APP_SECRET") accountID, ok := callbackAccountID(c.Query("state"), stateSecret) if !ok { c.Redirect(http.StatusFound, frontendBaseURL()) return } if c.Query("error") != "" { c.Redirect(http.StatusFound, newInboxURL(accountID, "instagram", map[string]string{ "error_type": firstNonBlank(c.Query("error"), "authorization_error"), "code": "400", "error_message": firstNonBlank(c.Query("error_description"), "Authorization was denied"), })) return } if db == nil || c.Query("code") == "" || !accountExists(c, db, accountID) { c.Redirect(http.StatusFound, newInboxURL(accountID, "instagram", nil)) return } // Resolve per-inbox credentials: try nonce from state first, fall back to env appID, appSecret, credOK := resolveChannelOAuthCredentials(c.Query("state"), stateSecret, "INSTAGRAM_APP_ID", "INSTAGRAM_APP_SECRET") if !credOK { c.Redirect(http.StatusFound, newInboxURL(accountID, "instagram", map[string]string{"error_type": "OAuthException", "code": "500", "error_message": "Instagram OAuth credentials not configured"})) return } body, err := exchangeOAuthToken(c, oauthTokenExchangeRequest{ TokenURL: envOrDefault("INSTAGRAM_OAUTH_TOKEN_URL", "https://api.instagram.com/oauth/access_token"), ClientID: appID, ClientSecret: appSecret, Code: c.Query("code"), RedirectURI: frontendBaseURL() + "/instagram/callback", }) instagramID := firstNonBlank(body["instagram_account_id"], body["user_id"], body["id"]) username := firstNonBlank(body["username"], body["instagram_account_name"], "Instagram") if err != nil || body["access_token"] == "" || instagramID == "" { c.Redirect(http.StatusFound, newInboxURL(accountID, "instagram", map[string]string{"error_type": "OAuthException", "code": "400", "error_message": "failed to exchange OAuth token"})) return } inbox, existed, err := upsertInstagramInbox(c, db, accountID, instagramID, username, body) if err != nil { c.Redirect(http.StatusFound, newInboxURL(accountID, "instagram", map[string]string{"error_type": "RecordInvalid", "code": "500", "error_message": err.Error()})) return } if existed { c.Redirect(http.StatusFound, inboxSettingsURL(accountID, inbox.ID)) return } c.Redirect(http.StatusFound, inboxAgentsURL(accountID, inbox.ID)) } } func tiktokChannelCallback(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { stateSecret := os.Getenv("TIKTOK_APP_SECRET") accountID, ok := callbackAccountID(c.Query("state"), stateSecret) if !ok { c.Redirect(http.StatusFound, frontendBaseURL()) return } if c.Query("error") != "" { c.Redirect(http.StatusFound, newInboxURL(accountID, "tiktok", map[string]string{ "error_type": firstNonBlank(c.Query("error"), "access_denied"), "code": c.Query("error_code"), "error_message": firstNonBlank(c.Query("error_description"), "User cancelled the Authorization"), })) return } if db == nil || c.Query("code") == "" || !accountExists(c, db, accountID) { c.Redirect(http.StatusFound, newInboxURL(accountID, "tiktok", nil)) return } // Resolve per-inbox credentials: try nonce from state first, fall back to env appID, appSecret, credOK := resolveChannelOAuthCredentials(c.Query("state"), stateSecret, "TIKTOK_APP_ID", "TIKTOK_APP_SECRET") if !credOK { c.Redirect(http.StatusFound, newInboxURL(accountID, "tiktok", map[string]string{"error_type": "OAuthException", "code": "500", "error_message": "TikTok OAuth credentials not configured"})) return } body, err := exchangeOAuthToken(c, oauthTokenExchangeRequest{ TokenURL: envOrDefault("TIKTOK_OAUTH_TOKEN_URL", "https://business-api.tiktok.com/open_api/v1.3/oauth2/access_token/"), ClientID: appID, ClientSecret: appSecret, Code: c.Query("code"), RedirectURI: frontendBaseURL() + "/tiktok/callback", }) businessID := firstNonBlank(body["business_id"], body["tiktok_business_id"], body["advertiser_id"]) if err != nil || body["access_token"] == "" || businessID == "" { c.Redirect(http.StatusFound, newInboxURL(accountID, "tiktok", map[string]string{"error_type": "OAuthException", "code": "500", "error_message": "failed to exchange OAuth token"})) return } if !tiktokScopesGranted(body["scope"]) { c.Redirect(http.StatusFound, newInboxURL(accountID, "tiktok", map[string]string{"error_type": "ungranted_scopes", "code": "400", "error_message": "User did not grant all the required scopes"})) return } name := firstNonBlank(body["display_name"], body["username"], "TikTok") inbox, existed, err := upsertTikTokInbox(c, db, accountID, businessID, name, body) if err != nil { c.Redirect(http.StatusFound, newInboxURL(accountID, "tiktok", map[string]string{"error_type": "RecordInvalid", "code": "500", "error_message": err.Error()})) return } if existed { c.Redirect(http.StatusFound, inboxSettingsURL(accountID, inbox.ID)) return } c.Redirect(http.StatusFound, inboxAgentsURL(accountID, inbox.ID)) } } func twitterChannelCallback(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { accountID, ok := callbackAccountID(firstNonBlank(c.Query("state"), c.Query("account_id")), os.Getenv("TWITTER_CONSUMER_SECRET")) if !ok { c.Redirect(http.StatusFound, frontendBaseURL()) return } if c.Query("denied") != "" || db == nil || !accountExists(c, db, accountID) { c.Redirect(http.StatusFound, newInboxURL(accountID, "twitter", nil)) return } body, err := exchangeTwitterAccessToken(c) if err != nil || body.Get("oauth_token") == "" || body.Get("user_id") == "" { c.Redirect(http.StatusFound, newInboxURL(accountID, "twitter", nil)) return } inbox, existed, err := upsertTwitterInbox(c, db, accountID, body) if err != nil { c.Redirect(http.StatusFound, newInboxURL(accountID, "twitter", nil)) return } if existed { c.Redirect(http.StatusFound, inboxSettingsURL(accountID, inbox.ID)) return } c.Redirect(http.StatusFound, inboxAgentsURL(accountID, inbox.ID)) } } func upsertEmailOAuthInbox(c *gin.Context, db *gorm.DB, accountID uint, email, login, name, imapAddress, provider string, tokenBody map[string]string) (*model.Inbox, bool, error) { var channel channelmodel.ChannelEmail existed := db.WithContext(c.Request.Context()).Where("account_id = ? AND (imap_login = ? OR email = ?)", accountID, login, email).First(&channel).Error == nil if existed { channel.IMAPLogin = login channel.IMAPAddress = imapAddress channel.IMAPPort = 993 channel.IMAPEnabled = true channel.MailboxName = name if err := db.WithContext(c.Request.Context()).Save(&channel).Error; err != nil { return nil, false, err } var inbox model.Inbox if err := db.WithContext(c.Request.Context()).Where("id = ? AND account_id = ?", channel.InboxID, accountID).First(&inbox).Error; err != nil { return nil, false, err } inbox.Name = name inbox.ChannelConfig = oauthInboxConfig(provider, tokenBody) return &inbox, true, db.WithContext(c.Request.Context()).Save(&inbox).Error } channel = channelmodel.ChannelEmail{AccountID: accountID, Email: email, MailboxName: name, Domain: emailDomain(email), IMAPEnabled: true, IMAPAddress: imapAddress, IMAPPort: 993, IMAPLogin: login, IMAPSSLMode: "ssl"} if err := db.WithContext(c.Request.Context()).Create(&channel).Error; err != nil { return nil, false, err } inbox := model.Inbox{AccountID: accountID, Name: name, ChannelType: "email", ChannelID: channel.ID, Enabled: true, ChannelConfig: oauthInboxConfig(provider, tokenBody)} if err := db.WithContext(c.Request.Context()).Create(&inbox).Error; err != nil { return nil, false, err } channel.InboxID = inbox.ID return &inbox, false, db.WithContext(c.Request.Context()).Save(&channel).Error } func upsertInstagramInbox(c *gin.Context, db *gorm.DB, accountID uint, instagramID, username string, body map[string]string) (*model.Inbox, bool, error) { var channel channelmodel.ChannelInstagram existed := db.WithContext(c.Request.Context()).Where("account_id = ? AND instagram_account_id = ?", accountID, instagramID).First(&channel).Error == nil if existed { channel.PageAccessToken = body["access_token"] channel.InstagramAccountName = username if err := db.WithContext(c.Request.Context()).Save(&channel).Error; err != nil { return nil, false, err } var inbox model.Inbox if err := db.WithContext(c.Request.Context()).Where("id = ? AND account_id = ?", channel.InboxID, accountID).First(&inbox).Error; err != nil { return nil, false, err } inbox.Name = username inbox.ChannelConfig = instagramInboxConfig(channel) return &inbox, true, db.WithContext(c.Request.Context()).Save(&inbox).Error } channel = channelmodel.ChannelInstagram{AccountID: accountID, InstagramAccountID: instagramID, InstagramBusinessAccountID: body["instagram_business_account_id"], PageAccessToken: body["access_token"], ConnectedFBPageID: firstNonBlank(body["connected_fb_page_id"], body["page_id"]), InstagramAccountName: username} if channel.ConnectedFBPageID == "" { channel.ConnectedFBPageID = instagramID } if err := db.WithContext(c.Request.Context()).Create(&channel).Error; err != nil { return nil, false, err } inbox := model.Inbox{AccountID: accountID, Name: username, ChannelType: "instagram", ChannelID: channel.ID, Enabled: true, ChannelConfig: instagramInboxConfig(channel)} if err := db.WithContext(c.Request.Context()).Create(&inbox).Error; err != nil { return nil, false, err } channel.InboxID = inbox.ID return &inbox, false, db.WithContext(c.Request.Context()).Save(&channel).Error } func upsertTikTokInbox(c *gin.Context, db *gorm.DB, accountID uint, businessID, name string, body map[string]string) (*model.Inbox, bool, error) { var channel channelmodel.ChannelTikTok existed := db.WithContext(c.Request.Context()).Where("account_id = ? AND tiktok_business_id = ?", accountID, businessID).First(&channel).Error == nil expiresAt := time.Now().UTC().Add(time.Duration(parseIntDefault(body["expires_in"], 3600)) * time.Second) if existed { channel.AccessToken = body["access_token"] channel.RefreshToken = body["refresh_token"] channel.TokenExpiresAt = expiresAt if err := db.WithContext(c.Request.Context()).Save(&channel).Error; err != nil { return nil, false, err } var inbox model.Inbox if err := db.WithContext(c.Request.Context()).Where("id = ? AND account_id = ?", channel.InboxID, accountID).First(&inbox).Error; err != nil { return nil, false, err } inbox.Name = name inbox.ChannelConfig = tiktokInboxConfig(channel) return &inbox, true, db.WithContext(c.Request.Context()).Save(&inbox).Error } channel = channelmodel.ChannelTikTok{AccountID: accountID, TikTokBusinessID: businessID, AccessToken: body["access_token"], RefreshToken: body["refresh_token"], TokenExpiresAt: expiresAt} if err := db.WithContext(c.Request.Context()).Create(&channel).Error; err != nil { return nil, false, err } inbox := model.Inbox{AccountID: accountID, Name: name, ChannelType: "tiktok", ChannelID: channel.ID, Enabled: true, ChannelConfig: tiktokInboxConfig(channel)} if err := db.WithContext(c.Request.Context()).Create(&inbox).Error; err != nil { return nil, false, err } channel.InboxID = inbox.ID return &inbox, false, db.WithContext(c.Request.Context()).Save(&channel).Error } func upsertTwitterInbox(c *gin.Context, db *gorm.DB, accountID uint, values url.Values) (*model.Inbox, bool, error) { var channel channelmodel.ChannelTwitter existed := db.WithContext(c.Request.Context()).Where("account_id = ? AND twitter_user_id = ?", accountID, values.Get("user_id")).First(&channel).Error == nil name := firstNonBlank(values.Get("screen_name"), values.Get("name"), "Twitter") if existed { channel.TwitterAccessToken = values.Get("oauth_token") channel.TwitterAccessTokenSecret = values.Get("oauth_token_secret") channel.ScreenName = values.Get("screen_name") channel.Name = name if err := db.WithContext(c.Request.Context()).Save(&channel).Error; err != nil { return nil, false, err } var inbox model.Inbox if err := db.WithContext(c.Request.Context()).Where("id = ? AND account_id = ?", channel.InboxID, accountID).First(&inbox).Error; err != nil { return nil, false, err } inbox.Name = name inbox.ChannelConfig = twitterInboxConfig(channel) if err := db.WithContext(c.Request.Context()).Save(&inbox).Error; err != nil { return nil, false, err } return &inbox, true, nil } channel = channelmodel.ChannelTwitter{AccountID: accountID, TwitterUserID: values.Get("user_id"), TwitterAccessToken: values.Get("oauth_token"), TwitterAccessTokenSecret: values.Get("oauth_token_secret"), ScreenName: values.Get("screen_name"), Name: name, AccessToken: values.Get("oauth_token")} if err := db.WithContext(c.Request.Context()).Create(&channel).Error; err != nil { return nil, false, err } inbox := model.Inbox{AccountID: accountID, Name: name, ChannelType: "twitter", ChannelID: channel.ID, Enabled: true, ChannelConfig: twitterInboxConfig(channel)} if err := db.WithContext(c.Request.Context()).Create(&inbox).Error; err != nil { return nil, false, err } channel.InboxID = inbox.ID return &inbox, false, db.WithContext(c.Request.Context()).Save(&channel).Error } func parseJWTClaimsUnverified(tokenString string) (jwt.MapClaims, error) { claims := jwt.MapClaims{} _, _, err := jwt.NewParser().ParseUnverified(tokenString, claims) return claims, err } func claimString(claims jwt.MapClaims, key string) string { if value, ok := claims[key].(string); ok { return value } return "" } func oauthInboxConfig(provider string, tokenBody map[string]string) string { encoded, _ := json.Marshal(map[string]any{"provider": provider, "provider_config": compactStringMap(tokenBody)}) return string(encoded) } func instagramInboxConfig(channel channelmodel.ChannelInstagram) string { encoded, _ := json.Marshal(map[string]any{ "instagram_id": channel.InstagramAccountID, "instagram_account_id": channel.InstagramAccountID, "instagram_business_account_id": channel.InstagramBusinessAccountID, "instagram_account_name": channel.InstagramAccountName, "connected_fb_page_id": channel.ConnectedFBPageID, "page_access_token": channel.PageAccessToken, "reauthorization_required": channel.ReauthorizationRequired, }) return string(encoded) } func tiktokInboxConfig(channel channelmodel.ChannelTikTok) string { encoded, _ := json.Marshal(map[string]any{ "tiktok_business_id": channel.TikTokBusinessID, "access_token": channel.AccessToken, "refresh_token": channel.RefreshToken, "webhook_verify_token": channel.WebhookVerifyToken, "reauthorization_required": channel.ReauthorizationRequired, }) return string(encoded) } func twitterInboxConfig(channel channelmodel.ChannelTwitter) string { encoded, _ := json.Marshal(map[string]any{ "twitter_user_id": channel.TwitterUserID, "screen_name": channel.ScreenName, "twitter_access_token": channel.TwitterAccessToken, "twitter_access_token_secret": channel.TwitterAccessTokenSecret, "tweets_enabled": true, }) return string(encoded) } func compactStringMap(values map[string]string) map[string]string { result := make(map[string]string, len(values)) for key, value := range values { if value != "" { result[key] = value } } return result } func exchangeTwitterAccessToken(c *gin.Context) (url.Values, error) { form := url.Values{} form.Set("oauth_token", c.Query("oauth_token")) form.Set("oauth_verifier", c.Query("oauth_verifier")) req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, envOrDefault("TWITTER_OAUTH_TOKEN_URL", "https://api.twitter.com/oauth/access_token"), strings.NewReader(form.Encode())) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := http.DefaultClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { return nil, fmt.Errorf("twitter access token failed: %s", resp.Status) } body, err := io.ReadAll(resp.Body) if err != nil { return nil, err } return url.ParseQuery(string(body)) } func tiktokScopesGranted(scope string) bool { if strings.TrimSpace(scope) == "" { return true } granted := map[string]bool{} for _, item := range strings.Split(scope, ",") { granted[strings.TrimSpace(item)] = true } for _, required := range []string{"user.info.basic", "user.info.username", "user.info.stats", "user.info.profile", "user.account.type", "user.insights", "message.list.read", "message.list.send", "message.list.manage"} { if !granted[required] { return false } } return true } func inboxAgentsURL(accountID uint, inboxID uint) string { return fmt.Sprintf("%s/app/accounts/%d/settings/inboxes/new/%d/agents", frontendBaseURL(), accountID, inboxID) } func inboxSettingsURL(accountID uint, inboxID uint) string { return fmt.Sprintf("%s/app/accounts/%d/settings/inboxes/%d", frontendBaseURL(), accountID, inboxID) } func newInboxURL(accountID uint, channel string, query map[string]string) string { base := fmt.Sprintf("%s/app/accounts/%d/settings/inboxes/new/%s", frontendBaseURL(), accountID, channel) if len(query) == 0 { return base } values := url.Values{} for key, value := range query { if value != "" { values.Set(key, value) } } if len(values) == 0 { return base } return base + "?" + values.Encode() } func emailDomain(email string) string { parts := strings.SplitN(email, "@", 2) if len(parts) == 2 { return parts[1] } return "" } func parseIntDefault(value string, fallback int) int { parsed, err := strconv.Atoi(value) if err != nil { return fallback } return parsed } func firstNonBlank(values ...string) string { for _, value := range values { if strings.TrimSpace(value) != "" { return value } } return "" } // resolveChannelOAuthCredentials resolves per-inbox OAuth credentials from the // state JWT's nonce claim. If no nonce is present (legacy/env-based flow), it // falls back to environment variables. // Returns (appID, appSecret, ok). func resolveChannelOAuthCredentials(state string, stateSecret string, envAppID, envAppSecret string) (string, string, bool) { // Try per-inbox credentials via nonce in the JWT state if stateSecret != "" { claims := jwt.MapClaims{} token, err := jwt.ParseWithClaims(state, claims, func(token *jwt.Token) (any, error) { if token.Method != jwt.SigningMethodHS256 { return nil, fmt.Errorf("unexpected signing method") } return []byte(stateSecret), nil }) if err == nil && token.Valid { if nonce, ok := claims["nonce"].(string); ok && nonce != "" { if entry, found := credentialstore.Retrieve(nonce); found { return entry.AppID, entry.AppSecret, true } } } } // Fallback to environment variables (backward compatibility) appID := strings.TrimSpace(os.Getenv(envAppID)) appSecret := strings.TrimSpace(os.Getenv(envAppSecret)) if appID != "" && appSecret != "" { return appID, appSecret, true } return "", "", false }