Add PKCE, token-body extras, AuthURL templating to OAuth2 → Add settings structs for Pattern-2 connector providers
- Add PKCE, token-body extras, AuthURL templating to OAuth2 - Add 13 connector provider enum values - Add scopes, display names, name resolvers for 13 providers - Add settings structs for Pattern-2 connector providers Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
This commit is contained in:
@@ -51,6 +51,19 @@ var providerDisplayNames = map[coredata.ConnectorProvider]string{
|
||||
coredata.ConnectorProviderIntercom: "Intercom",
|
||||
coredata.ConnectorProviderResend: "Resend",
|
||||
coredata.ConnectorProviderMicrosoft365: "Microsoft 365",
|
||||
coredata.ConnectorProviderGitLab: "GitLab",
|
||||
coredata.ConnectorProviderBitbucket: "Bitbucket",
|
||||
coredata.ConnectorProviderHeroku: "Heroku",
|
||||
coredata.ConnectorProviderPagerDuty: "PagerDuty",
|
||||
coredata.ConnectorProviderAsana: "Asana",
|
||||
coredata.ConnectorProviderSnyk: "Snyk",
|
||||
coredata.ConnectorProviderNetlify: "Netlify",
|
||||
coredata.ConnectorProviderRamp: "Ramp",
|
||||
coredata.ConnectorProviderClickUp: "ClickUp",
|
||||
coredata.ConnectorProviderVercel: "Vercel",
|
||||
coredata.ConnectorProviderMonday: "Monday.com",
|
||||
coredata.ConnectorProviderLever: "Lever",
|
||||
coredata.ConnectorProviderDeel: "Deel",
|
||||
}
|
||||
|
||||
// ProviderDisplayName returns the human-readable label for a connector provider.
|
||||
@@ -590,6 +603,586 @@ func (r *resendNameResolver) ResolveInstanceName(_ context.Context) (string, err
|
||||
return "Resend", nil
|
||||
}
|
||||
|
||||
// gitlabNameResolver resolves the GitLab group name.
|
||||
type gitlabNameResolver struct {
|
||||
httpClient *http.Client
|
||||
groupID string
|
||||
}
|
||||
|
||||
func NewGitLabNameResolver(httpClient *http.Client, groupID string) NameResolver {
|
||||
return &gitlabNameResolver{httpClient: httpClient, groupID: groupID}
|
||||
}
|
||||
|
||||
func (r *gitlabNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
|
||||
if r.groupID == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("https://gitlab.com/api/v4/groups/%s", r.groupID)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot create gitlab group request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
httpResp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot execute gitlab group request: %w", err)
|
||||
}
|
||||
defer func() { _ = httpResp.Body.Close() }()
|
||||
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("cannot fetch gitlab group: unexpected status %d", httpResp.StatusCode)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Name string `json:"name"`
|
||||
FullPath string `json:"full_path"`
|
||||
}
|
||||
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
||||
return "", fmt.Errorf("cannot decode gitlab group response: %w", err)
|
||||
}
|
||||
|
||||
if resp.Name != "" {
|
||||
return resp.Name, nil
|
||||
}
|
||||
return resp.FullPath, nil
|
||||
}
|
||||
|
||||
// bitbucketNameResolver resolves the Bitbucket workspace name.
|
||||
type bitbucketNameResolver struct {
|
||||
httpClient *http.Client
|
||||
workspace string
|
||||
}
|
||||
|
||||
func NewBitbucketNameResolver(httpClient *http.Client, workspace string) NameResolver {
|
||||
return &bitbucketNameResolver{httpClient: httpClient, workspace: workspace}
|
||||
}
|
||||
|
||||
func (r *bitbucketNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
|
||||
if r.workspace == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("https://api.bitbucket.org/2.0/workspaces/%s", r.workspace)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot create bitbucket workspace request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
httpResp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot execute bitbucket workspace request: %w", err)
|
||||
}
|
||||
defer func() { _ = httpResp.Body.Close() }()
|
||||
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("cannot fetch bitbucket workspace: unexpected status %d", httpResp.StatusCode)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
}
|
||||
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
||||
return "", fmt.Errorf("cannot decode bitbucket workspace response: %w", err)
|
||||
}
|
||||
|
||||
if resp.Name != "" {
|
||||
return resp.Name, nil
|
||||
}
|
||||
return resp.Slug, nil
|
||||
}
|
||||
|
||||
// herokuNameResolver resolves the Heroku team name.
|
||||
type herokuNameResolver struct {
|
||||
httpClient *http.Client
|
||||
teamID string
|
||||
}
|
||||
|
||||
func NewHerokuNameResolver(httpClient *http.Client, teamID string) NameResolver {
|
||||
return &herokuNameResolver{httpClient: httpClient, teamID: teamID}
|
||||
}
|
||||
|
||||
func (r *herokuNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
|
||||
if r.teamID == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("https://api.heroku.com/teams/%s", r.teamID)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot create heroku team request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.heroku+json; version=3")
|
||||
|
||||
httpResp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot execute heroku team request: %w", err)
|
||||
}
|
||||
defer func() { _ = httpResp.Body.Close() }()
|
||||
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("cannot fetch heroku team: unexpected status %d", httpResp.StatusCode)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
||||
return "", fmt.Errorf("cannot decode heroku team response: %w", err)
|
||||
}
|
||||
|
||||
return resp.Name, nil
|
||||
}
|
||||
|
||||
// pagerdutyNameResolver returns the PagerDuty subdomain stored in connector
|
||||
// settings. The subdomain is captured during the OAuth callback (see
|
||||
// handleConnectorComplete) so no HTTP call is required.
|
||||
type pagerdutyNameResolver struct {
|
||||
subdomain string
|
||||
}
|
||||
|
||||
func NewPagerDutyNameResolver(subdomain string) NameResolver {
|
||||
return &pagerdutyNameResolver{subdomain: subdomain}
|
||||
}
|
||||
|
||||
func (r *pagerdutyNameResolver) ResolveInstanceName(_ context.Context) (string, error) {
|
||||
return r.subdomain, nil
|
||||
}
|
||||
|
||||
// asanaNameResolver resolves the Asana workspace name.
|
||||
type asanaNameResolver struct {
|
||||
httpClient *http.Client
|
||||
workspaceGID string
|
||||
}
|
||||
|
||||
func NewAsanaNameResolver(httpClient *http.Client, workspaceGID string) NameResolver {
|
||||
return &asanaNameResolver{httpClient: httpClient, workspaceGID: workspaceGID}
|
||||
}
|
||||
|
||||
func (r *asanaNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
|
||||
if r.workspaceGID == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("https://app.asana.com/api/1.0/workspaces/%s", r.workspaceGID)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot create asana workspace request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
httpResp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot execute asana workspace request: %w", err)
|
||||
}
|
||||
defer func() { _ = httpResp.Body.Close() }()
|
||||
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("cannot fetch asana workspace: unexpected status %d", httpResp.StatusCode)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Data struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
||||
return "", fmt.Errorf("cannot decode asana workspace response: %w", err)
|
||||
}
|
||||
|
||||
return resp.Data.Name, nil
|
||||
}
|
||||
|
||||
// snykNameResolver resolves the Snyk organization name.
|
||||
type snykNameResolver struct {
|
||||
httpClient *http.Client
|
||||
orgID string
|
||||
}
|
||||
|
||||
func NewSnykNameResolver(httpClient *http.Client, orgID string) NameResolver {
|
||||
return &snykNameResolver{httpClient: httpClient, orgID: orgID}
|
||||
}
|
||||
|
||||
func (r *snykNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
|
||||
if r.orgID == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("https://api.snyk.io/rest/orgs/%s?version=2024-10-15", r.orgID)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot create snyk org request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.api+json")
|
||||
|
||||
httpResp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot execute snyk org request: %w", err)
|
||||
}
|
||||
defer func() { _ = httpResp.Body.Close() }()
|
||||
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("cannot fetch snyk org: unexpected status %d", httpResp.StatusCode)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Data struct {
|
||||
Attributes struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"attributes"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
||||
return "", fmt.Errorf("cannot decode snyk org response: %w", err)
|
||||
}
|
||||
|
||||
return resp.Data.Attributes.Name, nil
|
||||
}
|
||||
|
||||
// netlifyNameResolver resolves the Netlify account name.
|
||||
type netlifyNameResolver struct {
|
||||
httpClient *http.Client
|
||||
accountSlug string
|
||||
}
|
||||
|
||||
func NewNetlifyNameResolver(httpClient *http.Client, accountSlug string) NameResolver {
|
||||
return &netlifyNameResolver{httpClient: httpClient, accountSlug: accountSlug}
|
||||
}
|
||||
|
||||
func (r *netlifyNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
|
||||
if r.accountSlug == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("https://api.netlify.com/api/v1/accounts/%s", r.accountSlug)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot create netlify account request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
httpResp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot execute netlify account request: %w", err)
|
||||
}
|
||||
defer func() { _ = httpResp.Body.Close() }()
|
||||
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("cannot fetch netlify account: unexpected status %d", httpResp.StatusCode)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
||||
return "", fmt.Errorf("cannot decode netlify account response: %w", err)
|
||||
}
|
||||
|
||||
return resp.Name, nil
|
||||
}
|
||||
|
||||
// rampNameResolver resolves the Ramp business name.
|
||||
type rampNameResolver struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewRampNameResolver(httpClient *http.Client) NameResolver {
|
||||
return &rampNameResolver{httpClient: httpClient}
|
||||
}
|
||||
|
||||
func (r *rampNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
|
||||
req, err := http.NewRequestWithContext(
|
||||
ctx,
|
||||
http.MethodGet,
|
||||
"https://api.ramp.com/developer/v1/business",
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot create ramp business request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
httpResp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot execute ramp business request: %w", err)
|
||||
}
|
||||
defer func() { _ = httpResp.Body.Close() }()
|
||||
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("cannot fetch ramp business: unexpected status %d", httpResp.StatusCode)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
BusinessName string `json:"business_name"`
|
||||
LegalBusinessName string `json:"legal_business_name"`
|
||||
}
|
||||
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
||||
return "", fmt.Errorf("cannot decode ramp business response: %w", err)
|
||||
}
|
||||
|
||||
if resp.BusinessName != "" {
|
||||
return resp.BusinessName, nil
|
||||
}
|
||||
return resp.LegalBusinessName, nil
|
||||
}
|
||||
|
||||
// clickupNameResolver resolves the ClickUp team name.
|
||||
type clickupNameResolver struct {
|
||||
httpClient *http.Client
|
||||
teamID string
|
||||
}
|
||||
|
||||
func NewClickUpNameResolver(httpClient *http.Client, teamID string) NameResolver {
|
||||
return &clickupNameResolver{httpClient: httpClient, teamID: teamID}
|
||||
}
|
||||
|
||||
func (r *clickupNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
|
||||
if r.teamID == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("https://api.clickup.com/api/v2/team/%s", r.teamID)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot create clickup team request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
httpResp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot execute clickup team request: %w", err)
|
||||
}
|
||||
defer func() { _ = httpResp.Body.Close() }()
|
||||
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("cannot fetch clickup team: unexpected status %d", httpResp.StatusCode)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Team struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"team"`
|
||||
}
|
||||
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
||||
return "", fmt.Errorf("cannot decode clickup team response: %w", err)
|
||||
}
|
||||
|
||||
return resp.Team.Name, nil
|
||||
}
|
||||
|
||||
// vercelNameResolver resolves the Vercel team name. When the captured
|
||||
// TeamID is a personal-account UID, the v2 teams endpoint returns 404;
|
||||
// the resolver falls back to /v2/user and uses `username` (or `name`)
|
||||
// as the display name.
|
||||
type vercelNameResolver struct {
|
||||
httpClient *http.Client
|
||||
teamID string
|
||||
}
|
||||
|
||||
func NewVercelNameResolver(httpClient *http.Client, teamID string) NameResolver {
|
||||
return &vercelNameResolver{httpClient: httpClient, teamID: teamID}
|
||||
}
|
||||
|
||||
func (r *vercelNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
|
||||
if r.teamID == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
teamURL := fmt.Sprintf("https://api.vercel.com/v2/teams/%s", r.teamID)
|
||||
teamReq, err := http.NewRequestWithContext(ctx, http.MethodGet, teamURL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot create vercel team request: %w", err)
|
||||
}
|
||||
teamReq.Header.Set("Accept", "application/json")
|
||||
|
||||
teamResp, err := r.httpClient.Do(teamReq)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot execute vercel team request: %w", err)
|
||||
}
|
||||
defer func() { _ = teamResp.Body.Close() }()
|
||||
|
||||
if teamResp.StatusCode == http.StatusOK {
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
}
|
||||
if err := json.NewDecoder(teamResp.Body).Decode(&body); err != nil {
|
||||
return "", fmt.Errorf("cannot decode vercel team response: %w", err)
|
||||
}
|
||||
if body.Name != "" {
|
||||
return body.Name, nil
|
||||
}
|
||||
return body.Slug, nil
|
||||
}
|
||||
|
||||
if teamResp.StatusCode != http.StatusNotFound {
|
||||
return "", fmt.Errorf("cannot fetch vercel team: unexpected status %d", teamResp.StatusCode)
|
||||
}
|
||||
|
||||
// Personal-account fallback: /v2/teams/<uid> returns 404, but
|
||||
// /v2/user works with the same Bearer token.
|
||||
userReq, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.vercel.com/v2/user", nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot create vercel user request: %w", err)
|
||||
}
|
||||
userReq.Header.Set("Accept", "application/json")
|
||||
|
||||
userResp, err := r.httpClient.Do(userReq)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot execute vercel user request: %w", err)
|
||||
}
|
||||
defer func() { _ = userResp.Body.Close() }()
|
||||
|
||||
if userResp.StatusCode < 200 || userResp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("cannot fetch vercel user: unexpected status %d", userResp.StatusCode)
|
||||
}
|
||||
|
||||
var userBody struct {
|
||||
User struct {
|
||||
Username string `json:"username"`
|
||||
Name string `json:"name"`
|
||||
} `json:"user"`
|
||||
}
|
||||
if err := json.NewDecoder(userResp.Body).Decode(&userBody); err != nil {
|
||||
return "", fmt.Errorf("cannot decode vercel user response: %w", err)
|
||||
}
|
||||
|
||||
if userBody.User.Username != "" {
|
||||
return userBody.User.Username, nil
|
||||
}
|
||||
return userBody.User.Name, nil
|
||||
}
|
||||
|
||||
// mondayNameResolver resolves the Monday.com account name via GraphQL.
|
||||
type mondayNameResolver struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewMondayNameResolver(httpClient *http.Client) NameResolver {
|
||||
return &mondayNameResolver{httpClient: httpClient}
|
||||
}
|
||||
|
||||
func (r *mondayNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
|
||||
body := struct {
|
||||
Query string `json:"query"`
|
||||
}{
|
||||
Query: `query { account { id name slug tier } }`,
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot marshal monday account query: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, mondayGraphQLEndpoint, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot create monday account request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
httpResp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot execute monday account request: %w", err)
|
||||
}
|
||||
defer func() { _ = httpResp.Body.Close() }()
|
||||
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("cannot fetch monday account: unexpected status %d", httpResp.StatusCode)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Data struct {
|
||||
Account struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"account"`
|
||||
} `json:"data"`
|
||||
Errors []struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"errors"`
|
||||
}
|
||||
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
||||
return "", fmt.Errorf("cannot decode monday account response: %w", err)
|
||||
}
|
||||
|
||||
if len(resp.Errors) > 0 {
|
||||
return "", fmt.Errorf("monday graphql error: %s", resp.Errors[0].Message)
|
||||
}
|
||||
|
||||
return resp.Data.Account.Name, nil
|
||||
}
|
||||
|
||||
// leverNameResolver returns an empty string: Lever does not expose a
|
||||
// dedicated org-name endpoint. The worker keeps the generic name and
|
||||
// the operator can rename the source manually.
|
||||
type leverNameResolver struct{}
|
||||
|
||||
func NewLeverNameResolver() NameResolver {
|
||||
return &leverNameResolver{}
|
||||
}
|
||||
|
||||
func (r *leverNameResolver) ResolveInstanceName(_ context.Context) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// deelNameResolver resolves the Deel organization name by reading the
|
||||
// first item of /rest/v2/organizations.
|
||||
type deelNameResolver struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewDeelNameResolver(httpClient *http.Client) NameResolver {
|
||||
return &deelNameResolver{httpClient: httpClient}
|
||||
}
|
||||
|
||||
func (r *deelNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
|
||||
req, err := http.NewRequestWithContext(
|
||||
ctx,
|
||||
http.MethodGet,
|
||||
"https://api.letsdeel.com/rest/v2/organizations",
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot create deel organizations request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
httpResp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot execute deel organizations request: %w", err)
|
||||
}
|
||||
defer func() { _ = httpResp.Body.Close() }()
|
||||
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("cannot fetch deel organizations: unexpected status %d", httpResp.StatusCode)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Data []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
||||
return "", fmt.Errorf("cannot decode deel organizations response: %w", err)
|
||||
}
|
||||
|
||||
if len(resp.Data) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
return resp.Data[0].Name, nil
|
||||
}
|
||||
|
||||
// notionNameResolver resolves the Notion workspace name via /v1/users/me.
|
||||
type notionNameResolver struct {
|
||||
httpClient *http.Client
|
||||
|
||||
@@ -41,9 +41,22 @@ var providerOAuth2Scopes = map[coredata.ConnectorProvider][]string{
|
||||
"https://graph.microsoft.com/Directory.Read.All",
|
||||
"https://graph.microsoft.com/RoleManagement.Read.Directory",
|
||||
},
|
||||
coredata.ConnectorProviderGitLab: {"read_api"},
|
||||
coredata.ConnectorProviderHeroku: {"read"},
|
||||
coredata.ConnectorProviderPagerDuty: {"users.read"},
|
||||
coredata.ConnectorProviderAsana: {"users:read"},
|
||||
coredata.ConnectorProviderSnyk: {"org.read", "offline_access"},
|
||||
coredata.ConnectorProviderRamp: {"users:read"},
|
||||
coredata.ConnectorProviderMonday: {"users:read", "account:read"},
|
||||
coredata.ConnectorProviderLever: {"users:read:admin", "offline_access"},
|
||||
coredata.ConnectorProviderDeel: {"people:read", "organizations:read"},
|
||||
// Notion and Intercom have no scopes here: Notion authorizes via
|
||||
// extra-auth-params (owner=user), Intercom configures scopes at the app
|
||||
// level.
|
||||
// level. Bitbucket scopes are pinned on the OAuth consumer at
|
||||
// registration time, not passed via the authorize URL. Netlify and
|
||||
// ClickUp OAuth flows have no scope granularity, so they are also
|
||||
// omitted. Vercel pins capabilities on the integration registration
|
||||
// in the Vercel dashboard, so no scopes are passed here.
|
||||
}
|
||||
|
||||
// ProviderOAuth2Scopes returns the OAuth2 scopes the access review driver
|
||||
|
||||
@@ -17,10 +17,13 @@ package connector
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
@@ -49,6 +52,22 @@ type (
|
||||
ExtraAuthParams map[string]string // Optional: extra params for auth URL (e.g., access_type=offline for Google)
|
||||
TokenEndpointAuth string // "post-form" (default), "basic-form", or "basic-json"
|
||||
SupportsIncrementalAuth bool
|
||||
// RequiresPKCE enables RFC 7636 PKCE (S256). When true,
|
||||
// InitiateWithState generates a verifier, persists it in the
|
||||
// OAuth2State, and adds code_challenge / code_challenge_method
|
||||
// to the authorize URL; CompleteWithState replays the verifier
|
||||
// on the token exchange.
|
||||
RequiresPKCE bool
|
||||
// TokenExtraParams are merged into the token-exchange request
|
||||
// body (form-encoded for post-form / basic-form, JSON for
|
||||
// basic-json). Used for provider-specific extras such as
|
||||
// Lever's `audience` parameter.
|
||||
TokenExtraParams map[string]string
|
||||
// AuthURLParams are operator-supplied placeholders substituted
|
||||
// into the static provider AuthURL by ApplyProviderDefaults
|
||||
// (for example Vercel's "{integration_slug}"). Empty for the
|
||||
// vast majority of providers.
|
||||
AuthURLParams map[string]string
|
||||
|
||||
// HTTPClient is used for the OAuth2 token-exchange request
|
||||
// issued from CompleteWithState. It must be set by callers;
|
||||
@@ -63,6 +82,18 @@ type (
|
||||
ContinueURL string `json:"continue,omitempty"`
|
||||
ConnectorID string `json:"cid,omitempty"` // Set when reconnecting an existing connector
|
||||
RequestedScopes []string `json:"scopes,omitempty"`
|
||||
// CodeVerifier carries the PKCE verifier between Initiate and
|
||||
// Complete. Set only when the provider requires PKCE
|
||||
// (RequiresPKCE = true on the OAuth2Connector).
|
||||
CodeVerifier string `json:"cv,omitempty"`
|
||||
// ProviderMetadata surfaces provider-specific extras parsed
|
||||
// from the token-exchange response (e.g. PagerDuty's
|
||||
// `subdomain`). It is populated by CompleteWithState and is
|
||||
// NEVER serialized into the state token (the field is for
|
||||
// in-process plumbing only). Consumers that need to persist
|
||||
// these values (typically the OAuth callback handler) read
|
||||
// them off the returned *OAuth2State.
|
||||
ProviderMetadata map[string]string `json:"-"`
|
||||
}
|
||||
|
||||
OAuth2Connection struct {
|
||||
@@ -133,6 +164,17 @@ func (c *OAuth2Connector) InitiateWithState(
|
||||
stateData OAuth2State,
|
||||
opts InitiateOptions,
|
||||
) (string, error) {
|
||||
// PKCE is generated before the state token so the verifier is
|
||||
// embedded in the signed payload and replayed on the token
|
||||
// exchange. Providers that do not require PKCE skip this entirely.
|
||||
if c.RequiresPKCE {
|
||||
verifier, err := generatePKCEVerifier()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot generate PKCE verifier: %w", err)
|
||||
}
|
||||
stateData.CodeVerifier = verifier
|
||||
}
|
||||
|
||||
state, err := statelesstoken.NewToken(c.ClientSecret, OAuth2TokenType, OAuth2TokenTTL, stateData)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot create state token: %w", err)
|
||||
@@ -147,6 +189,11 @@ func (c *OAuth2Connector) InitiateWithState(
|
||||
authCodeQuery.Set("scope", strings.Join(opts.Scopes, " "))
|
||||
}
|
||||
|
||||
if c.RequiresPKCE {
|
||||
authCodeQuery.Set("code_challenge", pkceChallenge(stateData.CodeVerifier))
|
||||
authCodeQuery.Set("code_challenge_method", "S256")
|
||||
}
|
||||
|
||||
incrementalAuth := c.SupportsIncrementalAuth && opts.IncludeGrantedScopes
|
||||
if incrementalAuth {
|
||||
authCodeQuery.Set("include_granted_scopes", "true")
|
||||
@@ -172,6 +219,24 @@ func (c *OAuth2Connector) InitiateWithState(
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
// generatePKCEVerifier produces a 32-byte cryptographically random
|
||||
// PKCE verifier encoded as base64url without padding (RFC 7636 §4.1).
|
||||
func generatePKCEVerifier() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("cannot read random bytes: %w", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// pkceChallenge derives the S256 PKCE challenge from a verifier: it is
|
||||
// the base64url-without-padding encoding of SHA-256(verifier) (RFC 7636
|
||||
// §4.2).
|
||||
func pkceChallenge(verifier string) string {
|
||||
sum := sha256.Sum256([]byte(verifier))
|
||||
return base64.RawURLEncoding.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func (c *OAuth2Connector) Complete(ctx context.Context, r *http.Request) (Connection, *gid.GID, string, error) {
|
||||
conn, state, err := c.CompleteWithState(ctx, r)
|
||||
if err != nil {
|
||||
@@ -209,7 +274,7 @@ func (c *OAuth2Connector) CompleteWithState(ctx context.Context, r *http.Request
|
||||
return nil, nil, fmt.Errorf("cannot parse organization ID: %w", err)
|
||||
}
|
||||
|
||||
tokenRequest, err := c.buildTokenRequest(ctx, code, c.RedirectURI)
|
||||
tokenRequest, err := c.buildTokenRequest(ctx, code, c.RedirectURI, payload.Data.CodeVerifier)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@@ -266,17 +331,40 @@ func (c *OAuth2Connector) CompleteWithState(ctx context.Context, r *http.Request
|
||||
return conn, &payload.Data, err
|
||||
}
|
||||
|
||||
// PagerDuty Scoped OAuth includes the customer's subdomain in the
|
||||
// token response body. We parse it here so the OAuth callback
|
||||
// handler can write it to PagerDutyConnectorSettings without
|
||||
// having to issue a second decode against a now-closed body.
|
||||
if payload.Data.Provider == PagerDutyProvider {
|
||||
var pd struct {
|
||||
Subdomain string `json:"subdomain"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &pd); err == nil && pd.Subdomain != "" {
|
||||
if payload.Data.ProviderMetadata == nil {
|
||||
payload.Data.ProviderMetadata = map[string]string{}
|
||||
}
|
||||
payload.Data.ProviderMetadata["subdomain"] = pd.Subdomain
|
||||
}
|
||||
}
|
||||
|
||||
return &oauth2Conn, &payload.Data, nil
|
||||
}
|
||||
|
||||
// PagerDutyProvider is the canonical string used by PagerDuty in the
|
||||
// state token's `provider` field.
|
||||
const PagerDutyProvider = "PAGERDUTY"
|
||||
|
||||
func basicAuthHeader(clientID, clientSecret string) string {
|
||||
credentials := clientID + ":" + clientSecret
|
||||
return "Basic " + base64.StdEncoding.EncodeToString([]byte(credentials))
|
||||
}
|
||||
|
||||
// buildTokenRequest creates the HTTP request for the token exchange, branching
|
||||
// on c.TokenEndpointAuth to support different provider requirements.
|
||||
func (c *OAuth2Connector) buildTokenRequest(ctx context.Context, code, redirectURI string) (*http.Request, error) {
|
||||
// on c.TokenEndpointAuth to support different provider requirements. When
|
||||
// codeVerifier is non-empty (PKCE-enabled providers), it is replayed as
|
||||
// `code_verifier` in the request body. TokenExtraParams are merged into the
|
||||
// body in every branch.
|
||||
func (c *OAuth2Connector) buildTokenRequest(ctx context.Context, code, redirectURI, codeVerifier string) (*http.Request, error) {
|
||||
switch c.TokenEndpointAuth {
|
||||
case "basic-json":
|
||||
// JSON body with Basic auth header (Notion).
|
||||
@@ -285,6 +373,10 @@ func (c *OAuth2Connector) buildTokenRequest(ctx context.Context, code, redirectU
|
||||
"redirect_uri": redirectURI,
|
||||
"grant_type": "authorization_code",
|
||||
}
|
||||
if codeVerifier != "" {
|
||||
body["code_verifier"] = codeVerifier
|
||||
}
|
||||
maps.Copy(body, c.TokenExtraParams)
|
||||
jsonBody, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot marshal token request body: %w", err)
|
||||
@@ -312,6 +404,12 @@ func (c *OAuth2Connector) buildTokenRequest(ctx context.Context, code, redirectU
|
||||
formData.Set("code", code)
|
||||
formData.Set("redirect_uri", redirectURI)
|
||||
formData.Set("grant_type", "authorization_code")
|
||||
if codeVerifier != "" {
|
||||
formData.Set("code_verifier", codeVerifier)
|
||||
}
|
||||
for k, v := range c.TokenExtraParams {
|
||||
formData.Set(k, v)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(
|
||||
ctx,
|
||||
@@ -337,6 +435,12 @@ func (c *OAuth2Connector) buildTokenRequest(ctx context.Context, code, redirectU
|
||||
formData.Set("code", code)
|
||||
formData.Set("redirect_uri", redirectURI)
|
||||
formData.Set("grant_type", "authorization_code")
|
||||
if codeVerifier != "" {
|
||||
formData.Set("code_verifier", codeVerifier)
|
||||
}
|
||||
for k, v := range c.TokenExtraParams {
|
||||
formData.Set(k, v)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(
|
||||
ctx,
|
||||
|
||||
@@ -49,6 +49,7 @@ func TestBuildTokenRequest_PostForm(t *testing.T) {
|
||||
context.Background(),
|
||||
"test-code",
|
||||
"https://example.com/callback",
|
||||
"",
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -84,6 +85,7 @@ func TestBuildTokenRequest_PostForm(t *testing.T) {
|
||||
context.Background(),
|
||||
"test-code",
|
||||
"https://example.com/callback",
|
||||
"",
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -118,6 +120,7 @@ func TestBuildTokenRequest_BasicForm(t *testing.T) {
|
||||
context.Background(),
|
||||
"test-code",
|
||||
"https://example.com/callback",
|
||||
"",
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -160,6 +163,7 @@ func TestBuildTokenRequest_BasicJSON(t *testing.T) {
|
||||
context.Background(),
|
||||
"test-code",
|
||||
"https://example.com/callback",
|
||||
"",
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -553,3 +557,297 @@ func TestCompleteWithState_ScopeFallback(t *testing.T) {
|
||||
assert.Equal(t, "read:user write:user", oauth2Conn.Scope)
|
||||
assert.Equal(t, []string{"read:user", "write:user"}, returnedState.RequestedScopes)
|
||||
}
|
||||
|
||||
// TestInitiateWithState_PKCE verifies that connectors with RequiresPKCE=true
|
||||
// generate a PKCE verifier, embed the S256 challenge in the authorization
|
||||
// URL (RFC 7636 §4.3), and persist the verifier in the signed state token
|
||||
// so CompleteWithState can replay it on the token exchange.
|
||||
func TestInitiateWithState_PKCE(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("authorize URL carries S256 code_challenge when PKCE is required", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := &OAuth2Connector{
|
||||
ClientID: "id",
|
||||
ClientSecret: "secret",
|
||||
RedirectURI: "https://example.com/cb",
|
||||
AuthURL: "https://provider.example.com/authorize",
|
||||
RequiresPKCE: true,
|
||||
}
|
||||
|
||||
orgID := gid.New(gid.NewTenantID(), 0)
|
||||
|
||||
u, err := c.InitiateWithState(
|
||||
context.Background(),
|
||||
OAuth2State{OrganizationID: orgID.String(), Provider: "TEST"},
|
||||
InitiateOptions{Scopes: []string{"read:user"}},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
parsed, err := url.Parse(u)
|
||||
require.NoError(t, err)
|
||||
|
||||
challenge := parsed.Query().Get("code_challenge")
|
||||
require.NotEmpty(t, challenge, "code_challenge must be present when RequiresPKCE=true")
|
||||
assert.Equal(t, "S256", parsed.Query().Get("code_challenge_method"))
|
||||
|
||||
// The verifier is persisted in the signed state token. Decode
|
||||
// the payload (without secret-checking — just inspect) and
|
||||
// verify that re-deriving the challenge from the verifier
|
||||
// reproduces the URL value.
|
||||
stateToken := parsed.Query().Get("state")
|
||||
require.NotEmpty(t, stateToken)
|
||||
|
||||
payload, err := DecodeOAuth2StatePayload(stateToken)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, payload.Data.CodeVerifier, "verifier must be persisted in state token")
|
||||
assert.Equal(t, challenge, pkceChallenge(payload.Data.CodeVerifier),
|
||||
"code_challenge must equal base64url(sha256(verifier))")
|
||||
})
|
||||
|
||||
t.Run("authorize URL omits PKCE params when PKCE is not required", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := &OAuth2Connector{
|
||||
ClientID: "id",
|
||||
ClientSecret: "secret",
|
||||
RedirectURI: "https://example.com/cb",
|
||||
AuthURL: "https://provider.example.com/authorize",
|
||||
RequiresPKCE: false,
|
||||
}
|
||||
|
||||
orgID := gid.New(gid.NewTenantID(), 0)
|
||||
|
||||
u, err := c.InitiateWithState(
|
||||
context.Background(),
|
||||
OAuth2State{OrganizationID: orgID.String(), Provider: "TEST"},
|
||||
InitiateOptions{Scopes: []string{"read:user"}},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
parsed, err := url.Parse(u)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, parsed.Query().Has("code_challenge"))
|
||||
assert.False(t, parsed.Query().Has("code_challenge_method"))
|
||||
})
|
||||
|
||||
t.Run("token POST replays code_verifier from state on PKCE flow", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var capturedVerifier string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
assert.NoError(t, err)
|
||||
|
||||
form, err := url.ParseQuery(string(body))
|
||||
assert.NoError(t, err)
|
||||
capturedVerifier = form.Get("code_verifier")
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"access_token":"live-token","token_type":"Bearer","expires_in":3600}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := &OAuth2Connector{
|
||||
ClientID: "id",
|
||||
ClientSecret: "secret",
|
||||
RedirectURI: "https://example.com/cb",
|
||||
AuthURL: "https://provider.example.com/authorize",
|
||||
TokenURL: server.URL,
|
||||
RequiresPKCE: true,
|
||||
HTTPClient: httpclient.DefaultClient(httpclient.WithSSRFProtection(), httpclient.WithSSRFAllowLoopback()),
|
||||
}
|
||||
|
||||
// Initiate to mint a state token that embeds a fresh PKCE verifier.
|
||||
orgID := gid.New(gid.NewTenantID(), 0)
|
||||
authURL, err := c.InitiateWithState(
|
||||
context.Background(),
|
||||
OAuth2State{OrganizationID: orgID.String(), Provider: "TEST"},
|
||||
InitiateOptions{Scopes: []string{"read:user"}},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
parsed, err := url.Parse(authURL)
|
||||
require.NoError(t, err)
|
||||
|
||||
stateToken := parsed.Query().Get("state")
|
||||
require.NotEmpty(t, stateToken)
|
||||
|
||||
payload, err := DecodeOAuth2StatePayload(stateToken)
|
||||
require.NoError(t, err)
|
||||
expectedVerifier := payload.Data.CodeVerifier
|
||||
require.NotEmpty(t, expectedVerifier)
|
||||
|
||||
// Drive Complete with that same state token + an arbitrary code.
|
||||
req := httptest.NewRequest(
|
||||
http.MethodGet,
|
||||
"https://example.com/cb?code=the-code&state="+stateToken,
|
||||
nil,
|
||||
)
|
||||
|
||||
_, _, err = c.CompleteWithState(context.Background(), req)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, expectedVerifier, capturedVerifier,
|
||||
"token POST body must carry the verifier persisted in the state token")
|
||||
})
|
||||
}
|
||||
|
||||
// TestBuildTokenRequest_TokenExtraParams verifies that TokenExtraParams are
|
||||
// merged into the token-exchange body in all three auth branches. This
|
||||
// powers Lever's required `audience=https://api.lever.co/v1/` parameter
|
||||
// without any per-provider branching in the OAuth2 core.
|
||||
func TestBuildTokenRequest_TokenExtraParams(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("post-form merges audience into form body", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := &OAuth2Connector{
|
||||
ClientID: "lever-client-id",
|
||||
ClientSecret: "lever-client-secret",
|
||||
TokenURL: "https://auth.lever.co/oauth/token",
|
||||
TokenExtraParams: map[string]string{
|
||||
"audience": "https://api.lever.co/v1/",
|
||||
},
|
||||
}
|
||||
|
||||
req, err := c.buildTokenRequest(
|
||||
context.Background(),
|
||||
"the-code",
|
||||
"https://example.com/cb",
|
||||
"",
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
body, err := io.ReadAll(req.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Raw body check: the URL-encoded value must be present
|
||||
// verbatim (catches any double-encoding regressions).
|
||||
assert.Contains(t, string(body), "audience=https%3A%2F%2Fapi.lever.co%2Fv1%2F")
|
||||
|
||||
form, err := url.ParseQuery(string(body))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "https://api.lever.co/v1/", form.Get("audience"))
|
||||
assert.Equal(t, "the-code", form.Get("code"))
|
||||
assert.Equal(t, "authorization_code", form.Get("grant_type"))
|
||||
})
|
||||
|
||||
t.Run("basic-form merges extra params into form body", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := &OAuth2Connector{
|
||||
ClientID: "id",
|
||||
ClientSecret: "secret",
|
||||
TokenURL: "https://provider.example.com/oauth/token",
|
||||
TokenEndpointAuth: "basic-form",
|
||||
TokenExtraParams: map[string]string{
|
||||
"audience": "https://api.lever.co/v1/",
|
||||
},
|
||||
}
|
||||
|
||||
req, err := c.buildTokenRequest(
|
||||
context.Background(),
|
||||
"the-code",
|
||||
"https://example.com/cb",
|
||||
"",
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
body, err := io.ReadAll(req.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
form, err := url.ParseQuery(string(body))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "https://api.lever.co/v1/", form.Get("audience"))
|
||||
})
|
||||
|
||||
t.Run("basic-json merges extra params into JSON body", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := &OAuth2Connector{
|
||||
ClientID: "id",
|
||||
ClientSecret: "secret",
|
||||
TokenURL: "https://provider.example.com/oauth/token",
|
||||
TokenEndpointAuth: "basic-json",
|
||||
TokenExtraParams: map[string]string{
|
||||
"audience": "https://api.lever.co/v1/",
|
||||
},
|
||||
}
|
||||
|
||||
req, err := c.buildTokenRequest(
|
||||
context.Background(),
|
||||
"the-code",
|
||||
"https://example.com/cb",
|
||||
"",
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
body, err := io.ReadAll(req.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
var jsonBody map[string]string
|
||||
require.NoError(t, json.Unmarshal(body, &jsonBody))
|
||||
assert.Equal(t, "https://api.lever.co/v1/", jsonBody["audience"])
|
||||
})
|
||||
}
|
||||
|
||||
// TestApplyProviderDefaults_AuthURLTemplating verifies that operator-supplied
|
||||
// AuthURLParams (for example Vercel's "{integration_slug}") are substituted
|
||||
// into the static provider AuthURL when the connector is initialized.
|
||||
// Providers without placeholders are unaffected.
|
||||
func TestApplyProviderDefaults_AuthURLTemplating(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Register a fake provider definition for the duration of this
|
||||
// test so we do not have to wait for a real Vercel-style provider
|
||||
// to land. Restore on teardown.
|
||||
const fakeProvider = "TEST_TEMPLATED_AUTH_URL"
|
||||
previous, hadPrevious := providerDefinitions[fakeProvider]
|
||||
providerDefinitions[fakeProvider] = providerDefinition{
|
||||
AuthURL: "https://example.com/integrations/{integration_slug}/new",
|
||||
TokenURL: "https://example.com/oauth/token",
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if hadPrevious {
|
||||
providerDefinitions[fakeProvider] = previous
|
||||
} else {
|
||||
delete(providerDefinitions, fakeProvider)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("placeholder is substituted when AuthURLParams is supplied", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := &OAuth2Connector{
|
||||
ClientID: "id",
|
||||
ClientSecret: "secret",
|
||||
AuthURLParams: map[string]string{
|
||||
"integration_slug": "acme",
|
||||
},
|
||||
}
|
||||
|
||||
ApplyProviderDefaults(fakeProvider, "https://example.com/cb", c)
|
||||
|
||||
assert.Equal(t, "https://example.com/integrations/acme/new", c.AuthURL)
|
||||
assert.Equal(t, "https://example.com/oauth/token", c.TokenURL)
|
||||
})
|
||||
|
||||
t.Run("placeholder remains literal when AuthURLParams is empty", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := &OAuth2Connector{
|
||||
ClientID: "id",
|
||||
ClientSecret: "secret",
|
||||
}
|
||||
|
||||
ApplyProviderDefaults(fakeProvider, "https://example.com/cb", c)
|
||||
|
||||
// No substitution requested; the placeholder is preserved
|
||||
// verbatim so a misconfiguration is visible at the
|
||||
// authorization step rather than silently masked.
|
||||
assert.Equal(t, "https://example.com/integrations/{integration_slug}/new", c.AuthURL)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -14,7 +14,12 @@
|
||||
|
||||
package connector
|
||||
|
||||
import "go.gearno.de/kit/httpclient"
|
||||
import (
|
||||
"maps"
|
||||
"strings"
|
||||
|
||||
"go.gearno.de/kit/httpclient"
|
||||
)
|
||||
|
||||
// CallbackPath is the HTTP path for the OAuth2 callback endpoint.
|
||||
const CallbackPath = "/api/console/v1/connectors/complete"
|
||||
@@ -30,6 +35,14 @@ type providerDefinition struct {
|
||||
ExtraAuthParams map[string]string
|
||||
TokenEndpointAuth string // "post-form" (default), "basic-form", or "basic-json"
|
||||
SupportsIncrementalAuth bool
|
||||
// RequiresPKCE enables RFC 7636 PKCE (S256) on the authorization
|
||||
// request and replays the verifier on the token exchange. Default
|
||||
// false; existing providers are unaffected.
|
||||
RequiresPKCE bool
|
||||
// TokenExtraParams are merged into the token-exchange request body
|
||||
// (form-encoded for "post-form"/"basic-form", JSON for "basic-json").
|
||||
// Used by providers like Lever that require an `audience` parameter.
|
||||
TokenExtraParams map[string]string
|
||||
}
|
||||
|
||||
// providerDefinitions maps provider names to their static OAuth2 definitions.
|
||||
@@ -91,6 +104,83 @@ var (
|
||||
AuthURL: "https://linear.app/oauth/authorize",
|
||||
TokenURL: "https://api.linear.app/oauth/token",
|
||||
},
|
||||
"GITLAB": {
|
||||
AuthURL: "https://gitlab.com/oauth/authorize",
|
||||
TokenURL: "https://gitlab.com/oauth/token",
|
||||
},
|
||||
// Bitbucket scopes are pinned on the OAuth consumer at registration
|
||||
// time (`account` for workspace membership). They are not passed in
|
||||
// the authorize URL and not configured here.
|
||||
"BITBUCKET": {
|
||||
AuthURL: "https://bitbucket.org/site/oauth2/authorize",
|
||||
TokenURL: "https://bitbucket.org/site/oauth2/access_token",
|
||||
},
|
||||
"HEROKU": {
|
||||
AuthURL: "https://id.heroku.com/oauth/authorize",
|
||||
TokenURL: "https://id.heroku.com/oauth/token",
|
||||
},
|
||||
"PAGERDUTY": {
|
||||
AuthURL: "https://identity.pagerduty.com/oauth/authorize",
|
||||
TokenURL: "https://identity.pagerduty.com/oauth/token",
|
||||
RequiresPKCE: true,
|
||||
},
|
||||
"ASANA": {
|
||||
AuthURL: "https://app.asana.com/-/oauth_authorize",
|
||||
TokenURL: "https://app.asana.com/-/oauth_token",
|
||||
},
|
||||
"SNYK": {
|
||||
AuthURL: "https://app.snyk.io/oauth2/authorize",
|
||||
TokenURL: "https://api.snyk.io/oauth2/token",
|
||||
RequiresPKCE: true,
|
||||
},
|
||||
"NETLIFY": {
|
||||
AuthURL: "https://app.netlify.com/authorize",
|
||||
TokenURL: "https://api.netlify.com/oauth/token",
|
||||
},
|
||||
"RAMP": {
|
||||
AuthURL: "https://app.ramp.com/v1/authorize",
|
||||
TokenURL: "https://api.ramp.com/developer/v1/token",
|
||||
TokenEndpointAuth: "basic-form",
|
||||
},
|
||||
"CLICKUP": {
|
||||
AuthURL: "https://app.clickup.com/api",
|
||||
TokenURL: "https://api.clickup.com/api/v2/oauth/token",
|
||||
},
|
||||
// Vercel uses a templated AuthURL: the operator supplies an
|
||||
// `integration-slug` config field which is resolved into the
|
||||
// "{integration_slug}" placeholder by ApplyProviderDefaults.
|
||||
// Vercel does not use OAuth scopes — capabilities are pinned on
|
||||
// the integration registration in the Vercel dashboard.
|
||||
"VERCEL": {
|
||||
AuthURL: "https://vercel.com/integrations/{integration_slug}/new",
|
||||
TokenURL: "https://api.vercel.com/v2/oauth/access_token",
|
||||
},
|
||||
"MONDAY": {
|
||||
AuthURL: "https://auth.monday.com/oauth2/authorize",
|
||||
TokenURL: "https://auth.monday.com/oauth2/token",
|
||||
},
|
||||
// Lever runs on Auth0: the `audience` parameter is required in
|
||||
// BOTH the authorize URL and the token-exchange POST body. The
|
||||
// trailing slash on the audience value is mandatory.
|
||||
"LEVER": {
|
||||
AuthURL: "https://auth.lever.co/authorize",
|
||||
TokenURL: "https://auth.lever.co/oauth/token",
|
||||
ExtraAuthParams: map[string]string{
|
||||
"audience": "https://api.lever.co/v1/",
|
||||
"prompt": "consent",
|
||||
},
|
||||
TokenExtraParams: map[string]string{
|
||||
"audience": "https://api.lever.co/v1/",
|
||||
},
|
||||
},
|
||||
// Deel: the token endpoint path is "/oauth2/tokens" (plural) —
|
||||
// Deel's docs are inconsistent on the singular vs plural form.
|
||||
// The API base host (api.letsdeel.com) differs from the auth host
|
||||
// (app.deel.com).
|
||||
"DEEL": {
|
||||
AuthURL: "https://app.deel.com/oauth2/authorize",
|
||||
TokenURL: "https://app.deel.com/oauth2/tokens",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -108,5 +198,22 @@ func ApplyProviderDefaults(provider string, redirectURI string, c *OAuth2Connect
|
||||
c.ExtraAuthParams = def.ExtraAuthParams
|
||||
c.TokenEndpointAuth = def.TokenEndpointAuth
|
||||
c.SupportsIncrementalAuth = def.SupportsIncrementalAuth
|
||||
c.RequiresPKCE = def.RequiresPKCE
|
||||
|
||||
// Deep copy TokenExtraParams so per-connector mutations cannot
|
||||
// alias back into the shared providerDefinitions map.
|
||||
if len(def.TokenExtraParams) > 0 {
|
||||
tokenExtra := make(map[string]string, len(def.TokenExtraParams))
|
||||
maps.Copy(tokenExtra, def.TokenExtraParams)
|
||||
c.TokenExtraParams = tokenExtra
|
||||
}
|
||||
|
||||
// Resolve operator-supplied placeholders in the static AuthURL
|
||||
// (for example Vercel's "{integration_slug}"). Providers without
|
||||
// placeholders are unaffected; the loop is a no-op when
|
||||
// AuthURLParams is empty.
|
||||
for k, v := range c.AuthURLParams {
|
||||
c.AuthURL = strings.ReplaceAll(c.AuthURL, "{"+k+"}", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,6 +137,22 @@ var (
|
||||
"RESEND": "https://api.resend.com/domains",
|
||||
"ONE_PASSWORD": "https://events.1password.com/api/v1/auditevents",
|
||||
"MICROSOFT_365": "https://graph.microsoft.com/v1.0/organization?$top=1",
|
||||
"GITLAB": "https://gitlab.com/api/v4/user",
|
||||
"BITBUCKET": "https://api.bitbucket.org/2.0/user",
|
||||
"HEROKU": "https://api.heroku.com/account",
|
||||
"PAGERDUTY": "https://api.pagerduty.com/users/me",
|
||||
"ASANA": "https://app.asana.com/api/1.0/users/me",
|
||||
"SNYK": "https://api.snyk.io/rest/self?version=2024-10-15",
|
||||
"NETLIFY": "https://api.netlify.com/api/v1/user",
|
||||
"RAMP": "https://api.ramp.com/developer/v1/business",
|
||||
"CLICKUP": "https://api.clickup.com/api/v2/user",
|
||||
"VERCEL": "https://api.vercel.com/v2/user",
|
||||
// Monday's primary API is GraphQL POST, but the probe handler
|
||||
// is GET-only. Use the OIDC userinfo endpoint as a GET probe
|
||||
// that returns 200/401 with the same Bearer token.
|
||||
"MONDAY": "https://auth.monday.com/oauth2/userinfo",
|
||||
"LEVER": "https://api.lever.co/v1/users?limit=1",
|
||||
"DEEL": "https://api.letsdeel.com/rest/v2/people?limit=1",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -40,6 +40,19 @@ const (
|
||||
ConnectorProviderIntercom ConnectorProvider = "INTERCOM"
|
||||
ConnectorProviderResend ConnectorProvider = "RESEND"
|
||||
ConnectorProviderMicrosoft365 ConnectorProvider = "MICROSOFT_365"
|
||||
ConnectorProviderGitLab ConnectorProvider = "GITLAB"
|
||||
ConnectorProviderBitbucket ConnectorProvider = "BITBUCKET"
|
||||
ConnectorProviderHeroku ConnectorProvider = "HEROKU"
|
||||
ConnectorProviderPagerDuty ConnectorProvider = "PAGERDUTY"
|
||||
ConnectorProviderAsana ConnectorProvider = "ASANA"
|
||||
ConnectorProviderSnyk ConnectorProvider = "SNYK"
|
||||
ConnectorProviderNetlify ConnectorProvider = "NETLIFY"
|
||||
ConnectorProviderRamp ConnectorProvider = "RAMP"
|
||||
ConnectorProviderClickUp ConnectorProvider = "CLICKUP"
|
||||
ConnectorProviderVercel ConnectorProvider = "VERCEL"
|
||||
ConnectorProviderMonday ConnectorProvider = "MONDAY"
|
||||
ConnectorProviderLever ConnectorProvider = "LEVER"
|
||||
ConnectorProviderDeel ConnectorProvider = "DEEL"
|
||||
)
|
||||
|
||||
func ConnectorProviders() []ConnectorProvider {
|
||||
@@ -61,6 +74,19 @@ func ConnectorProviders() []ConnectorProvider {
|
||||
ConnectorProviderIntercom,
|
||||
ConnectorProviderResend,
|
||||
ConnectorProviderMicrosoft365,
|
||||
ConnectorProviderGitLab,
|
||||
ConnectorProviderBitbucket,
|
||||
ConnectorProviderHeroku,
|
||||
ConnectorProviderPagerDuty,
|
||||
ConnectorProviderAsana,
|
||||
ConnectorProviderSnyk,
|
||||
ConnectorProviderNetlify,
|
||||
ConnectorProviderRamp,
|
||||
ConnectorProviderClickUp,
|
||||
ConnectorProviderVercel,
|
||||
ConnectorProviderMonday,
|
||||
ConnectorProviderLever,
|
||||
ConnectorProviderDeel,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +140,32 @@ func (cp *ConnectorProvider) Scan(value any) error {
|
||||
*cp = ConnectorProviderResend
|
||||
case "MICROSOFT_365":
|
||||
*cp = ConnectorProviderMicrosoft365
|
||||
case "GITLAB":
|
||||
*cp = ConnectorProviderGitLab
|
||||
case "BITBUCKET":
|
||||
*cp = ConnectorProviderBitbucket
|
||||
case "HEROKU":
|
||||
*cp = ConnectorProviderHeroku
|
||||
case "PAGERDUTY":
|
||||
*cp = ConnectorProviderPagerDuty
|
||||
case "ASANA":
|
||||
*cp = ConnectorProviderAsana
|
||||
case "SNYK":
|
||||
*cp = ConnectorProviderSnyk
|
||||
case "NETLIFY":
|
||||
*cp = ConnectorProviderNetlify
|
||||
case "RAMP":
|
||||
*cp = ConnectorProviderRamp
|
||||
case "CLICKUP":
|
||||
*cp = ConnectorProviderClickUp
|
||||
case "VERCEL":
|
||||
*cp = ConnectorProviderVercel
|
||||
case "MONDAY":
|
||||
*cp = ConnectorProviderMonday
|
||||
case "LEVER":
|
||||
*cp = ConnectorProviderLever
|
||||
case "DEEL":
|
||||
*cp = ConnectorProviderDeel
|
||||
default:
|
||||
return fmt.Errorf("invalid ConnectorProvider value: %q", s)
|
||||
}
|
||||
|
||||
@@ -49,6 +49,42 @@ type (
|
||||
AccountID string `json:"account_id"`
|
||||
Region string `json:"region"`
|
||||
}
|
||||
|
||||
GitLabConnectorSettings struct {
|
||||
GroupID string `json:"group_id"`
|
||||
}
|
||||
|
||||
BitbucketConnectorSettings struct {
|
||||
Workspace string `json:"workspace"`
|
||||
}
|
||||
|
||||
HerokuConnectorSettings struct {
|
||||
TeamID string `json:"team_id"`
|
||||
}
|
||||
|
||||
PagerDutyConnectorSettings struct {
|
||||
Subdomain string `json:"subdomain"`
|
||||
}
|
||||
|
||||
AsanaConnectorSettings struct {
|
||||
WorkspaceGID string `json:"workspace_gid"`
|
||||
}
|
||||
|
||||
SnykConnectorSettings struct {
|
||||
OrgID string `json:"org_id"`
|
||||
}
|
||||
|
||||
NetlifyConnectorSettings struct {
|
||||
AccountSlug string `json:"account_slug"`
|
||||
}
|
||||
|
||||
ClickUpConnectorSettings struct {
|
||||
TeamID string `json:"team_id"`
|
||||
}
|
||||
|
||||
VercelConnectorSettings struct {
|
||||
TeamID string `json:"team_id"`
|
||||
}
|
||||
)
|
||||
|
||||
// SetSettings marshals a typed settings struct into the connector's RawSettings.
|
||||
@@ -124,6 +160,87 @@ func (c *Connector) OnePasswordUsersAPISettings() (OnePasswordUsersAPISettings,
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// GitLabSettings unmarshals the connector's RawSettings into GitLabConnectorSettings.
|
||||
func (c *Connector) GitLabSettings() (GitLabConnectorSettings, error) {
|
||||
var s GitLabConnectorSettings
|
||||
if err := c.unmarshalSettings(&s); err != nil {
|
||||
return s, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// BitbucketSettings unmarshals the connector's RawSettings into BitbucketConnectorSettings.
|
||||
func (c *Connector) BitbucketSettings() (BitbucketConnectorSettings, error) {
|
||||
var s BitbucketConnectorSettings
|
||||
if err := c.unmarshalSettings(&s); err != nil {
|
||||
return s, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// HerokuSettings unmarshals the connector's RawSettings into HerokuConnectorSettings.
|
||||
func (c *Connector) HerokuSettings() (HerokuConnectorSettings, error) {
|
||||
var s HerokuConnectorSettings
|
||||
if err := c.unmarshalSettings(&s); err != nil {
|
||||
return s, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// PagerDutySettings unmarshals the connector's RawSettings into PagerDutyConnectorSettings.
|
||||
func (c *Connector) PagerDutySettings() (PagerDutyConnectorSettings, error) {
|
||||
var s PagerDutyConnectorSettings
|
||||
if err := c.unmarshalSettings(&s); err != nil {
|
||||
return s, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// AsanaSettings unmarshals the connector's RawSettings into AsanaConnectorSettings.
|
||||
func (c *Connector) AsanaSettings() (AsanaConnectorSettings, error) {
|
||||
var s AsanaConnectorSettings
|
||||
if err := c.unmarshalSettings(&s); err != nil {
|
||||
return s, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// SnykSettings unmarshals the connector's RawSettings into SnykConnectorSettings.
|
||||
func (c *Connector) SnykSettings() (SnykConnectorSettings, error) {
|
||||
var s SnykConnectorSettings
|
||||
if err := c.unmarshalSettings(&s); err != nil {
|
||||
return s, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// NetlifySettings unmarshals the connector's RawSettings into NetlifyConnectorSettings.
|
||||
func (c *Connector) NetlifySettings() (NetlifyConnectorSettings, error) {
|
||||
var s NetlifyConnectorSettings
|
||||
if err := c.unmarshalSettings(&s); err != nil {
|
||||
return s, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// ClickUpSettings unmarshals the connector's RawSettings into ClickUpConnectorSettings.
|
||||
func (c *Connector) ClickUpSettings() (ClickUpConnectorSettings, error) {
|
||||
var s ClickUpConnectorSettings
|
||||
if err := c.unmarshalSettings(&s); err != nil {
|
||||
return s, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// VercelSettings unmarshals the connector's RawSettings into VercelConnectorSettings.
|
||||
func (c *Connector) VercelSettings() (VercelConnectorSettings, error) {
|
||||
var s VercelConnectorSettings
|
||||
if err := c.unmarshalSettings(&s); err != nil {
|
||||
return s, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (c *Connector) unmarshalSettings(v any) error {
|
||||
if len(c.RawSettings) == 0 || string(c.RawSettings) == "null" {
|
||||
return nil
|
||||
|
||||
@@ -35,6 +35,12 @@ type ConnectorConfig struct {
|
||||
type ConnectorConfigOAuth2 struct {
|
||||
ClientID string `json:"client-id"`
|
||||
ClientSecret string `json:"client-secret"`
|
||||
// IntegrationSlug is an operator-supplied value substituted into
|
||||
// providers whose static AuthURL contains a "{integration_slug}"
|
||||
// placeholder (Vercel-style integrations). It is propagated onto
|
||||
// OAuth2Connector.AuthURLParams and resolved by
|
||||
// connector.ApplyProviderDefaults.
|
||||
IntegrationSlug string `json:"integration-slug,omitempty"`
|
||||
}
|
||||
|
||||
func (c *Config) GetSlackSigningSecret() string {
|
||||
@@ -89,6 +95,12 @@ func (c *ConnectorConfig) UnmarshalJSON(data []byte) error {
|
||||
ClientSecret: config.ClientSecret,
|
||||
}
|
||||
|
||||
if config.IntegrationSlug != "" {
|
||||
oauth2Connector.AuthURLParams = map[string]string{
|
||||
"integration_slug": config.IntegrationSlug,
|
||||
}
|
||||
}
|
||||
|
||||
c.Config = &oauth2Connector
|
||||
default:
|
||||
return fmt.Errorf("unknown connector protocol: %q", c.Protocol)
|
||||
|
||||
@@ -31,6 +31,23 @@ enum ConnectorProvider
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderMicrosoft365"
|
||||
)
|
||||
GITLAB @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderGitLab")
|
||||
BITBUCKET
|
||||
@goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderBitbucket")
|
||||
HEROKU @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderHeroku")
|
||||
PAGERDUTY
|
||||
@goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderPagerDuty")
|
||||
ASANA @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderAsana")
|
||||
SNYK @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderSnyk")
|
||||
NETLIFY
|
||||
@goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderNetlify")
|
||||
RAMP @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderRamp")
|
||||
CLICKUP
|
||||
@goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderClickUp")
|
||||
VERCEL @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderVercel")
|
||||
MONDAY @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderMonday")
|
||||
LEVER @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderLever")
|
||||
DEEL @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderDeel")
|
||||
}
|
||||
|
||||
type ConnectorProviderInfo {
|
||||
|
||||
Reference in New Issue
Block a user