diff --git a/pkg/accessreview/drivers/name_resolver.go b/pkg/accessreview/drivers/name_resolver.go index 7593a62a6..746d9433e 100644 --- a/pkg/accessreview/drivers/name_resolver.go +++ b/pkg/accessreview/drivers/name_resolver.go @@ -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/ 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 diff --git a/pkg/accessreview/drivers/oauth2_scopes.go b/pkg/accessreview/drivers/oauth2_scopes.go index 076623501..937e7e087 100644 --- a/pkg/accessreview/drivers/oauth2_scopes.go +++ b/pkg/accessreview/drivers/oauth2_scopes.go @@ -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 diff --git a/pkg/connector/oauth2.go b/pkg/connector/oauth2.go index 510f2d688..47c1aef25 100644 --- a/pkg/connector/oauth2.go +++ b/pkg/connector/oauth2.go @@ -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, diff --git a/pkg/connector/oauth2_test.go b/pkg/connector/oauth2_test.go index 044954781..825d4a1a2 100644 --- a/pkg/connector/oauth2_test.go +++ b/pkg/connector/oauth2_test.go @@ -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) + }) +} diff --git a/pkg/connector/providers.go b/pkg/connector/providers.go index e429476e7..32b41fb1b 100644 --- a/pkg/connector/providers.go +++ b/pkg/connector/providers.go @@ -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) + } } } diff --git a/pkg/connector/registry.go b/pkg/connector/registry.go index 5aa426c6f..deb4221de 100644 --- a/pkg/connector/registry.go +++ b/pkg/connector/registry.go @@ -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", } ) diff --git a/pkg/coredata/connector_provider.go b/pkg/coredata/connector_provider.go index 320126d2a..76499c887 100644 --- a/pkg/coredata/connector_provider.go +++ b/pkg/coredata/connector_provider.go @@ -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) } diff --git a/pkg/coredata/connector_settings.go b/pkg/coredata/connector_settings.go index 571444c71..e51175d20 100644 --- a/pkg/coredata/connector_settings.go +++ b/pkg/coredata/connector_settings.go @@ -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 diff --git a/pkg/probodconfig/connector_config.go b/pkg/probodconfig/connector_config.go index dad00f396..b7fad3360 100644 --- a/pkg/probodconfig/connector_config.go +++ b/pkg/probodconfig/connector_config.go @@ -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) diff --git a/pkg/server/api/console/v1/graphql/connector.graphql b/pkg/server/api/console/v1/graphql/connector.graphql index 27babf9fc..43beeb69f 100644 --- a/pkg/server/api/console/v1/graphql/connector.graphql +++ b/pkg/server/api/console/v1/graphql/connector.graphql @@ -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 {