Repository: amp-labs/connectors (go)

Issue:
Add Jira deep connector
- GetPostAuthInfo
- ListObjectMetadata
- Read
- Write


Pull request:
feat: Deep connector Jira (AuthInfo)
Closes #709

# Description
This adds Jira connector with an ability to retreieve metadata information describing authentication.
CloudID is the only property of concern. This will be used when building API URL paths.
PostAuthInfo returns CloudID **based on the workspace name**.



Patch:
diff --git a/atlassian/authmetadata.go b/atlassian/authmetadata.go
new file mode 100644
index 00000000..3b8d45f5
--- /dev/null
+++ b/atlassian/authmetadata.go
@@ -0,0 +1,94 @@
+package atlassian
+
+import (
+	"context"
+	"errors"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/jsonquery"
+)
+
+var (
+	ErrDiscoveryFailure  = errors.New("failed to collect post authentication data")
+	ErrContainerNotFound = errors.New("cloud container was not found for chosen workspace")
+)
+
+func (c *Connector) GetPostAuthInfo(ctx context.Context) (*common.PostAuthInfo, error) {
+	cloudId, err := c.retrieveCloudId(ctx)
+	if err != nil {
+		return nil, errors.Join(ErrDiscoveryFailure, err)
+	}
+
+	return &common.PostAuthInfo{
+		CatalogVars: AuthMetadataVars{
+			CloudId: cloudId,
+		}.AsMap(),
+		RawResponse: nil,
+	}, nil
+}
+
+// retrieveCloudId gives CloudId for the workspace.
+// Cloud ID will be used to build URL paths.
+// After authentication completes we can call introspect API to find this data.
+//
+// Request: Get Cloud ID.
+// Response example:
+// [
+//
+//	{
+//	    "id": "9e1477fd-54ef-41fe-b747-bc9e6a11a925",
+//	    "url": "https://{{workspaceRef}}.atlassian.net",
+//	    "name": "{{workspaceRef}}",
+//	    "scopes": [
+//	        "manage:jira-project",
+//	        "manage:jira-configuration",
+//	        "read:jira-work",
+//	        "manage:jira-webhook",
+//	        "write:jira-work",
+//	        "read:jira-user"
+//	    ],
+//	    "avatarUrl": "https://site-admin-avatar-cdn.prod.public.atl-paas.net/avatars/240/pencilmarker.png"
+//	}
+//
+// ].
+func (c *Connector) retrieveCloudId(ctx context.Context) (string, error) {
+	url, err := c.getAccessibleSitesURL()
+	if err != nil {
+		return "", err
+	}
+
+	res, err := c.Client.Get(ctx, url.String())
+	if err != nil {
+		return "", err
+	}
+
+	if res.Body == nil {
+		return "", common.ErrEmptyJSONHTTPResponse
+	}
+
+	arr, err := res.Body.GetArray()
+	if err != nil {
+		return "", err
+	}
+
+	for _, item := range arr {
+		workspaceName, err := jsonquery.New(item).Str("name", false)
+		if err != nil {
+			return "", err
+		}
+
+		if *workspaceName == c.workspace {
+			// names match, select this container.
+			cloudID, err := jsonquery.New(item).Str("id", false)
+			if err != nil {
+				return "", err
+			}
+
+			return *cloudID, nil
+		}
+	}
+
+	// The container that matches connectors workspace was not found.
+	// Hence, we couldn't resolve cloud id.
+	return "", ErrContainerNotFound
+}
diff --git a/atlassian/authmetamodel.go b/atlassian/authmetamodel.go
new file mode 100644
index 00000000..6ce0d471
--- /dev/null
+++ b/atlassian/authmetamodel.go
@@ -0,0 +1,23 @@
+package atlassian
+
+const cloudIdKey = "cloudId"
+
+// AuthMetadataVars is a complete list of authentication metadata associated with connector.
+// This model serves as a documentation of map[string]string contents.
+type AuthMetadataVars struct {
+	CloudId string
+}
+
+// NewAuthMetadataVars parses map into the model.
+func NewAuthMetadataVars(dictionary map[string]string) *AuthMetadataVars {
+	return &AuthMetadataVars{
+		CloudId: dictionary[cloudIdKey],
+	}
+}
+
+// AsMap converts model back to the map.
+func (v AuthMetadataVars) AsMap() *map[string]string {
+	return &map[string]string{
+		cloudIdKey: v.CloudId,
+	}
+}
diff --git a/atlassian/client.go b/atlassian/client.go
new file mode 100644
index 00000000..6245f8be
--- /dev/null
+++ b/atlassian/client.go
@@ -0,0 +1,16 @@
+package atlassian
+
+import "github.com/amp-labs/connectors/common"
+
+// JSONHTTPClient returns the underlying JSON HTTP client.
+func (c *Connector) JSONHTTPClient() *common.JSONHTTPClient {
+	return c.Client
+}
+
+func (c *Connector) HTTPClient() *common.HTTPClient {
+	return c.Client.HTTPClient
+}
+
+func (c *Connector) Close() error {
+	return nil
+}
diff --git a/atlassian/connector.go b/atlassian/connector.go
new file mode 100644
index 00000000..b7116434
--- /dev/null
+++ b/atlassian/connector.go
@@ -0,0 +1,78 @@
+package atlassian
+
+import (
+	"fmt"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/interpreter"
+	"github.com/amp-labs/connectors/common/paramsbuilder"
+	"github.com/amp-labs/connectors/common/urlbuilder"
+	"github.com/amp-labs/connectors/providers"
+)
+
+type Connector struct {
+	Client  *common.JSONHTTPClient
+	BaseURL string
+	Module  string
+	// workspace is used to find cloud ID.
+	workspace string
+	cloudId   string
+}
+
+func NewConnector(opts ...Option) (conn *Connector, outErr error) {
+	defer common.PanicRecovery(func(cause error) {
+		outErr = cause
+		conn = nil
+	})
+
+	params, err := paramsbuilder.Apply(parameters{}, opts)
+	if err != nil {
+		return nil, err
+	}
+
+	httpClient := params.Client.Caller
+	conn = &Connector{
+		Client: &common.JSONHTTPClient{
+			HTTPClient: httpClient,
+		},
+		workspace: params.Workspace.Name,
+		Module:    params.Module.Name,
+	}
+
+	// Convert metadata map to model.
+	authMetadata := NewAuthMetadataVars(params.Metadata.Map)
+	conn.cloudId = authMetadata.CloudId
+
+	// Read provider info
+	providerInfo, err := providers.ReadInfo(conn.Provider())
+	if err != nil {
+		return nil, err
+	}
+
+	// connector and its client must mirror base url and provide its own error parser
+	conn.setBaseURL(providerInfo.BaseURL)
+	conn.Client.HTTPClient.ErrorHandler = interpreter.ErrorHandler{
+		JSON: conn.interpretJSONError,
+	}.Handle
+
+	return conn, nil
+}
+
+func (c *Connector) Provider() providers.Provider {
+	return providers.Atlassian
+}
+
+func (c *Connector) String() string {
+	return fmt.Sprintf("%s.Connector[%s]", c.Provider(), c.Module)
+}
+
+// URL allows to get list of sites associated with auth token.
+// https://developer.atlassian.com/cloud/confluence/oauth-2-3lo-apps/#3-1-get-the-cloudid-for-your-site
+func (c *Connector) getAccessibleSitesURL() (*urlbuilder.URL, error) {
+	return constructURL(c.BaseURL, "oauth/token/accessible-resources")
+}
+
+func (c *Connector) setBaseURL(newURL string) {
+	c.BaseURL = newURL
+	c.Client.HTTPClient.Base = newURL
+}
diff --git a/atlassian/errors.go b/atlassian/errors.go
new file mode 100644
index 00000000..9408f2d0
--- /dev/null
+++ b/atlassian/errors.go
@@ -0,0 +1,76 @@
+package atlassian
+
+import (
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"strings"
+	"time"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/interpreter"
+)
+
+func (*Connector) interpretJSONError(res *http.Response, body []byte) error { //nolint:cyclop
+	payload := make(map[string]any)
+	if err := json.Unmarshal(body, &payload); err != nil {
+		return fmt.Errorf("interpretJSONError general: %w %w", interpreter.ErrUnmarshal, err)
+	}
+
+	// now we can choose which error response Schema we expect
+	var schema common.ErrorDescriptor
+
+	if _, ok := payload["status"]; ok {
+		apiError := &ResponseStatusError{}
+		if err := json.Unmarshal(body, &apiError); err != nil {
+			return fmt.Errorf("interpretJSONError SingleError: %w %w", interpreter.ErrUnmarshal, err)
+		}
+
+		schema = apiError
+	} else {
+		apiError := &ResponseMessagesError{}
+		if err := json.Unmarshal(body, &apiError); err != nil {
+			return fmt.Errorf("interpretJSONError MessagesError: %w %w", interpreter.ErrUnmarshal, err)
+		}
+
+		schema = apiError
+	}
+
+	// enhance status code error with response payload
+	return schema.CombineErr(interpreter.DefaultStatusCodeMappingToErr(res, body))
+}
+
+type ResponseMessagesError struct {
+	ErrorMessages   []string `json:"errorMessages"`
+	WarningMessages []string `json:"warningMessages"`
+}
+
+func (r ResponseMessagesError) CombineErr(base error) error {
+	if len(r.ErrorMessages) == 0 {
+		return base
+	}
+
+	message := strings.Join(r.ErrorMessages, ",")
+
+	return fmt.Errorf("%w: %v", base, message)
+}
+
+type ResponseStatusError struct {
+	Status    int       `json:"status"`
+	Error     string    `json:"error"`
+	Message   string    `json:"message"`
+	Path      string    `json:"path"`
+	Timestamp time.Time `json:"timestamp"`
+}
+
+func (r ResponseStatusError) CombineErr(base error) error {
+	if len(r.Error) == 0 {
+		return base
+	}
+
+	if len(r.Message) == 0 {
+		return fmt.Errorf("%w: %v", base, r.Error)
+	}
+
+	return fmt.Errorf("%w: %v - %v", base, r.Error, r.Message)
+}
diff --git a/atlassian/modules.go b/atlassian/modules.go
new file mode 100644
index 00000000..002cac5d
--- /dev/null
+++ b/atlassian/modules.go
@@ -0,0 +1,24 @@
+package atlassian
+
+import (
+	"github.com/amp-labs/connectors/common/paramsbuilder"
+)
+
+// ModuleJira is the module used for listing Jira issues.
+var ModuleJira = paramsbuilder.APIModule{ // nolint: gochecknoglobals
+	Label:   "rest/api",
+	Version: "3",
+}
+
+// ModuleEmpty is used for proxying requests through.
+var ModuleEmpty = paramsbuilder.APIModule{ // nolint: gochecknoglobals
+	Label:   "",
+	Version: "",
+}
+
+// supportedModules represents currently working and supported modules within the Atlassian connector.
+// Any added module should be appended here.
+var supportedModules = []paramsbuilder.APIModule{ // nolint: gochecknoglobals
+	ModuleEmpty,
+	ModuleJira,
+}
diff --git a/atlassian/params.go b/atlassian/params.go
new file mode 100644
index 00000000..ff0962fb
--- /dev/null
+++ b/atlassian/params.go
@@ -0,0 +1,65 @@
+package atlassian
+
+import (
+	"context"
+	"errors"
+	"net/http"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/paramsbuilder"
+	"golang.org/x/oauth2"
+)
+
+// Option is a function which mutates the connector configuration.
+type Option = func(params *parameters)
+
+// parameters surface options by delegation.
+type parameters struct {
+	paramsbuilder.Client
+	paramsbuilder.Workspace
+	paramsbuilder.Module
+	paramsbuilder.Metadata
+}
+
+func (p parameters) ValidateParams() error {
+	return errors.Join(
+		p.Client.ValidateParams(),
+		p.Workspace.ValidateParams(),
+		// Metadata parameter is optional.
+		p.Module.ValidateParams(),
+	)
+}
+
+func WithClient(ctx context.Context, client *http.Client,
+	config *oauth2.Config, token *oauth2.Token, opts ...common.OAuthOption,
+) Option {
+	return func(params *parameters) {
+		params.WithOauthClient(ctx, client, config, token, opts...)
+	}
+}
+
+func WithAuthenticatedClient(client common.AuthenticatedHTTPClient) Option {
+	return func(params *parameters) {
+		params.WithAuthenticatedClient(client)
+	}
+}
+
+func WithWorkspace(workspaceRef string) Option {
+	return func(params *parameters) {
+		params.WithWorkspace(workspaceRef)
+	}
+}
+
+// WithModule sets the Atlassian API module to use for the connector. It's required.
+func WithModule(module paramsbuilder.APIModule) Option {
+	return func(params *parameters) {
+		params.WithModule(module, supportedModules, &ModuleEmpty)
+	}
+}
+
+// WithMetadata sets authentication metadata expected by connector.
+func WithMetadata(metadata map[string]string) Option {
+	return func(params *parameters) {
+		params.WithMetadata(metadata, nil)
+	}
+}
diff --git a/atlassian/url.go b/atlassian/url.go
new file mode 100644
index 00000000..ac883b91
--- /dev/null
+++ b/atlassian/url.go
@@ -0,0 +1,18 @@
+package atlassian
+
+import "github.com/amp-labs/connectors/common/urlbuilder"
+
+var queryEncodingExceptions = map[string]string{ //nolint:gochecknoglobals
+	// none
+}
+
+func constructURL(base string, path ...string) (*urlbuilder.URL, error) {
+	link, err := urlbuilder.New(base, path...)
+	if err != nil {
+		return nil, err
+	}
+
+	link.AddEncodingExceptions(queryEncodingExceptions)
+
+	return link, nil
+}
diff --git a/docusign/authmetamodel.go b/docusign/authmetamodel.go
index 18713ce1..f5163350 100644
--- a/docusign/authmetamodel.go
+++ b/docusign/authmetamodel.go
@@ -4,9 +4,11 @@ import (
 	"github.com/amp-labs/connectors/common/paramsbuilder"
 )
 
