mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-11 00:05:01 +00:00
692 lines
24 KiB
Go
692 lines
24 KiB
Go
package integration
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/warmbly/warmbly/internal/app/cipher"
|
|
"github.com/warmbly/warmbly/internal/app/webhook"
|
|
"github.com/warmbly/warmbly/internal/models"
|
|
"github.com/warmbly/warmbly/internal/repository"
|
|
)
|
|
|
|
// oauthStateTTL bounds how long a started OAuth handshake stays valid.
|
|
const oauthStateTTL = 15 * time.Minute
|
|
|
|
// ErrOAuthNotConfigured is returned when a provider's OAuth client credentials
|
|
// are not present in the environment.
|
|
var ErrOAuthNotConfigured = errors.New("oauth is not configured for this provider")
|
|
|
|
// ErrUseOAuth is returned when a caller tries to paste credentials for a
|
|
// provider that should be connected via the OAuth handshake instead.
|
|
var ErrUseOAuth = errors.New("this provider connects via OAuth; start the authorize flow instead")
|
|
|
|
// Service exposes the integration surface the dashboard and event pipeline
|
|
// talk to. Provider-specific behaviour (OAuth identity, event actions, inbound
|
|
// webhooks) lives in the per-provider files in this package.
|
|
type Service interface {
|
|
Catalog() []models.IntegrationCatalogEntry
|
|
ListConnections(ctx context.Context, orgID uuid.UUID) ([]models.IntegrationConnection, error)
|
|
GetConnection(ctx context.Context, orgID, id uuid.UUID) (*models.IntegrationConnection, error)
|
|
|
|
// Connect registers a credential-based connection (api-key / webhook-URL
|
|
// providers). OAuth providers must use OAuthStart instead. The config map's
|
|
// secret values are sealed with the connecting user's envelope DEK before
|
|
// they touch the database.
|
|
Connect(ctx context.Context, orgID, userID uuid.UUID, provider models.IntegrationProvider, label string, config map[string]any) (*models.IntegrationConnection, error)
|
|
Disconnect(ctx context.Context, orgID, id uuid.UUID) error
|
|
|
|
// OAuthStart returns the provider authorization URL for a one-click connect.
|
|
OAuthStart(ctx context.Context, orgID, userID uuid.UUID, provider models.IntegrationProvider, label string) (*models.IntegrationOAuthStartResponse, error)
|
|
// OAuthFinish completes the handshake: validates state, exchanges the code,
|
|
// resolves the account identity, and persists encrypted tokens.
|
|
OAuthFinish(ctx context.Context, userID uuid.UUID, code, state string) (*models.IntegrationConnection, error)
|
|
// Reauth starts a fresh OAuth handshake for an existing connection whose
|
|
// token expired or was revoked.
|
|
Reauth(ctx context.Context, orgID, userID, id uuid.UUID) (*models.IntegrationOAuthStartResponse, error)
|
|
|
|
// RotateInboundSecret regenerates the inbound URL secret (Calendly/Cal.com).
|
|
RotateInboundSecret(ctx context.Context, orgID, id uuid.UUID, provider models.IntegrationProvider) (string, error)
|
|
|
|
// Event subscriptions wire a Warmbly event to a provider action.
|
|
ListEventSubscriptions(ctx context.Context, orgID, connID uuid.UUID) ([]models.IntegrationEventSubscription, error)
|
|
CreateEventSubscription(ctx context.Context, orgID, connID uuid.UUID, eventType string, action models.IntegrationAction, config map[string]any, enabled bool) (*models.IntegrationEventSubscription, error)
|
|
DeleteEventSubscription(ctx context.Context, orgID, id uuid.UUID) error
|
|
|
|
// ListSyncRuns returns recent observability records for a connection.
|
|
ListSyncRuns(ctx context.Context, orgID, connID uuid.UUID, limit int) ([]models.IntegrationSyncRun, error)
|
|
|
|
// MarkSynced records a successful/failed round-trip against a connection.
|
|
MarkSynced(ctx context.Context, id uuid.UUID, status models.IntegrationStatus, displayFields map[string]any, errMsg string) error
|
|
|
|
// --- Google Sheets read helpers (used by the lead-sync feature) ---------
|
|
// These expose the existing google_sheets OAuth token + Sheets client to
|
|
// the leadsync package without leaking secret handling out of this service.
|
|
|
|
// GoogleConnection returns the org's google_sheets OAuth connection, or nil
|
|
// if the org has not connected Google. Even though google_sheets is hidden
|
|
// from the integrations catalog, the underlying OAuth connection still lives
|
|
// in integration_connections — this is how lead-sync finds it.
|
|
GoogleConnection(ctx context.Context, orgID uuid.UUID) (*models.IntegrationConnection, error)
|
|
// SpreadsheetMeta returns the sheet's title + tabs using the connection's
|
|
// (refreshed) Google token.
|
|
SpreadsheetMeta(ctx context.Context, orgID, connID uuid.UUID, sheetID string) (*SheetMeta, error)
|
|
// SpreadsheetValues reads an A1 range from the sheet using the connection's
|
|
// (refreshed) Google token.
|
|
SpreadsheetValues(ctx context.Context, orgID, connID uuid.UUID, sheetID, a1Range string) ([][]string, error)
|
|
|
|
// Dispatch fans a platform event out to every matching event subscription,
|
|
// executing each provider action. Best-effort: action failures are recorded
|
|
// on the connection's health but never block the caller.
|
|
Dispatch(ctx context.Context, orgID uuid.UUID, eventType models.WebhookEventType, data map[string]any)
|
|
|
|
// DispatchAny is the loosely-typed adapter wired into the webhook fan-out
|
|
// sink. It forwards only map-shaped payloads (the common event shape) to
|
|
// Dispatch; struct payloads are ignored.
|
|
DispatchAny(ctx context.Context, orgID uuid.UUID, eventType models.WebhookEventType, data any)
|
|
|
|
// Repo exposes the underlying repository for the inbound webhook handlers.
|
|
Repo() repository.IntegrationRepository
|
|
}
|
|
|
|
type service struct {
|
|
repo repository.IntegrationRepository
|
|
cipher cipher.CipherService
|
|
oauth *OAuthManager
|
|
}
|
|
|
|
// NewService builds the integration service. cipherSvc seals provider secrets
|
|
// with the connecting user's envelope DEK; oauth drives the OAuth handshakes.
|
|
func NewService(repo repository.IntegrationRepository, cipherSvc cipher.CipherService, oauth *OAuthManager) Service {
|
|
if oauth == nil {
|
|
oauth = NewOAuthManager()
|
|
}
|
|
return &service{repo: repo, cipher: cipherSvc, oauth: oauth}
|
|
}
|
|
|
|
func (s *service) Repo() repository.IntegrationRepository { return s.repo }
|
|
|
|
func (s *service) Catalog() []models.IntegrationCatalogEntry {
|
|
entries := Catalog()
|
|
for i := range entries {
|
|
e := &entries[i]
|
|
if e.AuthMethod == string(models.IntegrationAuthOAuth) {
|
|
e.Configured = s.oauth.Configured(e.Provider)
|
|
if len(e.Scopes) == 0 {
|
|
e.Scopes = s.oauth.Scopes(e.Provider)
|
|
}
|
|
} else {
|
|
// api-key and webhook providers are always usable.
|
|
e.Configured = true
|
|
}
|
|
}
|
|
return entries
|
|
}
|
|
|
|
func (s *service) ListConnections(ctx context.Context, orgID uuid.UUID) ([]models.IntegrationConnection, error) {
|
|
conns, err := s.repo.ListConnections(ctx, orgID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Google Sheets is no longer a catalog integration — its OAuth connection
|
|
// exists only to power the on-demand Lead Sync feature (see
|
|
// internal/app/leadsync). Hide it from the Integrations page so it doesn't
|
|
// render as an integration tile. GoogleConnection() still reaches it via the
|
|
// repository directly.
|
|
out := conns[:0]
|
|
for _, c := range conns {
|
|
if c.Provider == models.IntegrationGoogleSheets {
|
|
continue
|
|
}
|
|
out = append(out, c)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *service) GetConnection(ctx context.Context, orgID, id uuid.UUID) (*models.IntegrationConnection, error) {
|
|
return s.repo.GetConnectionByID(ctx, orgID, id)
|
|
}
|
|
|
|
func (s *service) Connect(ctx context.Context, orgID, userID uuid.UUID, provider models.IntegrationProvider, label string, config map[string]any) (*models.IntegrationConnection, error) {
|
|
if !models.IsValidIntegrationProvider(string(provider)) {
|
|
return nil, fmt.Errorf("unknown provider: %s", provider)
|
|
}
|
|
authMethod := catalogAuthMethod(provider)
|
|
if authMethod == string(models.IntegrationAuthOAuth) {
|
|
// Credential pasting is not allowed for OAuth providers.
|
|
return nil, ErrUseOAuth
|
|
}
|
|
|
|
label = strings.TrimSpace(label)
|
|
if label == "" {
|
|
label = string(provider)
|
|
}
|
|
|
|
// SSRF guard: any user-supplied outbound URL we'll later POST to must be
|
|
// HTTPS + publicly routable, matching the customer-webhook policy.
|
|
if err := validateOutboundConfigURLs(config); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
displayFields := buildDisplayFields(provider, config)
|
|
|
|
var inboundSecret string
|
|
var err error
|
|
if provider == models.IntegrationCalendly || provider == models.IntegrationCalCom {
|
|
inboundSecret, err = generateInboundSecret(provider)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
configEnc, err := s.sealConfig(ctx, userID, config)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
status := models.IntegrationStatusPending
|
|
switch provider {
|
|
case models.IntegrationCalendly, models.IntegrationCalCom:
|
|
// Inbound providers are "connected" once the URL exists.
|
|
status = models.IntegrationStatusConnected
|
|
default:
|
|
if hasAnyCredential(config) {
|
|
status = models.IntegrationStatusConnected
|
|
}
|
|
}
|
|
|
|
df, _ := json.Marshal(displayFields)
|
|
conn := &models.IntegrationConnection{
|
|
OrganizationID: orgID,
|
|
Provider: provider,
|
|
Label: label,
|
|
Status: status,
|
|
AuthMethod: authMethod,
|
|
DisplayFields: df,
|
|
ConnectedByUserID: &userID,
|
|
Health: string(models.IntegrationHealthUnknown),
|
|
}
|
|
if status == models.IntegrationStatusConnected {
|
|
conn.Health = string(models.IntegrationHealthHealthy)
|
|
now := time.Now().UTC()
|
|
conn.HealthCheckedAt = &now
|
|
}
|
|
|
|
if err := s.repo.UpsertConnection(ctx, &repository.ConnectionWrite{
|
|
Conn: conn,
|
|
ConfigEncrypted: configEnc,
|
|
InboundSecret: inboundSecret,
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if inboundSecret != "" {
|
|
conn.InboundWebhookURL = BuildInboundURL(provider, inboundSecret)
|
|
}
|
|
return conn, nil
|
|
}
|
|
|
|
func (s *service) Disconnect(ctx context.Context, orgID, id uuid.UUID) error {
|
|
return s.repo.DeleteConnection(ctx, orgID, id)
|
|
}
|
|
|
|
func (s *service) OAuthStart(ctx context.Context, orgID, userID uuid.UUID, provider models.IntegrationProvider, label string) (*models.IntegrationOAuthStartResponse, error) {
|
|
if !s.oauth.Configured(provider) {
|
|
return nil, ErrOAuthNotConfigured
|
|
}
|
|
state := randomURLToken(24)
|
|
authURL, verifier, err := s.oauth.AuthCodeURL(provider, state)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
st := &models.IntegrationOAuthState{
|
|
OrganizationID: orgID,
|
|
UserID: userID,
|
|
Provider: provider,
|
|
State: state,
|
|
CodeVerifier: verifier,
|
|
Label: strings.TrimSpace(label),
|
|
RequestedScopes: s.oauth.Scopes(provider),
|
|
ExpiresAt: time.Now().UTC().Add(oauthStateTTL),
|
|
}
|
|
if err := s.repo.CreateOAuthState(ctx, st); err != nil {
|
|
return nil, err
|
|
}
|
|
return &models.IntegrationOAuthStartResponse{URL: authURL, State: state}, nil
|
|
}
|
|
|
|
func (s *service) OAuthFinish(ctx context.Context, userID uuid.UUID, code, state string) (*models.IntegrationConnection, error) {
|
|
code = strings.TrimSpace(code)
|
|
state = strings.TrimSpace(state)
|
|
if code == "" || state == "" {
|
|
return nil, errors.New("missing code or state")
|
|
}
|
|
|
|
st, err := s.repo.TakeOAuthState(ctx, state)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if st == nil {
|
|
return nil, errors.New("invalid or expired oauth state")
|
|
}
|
|
if st.UserID != userID {
|
|
return nil, errors.New("oauth state does not belong to this user")
|
|
}
|
|
|
|
tokens, account, err := s.oauth.Exchange(ctx, st.Provider, code, st.CodeVerifier)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
accessEnc, err := s.seal(ctx, userID, tokens.AccessToken)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
refreshEnc, err := s.seal(ctx, userID, tokens.RefreshToken)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
label := st.Label
|
|
if label == "" {
|
|
label = string(st.Provider)
|
|
}
|
|
|
|
display := map[string]any{}
|
|
if account.Name != "" {
|
|
display["account"] = account.Name
|
|
}
|
|
df, _ := json.Marshal(display)
|
|
|
|
now := time.Now().UTC()
|
|
conn := &models.IntegrationConnection{
|
|
OrganizationID: st.OrganizationID,
|
|
Provider: st.Provider,
|
|
Label: label,
|
|
Status: models.IntegrationStatusConnected,
|
|
AuthMethod: string(models.IntegrationAuthOAuth),
|
|
DisplayFields: df,
|
|
ConnectedByUserID: &userID,
|
|
ExternalAccountID: account.ID,
|
|
ExternalAccountName: account.Name,
|
|
GrantedScopes: tokens.Scopes,
|
|
TokenExpiresAt: tokens.ExpiresAt,
|
|
Health: string(models.IntegrationHealthHealthy),
|
|
HealthCheckedAt: &now,
|
|
}
|
|
if err := s.repo.UpsertConnection(ctx, &repository.ConnectionWrite{
|
|
Conn: conn,
|
|
AccessTokenEnc: accessEnc,
|
|
RefreshTokenEnc: refreshEnc,
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Re-read so the caller gets the canonical row (id, timestamps).
|
|
stored, err := s.repo.GetConnection(ctx, st.OrganizationID, st.Provider, label)
|
|
if err == nil && stored != nil {
|
|
_ = s.repo.CreateSyncRun(ctx, &models.IntegrationSyncRun{
|
|
ConnectionID: stored.ID,
|
|
OrganizationID: st.OrganizationID,
|
|
Kind: "oauth_connect",
|
|
Status: "success",
|
|
Detail: "authorized " + string(st.Provider),
|
|
})
|
|
return stored, nil
|
|
}
|
|
return conn, nil
|
|
}
|
|
|
|
func (s *service) Reauth(ctx context.Context, orgID, userID, id uuid.UUID) (*models.IntegrationOAuthStartResponse, error) {
|
|
conn, err := s.repo.GetConnectionByID(ctx, orgID, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if conn == nil {
|
|
return nil, errors.New("connection not found")
|
|
}
|
|
if conn.AuthMethod != string(models.IntegrationAuthOAuth) {
|
|
return nil, ErrUseOAuth
|
|
}
|
|
_ = s.repo.SetConnectionStatus(ctx, id, models.IntegrationStatusAuthorizing, models.IntegrationHealthDegraded, "reauthorizing")
|
|
return s.OAuthStart(ctx, orgID, userID, conn.Provider, conn.Label)
|
|
}
|
|
|
|
func (s *service) RotateInboundSecret(ctx context.Context, orgID, id uuid.UUID, provider models.IntegrationProvider) (string, error) {
|
|
secret, err := generateInboundSecret(provider)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
conn := &models.IntegrationConnection{
|
|
ID: id,
|
|
OrganizationID: orgID,
|
|
Provider: provider,
|
|
Status: models.IntegrationStatusConnected,
|
|
AuthMethod: string(models.IntegrationAuthWebhook),
|
|
Health: string(models.IntegrationHealthHealthy),
|
|
}
|
|
if err := s.repo.UpsertConnection(ctx, &repository.ConnectionWrite{Conn: conn, InboundSecret: secret}); err != nil {
|
|
return "", err
|
|
}
|
|
return BuildInboundURL(provider, secret), nil
|
|
}
|
|
|
|
func (s *service) ListEventSubscriptions(ctx context.Context, orgID, connID uuid.UUID) ([]models.IntegrationEventSubscription, error) {
|
|
return s.repo.ListEventSubscriptions(ctx, orgID, connID)
|
|
}
|
|
|
|
func (s *service) CreateEventSubscription(ctx context.Context, orgID, connID uuid.UUID, eventType string, action models.IntegrationAction, config map[string]any, enabled bool) (*models.IntegrationEventSubscription, error) {
|
|
conn, err := s.repo.GetConnectionByID(ctx, orgID, connID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if conn == nil {
|
|
return nil, errors.New("connection not found")
|
|
}
|
|
if !models.IsValidWebhookEventType(eventType) {
|
|
return nil, fmt.Errorf("unknown event type: %s", eventType)
|
|
}
|
|
// SSRF guard for action configs that carry an outbound URL.
|
|
if err := validateOutboundConfigURLs(config); err != nil {
|
|
return nil, err
|
|
}
|
|
cfg, _ := json.Marshal(config)
|
|
sub := &models.IntegrationEventSubscription{
|
|
ConnectionID: connID,
|
|
OrganizationID: orgID,
|
|
EventType: eventType,
|
|
Action: action,
|
|
Config: cfg,
|
|
Enabled: enabled,
|
|
}
|
|
if err := s.repo.CreateEventSubscription(ctx, sub); err != nil {
|
|
return nil, err
|
|
}
|
|
return sub, nil
|
|
}
|
|
|
|
func (s *service) DeleteEventSubscription(ctx context.Context, orgID, id uuid.UUID) error {
|
|
return s.repo.DeleteEventSubscription(ctx, orgID, id)
|
|
}
|
|
|
|
func (s *service) ListSyncRuns(ctx context.Context, orgID, connID uuid.UUID, limit int) ([]models.IntegrationSyncRun, error) {
|
|
return s.repo.ListSyncRuns(ctx, orgID, connID, limit)
|
|
}
|
|
|
|
func (s *service) MarkSynced(ctx context.Context, id uuid.UUID, status models.IntegrationStatus, displayFields map[string]any, errMsg string) error {
|
|
df, _ := json.Marshal(displayFields)
|
|
return s.repo.MarkConnectionSynced(ctx, id, status, df, errMsg)
|
|
}
|
|
|
|
// --- Google Sheets read helpers ---------------------------------------------
|
|
|
|
// GoogleConnection returns the org's google_sheets OAuth connection (the most
|
|
// recent one), or nil when none exists. Lead-sync uses this connection's token
|
|
// to read sheets even though google_sheets is no longer surfaced as a catalog
|
|
// integration.
|
|
func (s *service) GoogleConnection(ctx context.Context, orgID uuid.UUID) (*models.IntegrationConnection, error) {
|
|
conns, err := s.repo.ListConnections(ctx, orgID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for i := range conns {
|
|
if conns[i].Provider == models.IntegrationGoogleSheets {
|
|
c := conns[i]
|
|
return &c, nil
|
|
}
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
// googleSheetsClient resolves the (refreshed) Google access token for a
|
|
// connection and returns a ready Sheets client. The connection must belong to
|
|
// orgID and be the google_sheets provider.
|
|
func (s *service) googleSheetsClient(ctx context.Context, orgID, connID uuid.UUID) (*SheetsClient, error) {
|
|
conn, err := s.repo.GetConnectionByID(ctx, orgID, connID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if conn == nil {
|
|
return nil, errors.New("connection not found")
|
|
}
|
|
if conn.Provider != models.IntegrationGoogleSheets {
|
|
return nil, errors.New("connection is not a google_sheets connection")
|
|
}
|
|
sec, err := s.repo.GetConnectionSecrets(ctx, connID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if sec == nil {
|
|
return nil, errors.New("connection secrets not found")
|
|
}
|
|
token, err := s.accessTokenFor(ctx, sec)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return NewSheetsClient(token), nil
|
|
}
|
|
|
|
func (s *service) SpreadsheetMeta(ctx context.Context, orgID, connID uuid.UUID, sheetID string) (*SheetMeta, error) {
|
|
client, err := s.googleSheetsClient(ctx, orgID, connID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return client.GetSpreadsheet(ctx, sheetID)
|
|
}
|
|
|
|
func (s *service) SpreadsheetValues(ctx context.Context, orgID, connID uuid.UUID, sheetID, a1Range string) ([][]string, error) {
|
|
client, err := s.googleSheetsClient(ctx, orgID, connID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return client.ReadValues(ctx, sheetID, a1Range)
|
|
}
|
|
|
|
// --- encryption helpers -----------------------------------------------------
|
|
|
|
func (s *service) seal(ctx context.Context, userID uuid.UUID, plaintext string) (string, error) {
|
|
if plaintext == "" {
|
|
return "", nil
|
|
}
|
|
if s.cipher == nil {
|
|
return "", errors.New("cipher service unavailable")
|
|
}
|
|
c, err := s.cipher.Cipher(ctx, userID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return c.Encrypt(ctx, plaintext)
|
|
}
|
|
|
|
func (s *service) open(ctx context.Context, userID uuid.UUID, ciphertext string) (string, error) {
|
|
if ciphertext == "" {
|
|
return "", nil
|
|
}
|
|
if s.cipher == nil {
|
|
return "", errors.New("cipher service unavailable")
|
|
}
|
|
c, err := s.cipher.Cipher(ctx, userID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return c.Decrypt(ctx, ciphertext)
|
|
}
|
|
|
|
func (s *service) sealConfig(ctx context.Context, userID uuid.UUID, config map[string]any) ([]byte, error) {
|
|
if len(config) == 0 {
|
|
return nil, nil
|
|
}
|
|
raw, err := json.Marshal(config)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
b64, err := s.seal(ctx, userID, string(raw))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return []byte(b64), nil
|
|
}
|
|
|
|
func (s *service) openConfig(ctx context.Context, sec *repository.ConnectionSecrets) (map[string]any, error) {
|
|
if len(sec.ConfigEncrypted) == 0 || sec.Conn.ConnectedByUserID == nil {
|
|
return map[string]any{}, nil
|
|
}
|
|
plain, err := s.open(ctx, *sec.Conn.ConnectedByUserID, string(sec.ConfigEncrypted))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := map[string]any{}
|
|
if plain == "" {
|
|
return out, nil
|
|
}
|
|
if err := json.Unmarshal([]byte(plain), &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// accessTokenFor decrypts the connection's access token, refreshing via the
|
|
// stored refresh token when near expiry and persisting the refreshed pair.
|
|
// On an unrecoverable refresh failure it flips the connection to
|
|
// reauth_required and returns an error.
|
|
func (s *service) accessTokenFor(ctx context.Context, sec *repository.ConnectionSecrets) (string, error) {
|
|
if sec.Conn.ConnectedByUserID == nil {
|
|
return "", errors.New("connection has no owning user for decryption")
|
|
}
|
|
userID := *sec.Conn.ConnectedByUserID
|
|
|
|
access, err := s.open(ctx, userID, sec.AccessTokenEnc)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
refresh, err := s.open(ctx, userID, sec.RefreshTokenEnc)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
current := models.IntegrationTokens{
|
|
AccessToken: access,
|
|
RefreshToken: refresh,
|
|
ExpiresAt: sec.Conn.TokenExpiresAt,
|
|
Scopes: sec.Conn.GrantedScopes,
|
|
}
|
|
refreshed, didRefresh, rerr := s.oauth.RefreshIfNeeded(ctx, sec.Conn.Provider, current)
|
|
if rerr != nil {
|
|
_ = s.repo.SetConnectionStatus(ctx, sec.Conn.ID, models.IntegrationStatusReauthRequired, models.IntegrationHealthDown, "token refresh failed: reconnect required")
|
|
return "", rerr
|
|
}
|
|
if didRefresh {
|
|
accessEnc, _ := s.seal(ctx, userID, refreshed.AccessToken)
|
|
refreshEnc, _ := s.seal(ctx, userID, refreshed.RefreshToken)
|
|
_ = s.repo.UpdateConnectionTokens(ctx, sec.Conn.ID, accessEnc, refreshEnc, refreshed.ExpiresAt, refreshed.Scopes)
|
|
return refreshed.AccessToken, nil
|
|
}
|
|
return refreshed.AccessToken, nil
|
|
}
|
|
|
|
// --- shared helpers ---------------------------------------------------------
|
|
|
|
// validateOutboundConfigURLs enforces the SSRF/HTTPS policy on any config value
|
|
// under a url-bearing key (webhook_url / url) we will later POST to.
|
|
func validateOutboundConfigURLs(config map[string]any) error {
|
|
for _, k := range []string{"webhook_url", "url"} {
|
|
v, ok := config[k]
|
|
if !ok {
|
|
continue
|
|
}
|
|
s, ok := v.(string)
|
|
if !ok || strings.TrimSpace(s) == "" {
|
|
continue
|
|
}
|
|
if err := webhook.ValidateOutboundURL(s); err != nil {
|
|
return fmt.Errorf("%s: %w", k, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func hasAnyCredential(config map[string]any) bool {
|
|
for _, k := range []string{"api_token", "access_token", "webhook_url", "api_key"} {
|
|
if v, ok := config[k]; ok {
|
|
if s, ok := v.(string); ok && strings.TrimSpace(s) != "" {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func catalogAuthMethod(provider models.IntegrationProvider) string {
|
|
for _, e := range Catalog() {
|
|
if e.Provider == provider {
|
|
return e.AuthMethod
|
|
}
|
|
}
|
|
return string(models.IntegrationAuthAPIKey)
|
|
}
|
|
|
|
// generateInboundSecret returns a prefixed 24-byte hex string.
|
|
func generateInboundSecret(provider models.IntegrationProvider) (string, error) {
|
|
buf := make([]byte, 24)
|
|
if _, err := rand.Read(buf); err != nil {
|
|
return "", err
|
|
}
|
|
prefix := "wmint"
|
|
switch provider {
|
|
case models.IntegrationCalendly:
|
|
prefix = "calendly"
|
|
case models.IntegrationCalCom:
|
|
prefix = "calcom"
|
|
}
|
|
return prefix + "_" + hex.EncodeToString(buf), nil
|
|
}
|
|
|
|
// BuildInboundURL is exported so the routes file and handler tests can generate
|
|
// the same URL the dashboard surfaces.
|
|
func BuildInboundURL(provider models.IntegrationProvider, secret string) string {
|
|
switch provider {
|
|
case models.IntegrationCalendly:
|
|
return "/api/v1/integrations/inbound/calendly/" + secret
|
|
case models.IntegrationCalCom:
|
|
return "/api/v1/integrations/inbound/cal-com/" + secret
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// buildDisplayFields extracts the public, non-secret bits of the config that
|
|
// the dashboard surfaces next to a connection card.
|
|
func buildDisplayFields(provider models.IntegrationProvider, config map[string]any) map[string]any {
|
|
df := map[string]any{}
|
|
pick := func(keys ...string) {
|
|
for _, k := range keys {
|
|
if v, ok := config[k]; ok {
|
|
df[k] = v
|
|
}
|
|
}
|
|
}
|
|
switch provider {
|
|
case models.IntegrationCalendly, models.IntegrationCalCom:
|
|
pick("organization_uri")
|
|
case models.IntegrationGoogleSheets:
|
|
pick("sheet_id", "sheet_title")
|
|
case models.IntegrationHubSpot, models.IntegrationSalesforce, models.IntegrationPipedrive, models.IntegrationClose:
|
|
pick("workspace", "account_email")
|
|
case models.IntegrationSlack:
|
|
pick("workspace", "channel")
|
|
case models.IntegrationDiscord:
|
|
pick("server")
|
|
case models.IntegrationZapier, models.IntegrationMake, models.IntegrationN8N:
|
|
// Outbound-via-Warmbly-API providers: minimal display fields.
|
|
}
|
|
return df
|
|
}
|