+const serverKey = "server"
+
 // Metadata fields that must be specified to initialize connector.
 var requiredMetadataFields = []string{ // nolint:gochecknoglobals
-	"server",
+	serverKey,
 }
 
 // AuthMetadataVars is a complete list of authentication metadata associated with connector.
@@ -18,14 +20,14 @@ type AuthMetadataVars struct {
 // NewAuthMetadataVars parses map into the model.
 func NewAuthMetadataVars(dictionary map[string]string) *AuthMetadataVars {
 	return &AuthMetadataVars{
-		Server: dictionary["server"],
+		Server: dictionary[serverKey],
 	}
 }
 
 // AsMap converts model back to the map.
 func (v AuthMetadataVars) AsMap() *map[string]string {
 	return &map[string]string{
-		"server": v.Server,
+		serverKey: v.Server,
 	}
 }
 
@@ -33,7 +35,7 @@ func (v AuthMetadataVars) AsMap() *map[string]string {
 // Only server URL is supported.
 func (v AuthMetadataVars) GetSubstitutionPlan() paramsbuilder.SubstitutionPlan {
 	return paramsbuilder.SubstitutionPlan{
-		From: "server",
+		From: serverKey,
 		To:   v.Server,
 	}
 }


Test patch:
diff --git a/atlassian/authmetadata_test.go b/atlassian/authmetadata_test.go
new file mode 100644
index 00000000..51bbae3d
--- /dev/null
+++ b/atlassian/authmetadata_test.go
@@ -0,0 +1,132 @@
+package atlassian
+
+import (
+	"context"
+	"errors"
+	"net/http"
+	"net/http/httptest"
+	"reflect"
+	"strings"
+	"testing"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/interpreter"
+	"github.com/amp-labs/connectors/test/utils/mockutils"
+	"github.com/amp-labs/connectors/test/utils/mockutils/mockserver"
+	"github.com/amp-labs/connectors/test/utils/testutils"
+	"github.com/go-test/deep"
+)
+
+func TestGetPostAuthInfo(t *testing.T) { //nolint:funlen,gocognit,cyclop,maintidx
+	t.Parallel()
+
+	responseCloudID := testutils.DataFromFile(t, "cloud-id.json")
+
+	tests := []struct {
+		name         string
+		server       *httptest.Server
+		expected     *common.PostAuthInfo
+		expectedErrs []error
+	}{
+		{
+			name:   "Mime response header expected",
+			server: mockserver.Dummy(),
+			expectedErrs: []error{
+				interpreter.ErrMissingContentType,
+				ErrDiscoveryFailure,
+			},
+		},
+		{
+			name: "Response should be an array",
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				w.WriteHeader(http.StatusOK)
+				mockutils.WriteBody(w, `{}`)
+			})),
+			expectedErrs: []error{
+				ErrDiscoveryFailure,
+			},
+		},
+		{
+			name: "Empty container list, missing cloud id",
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				w.WriteHeader(http.StatusOK)
+				mockutils.WriteBody(w, `[]`)
+			})),
+			expectedErrs: []error{
+				ErrContainerNotFound,
+				ErrDiscoveryFailure,
+			},
+		},
+		{
+			name: "Workspace is matched against container, success locating cloud id",
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				w.WriteHeader(http.StatusOK)
+				// response file has workspace that we set up in the constructor
+				_, _ = w.Write(responseCloudID)
+			})),
+			expected: &common.PostAuthInfo{
+				CatalogVars: &map[string]string{
+					"cloudId": "ebc887b2-7e61-4059-ab35-71f15cc16e12",
+				},
+				RawResponse: nil,
+			},
+			expectedErrs: nil,
+		},
+	}
+
+	for _, tt := range tests {
+		// nolint:varnamelen
+		tt := tt // rebind, omit loop side effects for parallel goroutine
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
+			defer tt.server.Close()
+
+			ctx := context.Background()
+
+			connector, err := NewConnector(
+				WithAuthenticatedClient(http.DefaultClient),
+				WithWorkspace("second-proj"),
+				WithModule(ModuleJira),
+			)
+			if err != nil {
+				t.Fatalf("%s: error in test while constructing connector %v", tt.name, err)
+			}
+
+			// for testing we want to redirect calls to our mock server
+			connector.setBaseURL(tt.server.URL)
+
+			if err != nil {
+				t.Fatalf("%s: failed to setup auth metadata connector %v", tt.name, err)
+			}
+
+			// start of tests
+			output, err := connector.GetPostAuthInfo(ctx)
+			if err != nil {
+				if len(tt.expectedErrs) == 0 {
+					t.Fatalf("%s: expected no errors, got: (%v)", tt.name, err)
+				}
+			} else {
+				// check that missing error is what is expected
+				if len(tt.expectedErrs) != 0 {
+					t.Fatalf("%s: expected errors (%v), but got nothing", tt.name, tt.expectedErrs)
+				}
+			}
+
+			// check every error
+			for _, expectedErr := range tt.expectedErrs {
+				if !errors.Is(err, expectedErr) && !strings.Contains(err.Error(), expectedErr.Error()) {
+					t.Fatalf("%s: expected Error: (%v), got: (%v)", tt.name, expectedErr, err)
+				}
+			}
+
+			if !reflect.DeepEqual(output, tt.expected) {
+				diff := deep.Equal(output, tt.expected)
+				t.Fatalf("%s:, \nexpected: (%v), \ngot: (%v), \ndiff: (%v)", tt.name, tt.expected, output, diff)
+			}
+		})
+	}
+}
diff --git a/atlassian/test/cloud-id.json b/atlassian/test/cloud-id.json
new file mode 100644
index 00000000..2185e7cb
--- /dev/null
+++ b/atlassian/test/cloud-id.json
@@ -0,0 +1,30 @@
+[
+  {
+    "id": "9e1477fd-54ef-41fe-b747-bc9e6a11a925",
+    "url": "https://first-proj.atlassian.net",
+    "name": "first-proj",
+    "scopes": [
+      "manage:jira-project",
+      "manage:jira-configuration",
+      "read:jira-work",
+      "manage:jira-webhook",
+      "write:jira-work",
+      "read:jira-user"
+    ],
+    "avatarUrl": "https://site-admin-avatar-cdn.prod.public.atl-paas.net/avatars/240/pencilmarker.png"
+  },
+  {
+    "id": "ebc887b2-7e61-4059-ab35-71f15cc16e12",
+    "url": "https://second-proj.atlassian.net",
+    "name": "second-proj",
+    "scopes": [
+      "manage:jira-project",
+      "manage:jira-configuration",
+      "read:jira-work",
+      "manage:jira-webhook",
+      "write:jira-work",
+      "read:jira-user"
+    ],
+    "avatarUrl": "https://site-admin-avatar-cdn.prod.public.atl-paas.net/avatars/240/rocket.png"
+  }
+]
diff --git a/test/atlassian/auth-metadata/main.go b/test/atlassian/auth-metadata/main.go
new file mode 100644
index 00000000..79f46e3c
--- /dev/null
+++ b/test/atlassian/auth-metadata/main.go
@@ -0,0 +1,38 @@
+package main
+
+import (
+	"context"
+	"log/slog"
+	"os/signal"
+	"syscall"
+
+	"github.com/amp-labs/connectors/atlassian"
+	connTest "github.com/amp-labs/connectors/test/atlassian"
+	"github.com/amp-labs/connectors/test/utils"
+)
+
+func main() {
+	// Handle Ctrl-C gracefully.
+	ctx, done := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
+	defer done()
+
+	// Set up slog logging.
+	utils.SetupLogging()
+
+	conn := connTest.GetAtlassianConnector(ctx)
+	defer utils.Close(conn)
+
+	info, err := conn.GetPostAuthInfo(ctx)
+	if err != nil || info.CatalogVars == nil {
+		utils.Fail("error obtaining auth info", "error", err)
+	}
+
+	metadata := atlassian.NewAuthMetadataVars(*info.CatalogVars)
+	cloudID := metadata.CloudId
+
+	if len(cloudID) == 0 {
+		utils.Fail("missing cloud id in post authentication metadata")
+	}
+
+	slog.Info("retrieved auth metadata", "cloud id", cloudID)
+}
diff --git a/test/atlassian/connector.go b/test/atlassian/connector.go
new file mode 100644
index 00000000..33d54bb3
--- /dev/null
+++ b/test/atlassian/connector.go
@@ -0,0 +1,59 @@
+package atlassian
+
+import (
+	"context"
+	"net/http"
+
+	"github.com/amp-labs/connectors/atlassian"
+	"github.com/amp-labs/connectors/common/scanning/credscanning"
+	"github.com/amp-labs/connectors/providers"
+	"github.com/amp-labs/connectors/test/utils"
+	"golang.org/x/oauth2"
+)
+
+const cloudId = "ebc887b2-7e61-4059-ab35-71f15cc16e12"
+
+func GetAtlassianConnector(ctx context.Context) *atlassian.Connector {
+	filePath := credscanning.LoadPath(providers.Atlassian)
+	reader := utils.MustCreateProvCredJSON(filePath, true, true)
+
+	conn, err := atlassian.NewConnector(
+		atlassian.WithClient(ctx, http.DefaultClient, getConfig(reader), reader.GetOauthToken()),
+		atlassian.WithWorkspace(reader.Get(credscanning.Fields.Workspace)),
+		atlassian.WithModule(atlassian.ModuleJira),
+		atlassian.WithMetadata(map[string]string{
+			// This value can be obtained by following this API reference.
+			// https://developer.atlassian.com/cloud/confluence/oauth-2-3lo-apps/#3-1-get-the-cloudid-for-your-site
+			"cloudId": cloudId,
+		}),
+	)
+	if err != nil {
+		utils.Fail("error creating connector", "error", err)
+	}
+
+	return conn
+}
+
+func getConfig(reader *credscanning.ProviderCredentials) *oauth2.Config {
+	cfg := &oauth2.Config{
+		ClientID:     reader.Get(credscanning.Fields.ClientId),
+		ClientSecret: reader.Get(credscanning.Fields.ClientSecret),
+		RedirectURL:  "http://localhost:8080/callbacks/v1/oauth",
+		Endpoint: oauth2.Endpoint{
+			AuthURL:   "https://auth.atlassian.com/authorize",
+			TokenURL:  "https://auth.atlassian.com/oauth/token",
+			AuthStyle: oauth2.AuthStyleInParams,
+		},
+		Scopes: []string{
+			"offline_access",
+			"read:jira-user",
+			"read:jira-work",
+			"write:jira-work",
+			"manage:jira-project",
+			"manage:jira-configuration",
+			"manage:jira-webhook",
+		},
+	}
+
+	return cfg
+}