From 57856e4749346fdda37e27d5bebd9754a1e0c6b9 Mon Sep 17 00:00:00 2001 From: Michael Kochell <6913320+mickmister@users.noreply.github.com> Date: Tue, 29 Aug 2023 17:49:39 -0400 Subject: [PATCH] MM-51310 Implement OAuth2 Authentication (reopened) (#949) * Initial commit to support OAuth2 authentication * Implemented modal to configure OAuth2 credentials * Made the field names consistent * Removed install_cloud_oauth.md template * Added telemetry tracking for oauth2 setup flow * Fixed typo * Implemented few review comments * Reverted webapp changes * Reverted package-lock.json file * Fixed depreciation * Using refresh token to get another access token * Implemented review comments & QA findings * Implemented few additional review comments * fixed enterprise check while updating instances * Renamed function name for easy understanding * Fixed failing instances test * fix merge issue * fix merge issue 2 * check for cloud oauth instance for comment webhooks * Fixed issue: Comment notification not working after implementing OAuth2 and Added PKCE code for OAuth2 (#953) * Migrate Docs from GitBook (#948) * Migrate Docs from GitBook * Update readme.md * Update readme.md Co-authored-by: Katie Wiersgalla <39744472+wiersgallak@users.noreply.github.com> * Update readme.md Co-authored-by: Katie Wiersgalla <39744472+wiersgallak@users.noreply.github.com> * Update readme.md Co-authored-by: Katie Wiersgalla <39744472+wiersgallak@users.noreply.github.com> --------- Co-authored-by: Katie Wiersgalla <39744472+wiersgallak@users.noreply.github.com> * [MI-3153] Fixed issue: comment webhook notification not working * [MI-3153] Added PKCE along with authorization code for getting the access token * [MI-3153] Review fixes * [MI-3159] Review fixes of comments given by Kshitij * [MI-3159] Review fixes given by Ayush * [MI-3159] Removed the code which was forcing the template window to close after user is connected * [MI-3153] Review fixes * [MI-3159] Review fixes * [MI-3159] Review fix * [MI-3282] Added logic to expand issue and issue with DM notification for comment webhook notification (#58) * [MI-3282] Added logic to expand issue in comment webhook notification * [MI-3282] Fix lint errors * [MI-3315] Fixed issue: comment DM notification not working due to invalid API call * [MI-3282] Review fixes * [MI-3282] Review fix * [MI-3331] Review fixes on PR #953 (Comment notification issue) (#60) * [MI-3331] Review fixes on PR #953 (Comment notification issue) * [MI-3331] Added separate condition to check if the instance is a cloud instance or not * [MI-3335] Review fixes on Jira PR #953(Comment notification issue) (#62) 1. Added conditional for separation out the logic for jira cloud and server --------- Co-authored-by: Carrie Warner (Mattermost) <74422101+cwarnermm@users.noreply.github.com> Co-authored-by: Katie Wiersgalla <39744472+wiersgallak@users.noreply.github.com> --------- Co-authored-by: Rohitesh Gupta <1429138+srkgupta@users.noreply.github.com> Co-authored-by: Mattermost Build Co-authored-by: Raghav Aggarwal Co-authored-by: Carrie Warner (Mattermost) <74422101+cwarnermm@users.noreply.github.com> Co-authored-by: Katie Wiersgalla <39744472+wiersgallak@users.noreply.github.com> --- assets/templates/oauth2/complete.html | 77 +++++++++ readme.md | 1 - server/client.go | 8 +- server/command.go | 142 +++++++++++----- server/http.go | 4 + server/instance.go | 10 +- server/instance_cloud.go | 4 + server/instance_cloud_oauth.go | 220 +++++++++++++++++++++++++ server/instance_server.go | 4 + server/instances.go | 13 +- server/instances_test.go | 2 +- server/issue.go | 66 ++++---- server/issue_parser.go | 16 +- server/kv.go | 25 +-- server/kv_mock_test.go | 4 + server/plugin.go | 4 +- server/setup_flow.go | 223 ++++++++++++++++++-------- server/user.go | 9 +- server/user_cloud_oauth.go | 118 ++++++++++++++ server/utils.go | 22 +++ server/webhook.go | 6 +- server/webhook_jira.go | 69 +++++--- 22 files changed, 844 insertions(+), 203 deletions(-) create mode 100644 assets/templates/oauth2/complete.html create mode 100644 server/instance_cloud_oauth.go create mode 100644 server/user_cloud_oauth.go diff --git a/assets/templates/oauth2/complete.html b/assets/templates/oauth2/complete.html new file mode 100644 index 000000000..e32b40f58 --- /dev/null +++ b/assets/templates/oauth2/complete.html @@ -0,0 +1,77 @@ + + + + + + + +
+

+ + + + Mattermost user is now connected to Jira +

+
+
Mattermost account: {{ .MattermostDisplayName }}
+
Jira account: {{ .JiraDisplayName }}
+
It is now safe to close this browser window.
+
+ Close + Disconnect +
+ + diff --git a/readme.md b/readme.md index e74c1311d..d6d91296c 100644 --- a/readme.md +++ b/readme.md @@ -114,7 +114,6 @@ To control Mattermost channel subscriptions, use the `/jira subscribe` command i * created * updated * deleted - 6. Choose **Save**. Previously configured webhooks that point to specific channels are still supported and will continue to work. diff --git a/server/client.go b/server/client.go index b974c947e..6dad89342 100644 --- a/server/client.go +++ b/server/client.go @@ -310,12 +310,12 @@ func (client JiraClient) GetSelf() (*jira.User, error) { // MakeCreateIssueURL makes a URL that would take a browser to a pre-filled form // to file a new issue in Jira. func MakeCreateIssueURL(instance Instance, project *jira.Project, issue *jira.Issue) string { - u, err := url.Parse(fmt.Sprintf("%v/secure/CreateIssueDetails!init.jspa", instance.GetURL())) + url, err := url.Parse(fmt.Sprintf("%v/secure/CreateIssueDetails!init.jspa", instance.GetJiraBaseURL())) if err != nil { return "" } - q := u.Query() + q := url.Query() q.Add("pid", project.ID) q.Add("issuetype", issue.Fields.Type.ID) q.Add("summary", issue.Fields.Summary) @@ -344,8 +344,8 @@ func MakeCreateIssueURL(instance Instance, project *jira.Project, issue *jira.Is } } - u.RawQuery = q.Encode() - return u.String() + url.RawQuery = q.Encode() + return url.String() } // SearchUsersAssignableToIssue finds all users that can be assigned to an issue. diff --git a/server/command.go b/server/command.go index dbe91a750..8fc92fa5e 100644 --- a/server/command.go +++ b/server/command.go @@ -10,6 +10,7 @@ import ( "github.com/pkg/errors" "github.com/mattermost/mattermost-plugin-api/experimental/command" + "github.com/mattermost/mattermost-plugin-api/experimental/flow" "github.com/mattermost/mattermost-server/v6/model" "github.com/mattermost/mattermost-server/v6/plugin" @@ -22,37 +23,39 @@ const commandTrigger = "jira" var jiraCommandHandler = CommandHandler{ handlers: map[string]CommandHandlerFunc{ - "assign": executeAssign, - "connect": executeConnect, - "disconnect": executeDisconnect, - "help": executeHelp, - "me": executeMe, - "about": executeAbout, - "install/cloud": executeInstanceInstallCloud, - "install/server": executeInstanceInstallServer, - "instance/alias": executeInstanceAlias, - "instance/unalias": executeInstanceUnalias, - "instance/connect": executeConnect, - "instance/disconnect": executeDisconnect, - "instance/install/cloud": executeInstanceInstallCloud, - "instance/install/server": executeInstanceInstallServer, - "instance/list": executeInstanceList, - "instance/settings": executeSettings, - "instance/uninstall": executeInstanceUninstall, - "instance/v2": executeInstanceV2Legacy, - "issue/assign": executeAssign, - "issue/transition": executeTransition, - "issue/unassign": executeUnassign, - "issue/view": executeView, - "settings": executeSettings, - "subscribe/list": executeSubscribeList, - "transition": executeTransition, - "unassign": executeUnassign, - "uninstall": executeInstanceUninstall, - "view": executeView, - "v2revert": executeV2Revert, - "webhook": executeWebhookURL, - "setup": executeSetup, + "assign": executeAssign, + "connect": executeConnect, + "disconnect": executeDisconnect, + "help": executeHelp, + "me": executeMe, + "about": executeAbout, + "install/cloud": executeInstanceInstallCloud, + "install/cloud-oauth": executeInstanceInstallCloudOAuth, + "install/server": executeInstanceInstallServer, + "instance/alias": executeInstanceAlias, + "instance/unalias": executeInstanceUnalias, + "instance/connect": executeConnect, + "instance/disconnect": executeDisconnect, + "instance/install/cloud": executeInstanceInstallCloud, + "instance/install/cloud-oauth": executeInstanceInstallCloudOAuth, + "instance/install/server": executeInstanceInstallServer, + "instance/list": executeInstanceList, + "instance/settings": executeSettings, + "instance/uninstall": executeInstanceUninstall, + "instance/v2": executeInstanceV2Legacy, + "issue/assign": executeAssign, + "issue/transition": executeTransition, + "issue/unassign": executeUnassign, + "issue/view": executeView, + "settings": executeSettings, + "subscribe/list": executeSubscribeList, + "transition": executeTransition, + "unassign": executeUnassign, + "uninstall": executeInstanceUninstall, + "view": executeView, + "v2revert": executeV2Revert, + "webhook": executeWebhookURL, + "setup": executeSetup, }, defaultHandler: executeJiraDefault, } @@ -78,11 +81,13 @@ const commonHelpText = "\n" + const sysAdminHelpText = "\n###### For System Administrators:\n" + "Install Jira instances:\n" + - "* `/jira instance install cloud [jiraURL]` - Connect Mattermost to a Jira Cloud instance located at \n" + "* `/jira instance install server [jiraURL]` - Connect Mattermost to a Jira Server or Data Center instance located at \n" + + "* `/jira instance install cloud-oauth [jiraURL]` - Connect Mattermost to a Jira Cloud instance using OAuth 2.0 located at \n" + + "* `/jira instance install cloud [jiraURL]` - Connect Mattermost to a Jira Cloud instance located at . (Deprecated. Please use `cloud-oauth` instead.)\n" + "Uninstall Jira instances:\n" + - "* `/jira instance uninstall cloud [jiraURL]` - Disconnect Mattermost from a Jira Cloud instance located at \n" + "* `/jira instance uninstall server [jiraURL]` - Disconnect Mattermost from a Jira Server or Data Center instance located at \n" + + "* `/jira instance uninstall cloud-oauth [jiraURL]` - Disconnect Mattermost from a Jira Cloud instance using OAuth 2.0 located at \n" + + "* `/jira instance uninstall cloud [jiraURL]` - Disconnect Mattermost from a Jira Cloud instance located at \n" + "Manage channel subscriptions:\n" + "* `/jira subscribe ` - Configure the Jira notifications sent to this channel\n" + "* `/jira subscribe list` - Display all the the subscription rules setup across all the channels and teams on your Mattermost instance\n" + @@ -169,18 +174,19 @@ func createInstanceCommand(optInstance bool) *model.AutocompleteData { jiraTypes := []model.AutocompleteListItem{ {HelpText: "Jira Server or Datacenter", Item: "server"}, - {HelpText: "Jira Cloud (atlassian.net)", Item: "cloud"}, + {HelpText: "Jira Cloud OAuth 2.0 (atlassian.net)", Item: "cloud-oauth"}, + {HelpText: "Jira Cloud (atlassian.net) (Deprecated. Please use cloud-oauth instead.)", Item: "cloud"}, } install := model.NewAutocompleteData( - "install", "[cloud|server] [URL]", "Connect Mattermost to a Jira instance") - install.AddStaticListArgument("Jira type: server or cloud", true, jiraTypes) + "install", "[cloud|server|cloud-oauth] [URL]", "Connect Mattermost to a Jira instance") + install.AddStaticListArgument("Jira type: server, cloud or cloud-oauth", true, jiraTypes) install.AddTextArgument("Jira URL", "Enter the Jira URL, e.g. https://mattermost.atlassian.net", "") install.RoleID = model.SystemAdminRoleId uninstall := model.NewAutocompleteData( - "uninstall", "[cloud|server] [URL]", "Disconnect Mattermost from a Jira instance") - uninstall.AddStaticListArgument("Jira type: server or cloud", true, jiraTypes) + "uninstall", "[cloud|server|cloud-oauth] [URL]", "Disconnect Mattermost from a Jira instance") + uninstall.AddStaticListArgument("Jira type: server, cloud or cloud-oauth", true, jiraTypes) uninstall.AddDynamicListArgument("Jira instance", makeAutocompleteRoute(routeAutocompleteInstalledInstance), true) uninstall.RoleID = model.SystemAdminRoleId @@ -778,7 +784,7 @@ func authorizedSysAdmin(p *Plugin, userID string) (bool, error) { func executeInstanceInstallCloud(p *Plugin, c *plugin.Context, header *model.CommandArgs, args ...string) *model.CommandResponse { authorized, err := authorizedSysAdmin(p, header.UserId) if err != nil { - return p.responsef(header, "%v", err) + return p.responsef(header, err.Error()) } if !authorized { return p.responsef(header, "`/jira install` can only be run by a system administrator.") @@ -799,10 +805,50 @@ func executeInstanceInstallCloud(p *Plugin, c *plugin.Context, header *model.Com }) } +func executeInstanceInstallCloudOAuth(p *Plugin, c *plugin.Context, header *model.CommandArgs, args ...string) *model.CommandResponse { + authorized, err := authorizedSysAdmin(p, header.UserId) + if err != nil { + return p.responsef(header, err.Error()) + } + if !authorized { + return p.responsef(header, "`/jira install` can only be run by a Mattermost system administrator.") + } + if len(args) != 1 { + return p.help(header) + } + + jiraURL, instance, err := p.installCloudOAuthInstance(args[0], "", "") + if err != nil { + return p.responsef(header, err.Error()) + } + + state := flow.State{ + keyEdition: string(CloudOAuthInstanceType), + keyJiraURL: jiraURL, + keyInstance: instance, + keyOAuthCompleteURL: p.GetPluginURL() + instancePath(routeOAuth2Complete, types.ID(jiraURL)), + keyConnectURL: p.GetPluginURL() + instancePath(routeUserConnect, types.ID(jiraURL)), + } + + if err = p.oauth2Flow.ForUser(header.UserId).Start(state); err != nil { + return p.responsef(header, err.Error()) + } + + channel, err := p.client.Channel.GetDirect(header.UserId, p.conf.botUserID) + if err != nil { + return p.responsef(header, err.Error()) + } + if channel != nil && channel.Id != header.ChannelId { + return p.responsef(header, "continue in the direct conversation with @jira bot.") + } + + return &model.CommandResponse{} +} + func executeInstanceInstallServer(p *Plugin, c *plugin.Context, header *model.CommandArgs, args ...string) *model.CommandResponse { authorized, err := authorizedSysAdmin(p, header.UserId) if err != nil { - return p.responsef(header, "%v", err) + return p.responsef(header, err.Error()) } if !authorized { return p.responsef(header, "`/jira install` can only be run by a system administrator.") @@ -832,7 +878,7 @@ func executeInstanceInstallServer(p *Plugin, c *plugin.Context, header *model.Co func executeInstanceUninstall(p *Plugin, c *plugin.Context, header *model.CommandArgs, args ...string) *model.CommandResponse { authorized, err := authorizedSysAdmin(p, header.UserId) if err != nil { - return p.responsef(header, "%v", err) + return p.responsef(header, err.Error()) } if !authorized { return p.responsef(header, "`/jira uninstall` can only be run by a System Administrator.") @@ -1094,11 +1140,19 @@ func executeSetup(p *Plugin, c *plugin.Context, header *model.CommandArgs, args return p.responsef(header, "`/jira setup` can only be run by a system administrator.") } - err = p.setupFlow.ForUser(header.UserId).Start(nil) - if err != nil { + if err = p.setupFlow.ForUser(header.UserId).Start(nil); err != nil { return p.responsef(header, errors.Wrap(err, "Failed to start setup wizard").Error()) } - return p.responsef(header, "continue in the direct conversation with @jira bot.") + + channel, err := p.client.Channel.GetDirect(header.UserId, p.conf.botUserID) + if err != nil { + return p.responsef(header, err.Error()) + } + if channel != nil && channel.Id != header.ChannelId { + return p.responsef(header, "continue in the direct conversation with @jira bot.") + } + + return &model.CommandResponse{} } func (p *Plugin) postCommandResponse(args *model.CommandArgs, text string) { diff --git a/server/http.go b/server/http.go index 815caa0a9..8bec2f145 100644 --- a/server/http.go +++ b/server/http.go @@ -58,6 +58,7 @@ const ( routeUserConnect = "/user/connect" routeUserDisconnect = "/user/disconnect" routeSharePublicly = "/share-issue-publicly" + routeOAuth2Complete = "/oauth2/complete.html" ) const routePrefixInstance = "instance" @@ -122,6 +123,9 @@ func (p *Plugin) initializeRouter() { instanceRouter.HandleFunc(routeOAuth1Complete, p.checkAuth(p.handleResponseWithCallbackInstance(p.httpOAuth1aComplete))).Methods(http.MethodGet) instanceRouter.HandleFunc(routeUserDisconnect, p.checkAuth(p.handleResponseWithCallbackInstance(p.httpOAuth1aDisconnect))).Methods(http.MethodGet) + // OAuth2 (Jira Cloud) + instanceRouter.HandleFunc(routeOAuth2Complete, p.handleResponseWithCallbackInstance(p.httpOAuth2Complete)).Methods(http.MethodGet) + // User connect/disconnect links instanceRouter.HandleFunc(routeUserConnect, p.checkAuth(p.handleResponseWithCallbackInstance(p.httpUserConnect))).Methods(http.MethodGet) p.router.HandleFunc(routeUserStart, p.checkAuth(p.handleResponseWithCallbackInstance(p.httpUserStart))).Methods(http.MethodGet) diff --git a/server/instance.go b/server/instance.go index 25dfa1161..ade0c33c0 100644 --- a/server/instance.go +++ b/server/instance.go @@ -12,8 +12,9 @@ import ( type InstanceType string const ( - CloudInstanceType = InstanceType("cloud") - ServerInstanceType = InstanceType("server") + CloudInstanceType = InstanceType("cloud") + ServerInstanceType = InstanceType("server") + CloudOAuthInstanceType = InstanceType("cloud-oauth") ) type Instance interface { @@ -23,6 +24,7 @@ type Instance interface { GetManageAppsURL() string GetManageWebhooksURL() string GetURL() string + GetJiraBaseURL() string Common() *InstanceCommon types.Value @@ -66,3 +68,7 @@ func (ic InstanceCommon) GetID() types.ID { func (ic *InstanceCommon) Common() *InstanceCommon { return ic } + +func (ic InstanceCommon) IsCloudInstance() bool { + return ic.Type == CloudInstanceType || ic.Type == CloudOAuthInstanceType +} diff --git a/server/instance_cloud.go b/server/instance_cloud.go index 9e0714904..ecf770f2c 100644 --- a/server/instance_cloud.go +++ b/server/instance_cloud.go @@ -185,6 +185,10 @@ func (ci *cloudInstance) GetURL() string { return ci.AtlassianSecurityContext.BaseURL } +func (ci *cloudInstance) GetJiraBaseURL() string { + return ci.GetURL() +} + func (ci *cloudInstance) GetManageAppsURL() string { return fmt.Sprintf("%s/plugins/servlet/upm", ci.GetURL()) } diff --git a/server/instance_cloud_oauth.go b/server/instance_cloud_oauth.go new file mode 100644 index 000000000..60e8f33b1 --- /dev/null +++ b/server/instance_cloud_oauth.go @@ -0,0 +1,220 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + jira "github.com/andygrunwald/go-jira" + "github.com/mattermost/mattermost-server/v6/model" + "github.com/pkg/errors" + "golang.org/x/oauth2" + + "github.com/mattermost/mattermost-plugin-jira/server/utils" + "github.com/mattermost/mattermost-plugin-jira/server/utils/types" +) + +type cloudOAuthInstance struct { + *InstanceCommon + + // The SiteURL may change as we go, so we store the PluginKey when it was installed + MattermostKey string + + JiraResourceID string + JiraClientID string + JiraClientSecret string + JiraBaseURL string + CodeVerifier string + CodeChallenge string +} + +type CloudOAuthConfigure struct { + InstanceURL string `json:"instance_url"` + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` +} + +type JiraAccessibleResources []struct { + ID string +} + +type PKCEParams struct { + CodeVerifier string + CodeChallenge string +} + +var _ Instance = (*cloudOAuthInstance)(nil) + +const ( + JiraScopes = "read:jira-user,read:jira-work,write:jira-work" + JiraScopesOffline = JiraScopes + ",offline_access" + JiraResponseType = "code" + JiraConsent = "consent" + PKCEByteArrayLength = 32 +) + +func (p *Plugin) installCloudOAuthInstance(rawURL, clientID, clientSecret string) (string, *cloudOAuthInstance, error) { + jiraURL, err := utils.CheckJiraURL(p.GetSiteURL(), rawURL, false) + if err != nil { + return "", nil, err + } + if !utils.IsJiraCloudURL(jiraURL) { + return "", nil, errors.Errorf("`%s` is a Jira server URL instead of a Jira Cloud URL", jiraURL) + } + + params, err := getS256PKCEParams() + if err != nil { + return "", nil, err + } + + instance := &cloudOAuthInstance{ + InstanceCommon: newInstanceCommon(p, CloudOAuthInstanceType, types.ID(jiraURL)), + MattermostKey: p.GetPluginKey(), + JiraClientID: clientID, + JiraClientSecret: clientSecret, + JiraBaseURL: rawURL, + CodeVerifier: params.CodeVerifier, + CodeChallenge: params.CodeChallenge, + } + + if err = p.InstallInstance(instance); err != nil { + return "", nil, err + } + + return jiraURL, instance, err +} + +func (ci *cloudOAuthInstance) GetClient(connection *Connection) (Client, error) { + client, _, err := ci.getClientForConnection(connection) + if err != nil { + return nil, errors.WithMessage(err, fmt.Sprintf("failed to get Jira client for the user %s", connection.DisplayName)) + } + return newCloudClient(client), nil +} + +func (ci *cloudOAuthInstance) getClientForConnection(connection *Connection) (*jira.Client, *http.Client, error) { + oauth2Conf := ci.GetOAuthConfig() + ctx := context.Background() + tokenSource := oauth2Conf.TokenSource(ctx, connection.OAuth2Token) + client := oauth2.NewClient(ctx, tokenSource) + + // Get a new token, if Access Token has expired + currentToken := connection.OAuth2Token + updatedToken, err := tokenSource.Token() + if err != nil { + return nil, nil, errors.Wrap(err, "error in getting token from token source") + } + + if updatedToken.RefreshToken != currentToken.RefreshToken { + connection.OAuth2Token = updatedToken + + // Store this new access token & refresh token to get a new access token in future when it has expired + if err = ci.Plugin.userStore.StoreConnection(ci.Common().InstanceID, connection.MattermostUserID, connection); err != nil { + return nil, nil, err + } + } + + // TODO: Get resource ID if not in the KV Store? + jiraID, err := ci.getJiraCloudResourceID(*client) + ci.JiraResourceID = jiraID + if err != nil { + return nil, nil, err + } + + jiraClient, err := jira.NewClient(client, ci.GetURL()) + return jiraClient, client, err +} + +func (ci *cloudOAuthInstance) GetDisplayDetails() map[string]string { + return map[string]string{ + "Jira Cloud Mattermost Key": ci.MattermostKey, + } +} + +func (ci *cloudOAuthInstance) GetUserConnectURL(mattermostUserID string) (string, *http.Cookie, error) { + oauthConf := ci.GetOAuthConfig() + state := fmt.Sprintf("%s_%s", model.NewId()[0:15], mattermostUserID) + url := oauthConf.AuthCodeURL( + state, + oauth2.SetAuthURLParam("audience", "api.atlassian.com"), + oauth2.SetAuthURLParam("state", state), + oauth2.SetAuthURLParam("response_type", "code"), + oauth2.SetAuthURLParam("prompt", "consent"), + oauth2.SetAuthURLParam("code_challenge_method", "S256"), + oauth2.SetAuthURLParam("code_challenge", ci.CodeChallenge), + ) + if err := ci.Plugin.otsStore.StoreOneTimeSecret(mattermostUserID, state); err != nil { + return "", nil, err + } + return url, nil, nil +} + +func (ci *cloudOAuthInstance) GetOAuthConfig() *oauth2.Config { + return &oauth2.Config{ + ClientID: ci.JiraClientID, + ClientSecret: ci.JiraClientSecret, + Scopes: strings.Split(JiraScopesOffline, ","), + RedirectURL: fmt.Sprintf("%s%s", ci.Plugin.GetPluginURL(), instancePath(routeOAuth2Complete, ci.InstanceID)), + Endpoint: oauth2.Endpoint{ + AuthURL: "https://auth.atlassian.com/authorize", + TokenURL: "https://auth.atlassian.com/oauth/token", + }, + } +} + +func (ci *cloudOAuthInstance) GetURL() string { + return "https://api.atlassian.com/ex/jira/" + ci.JiraResourceID +} + +func (ci *cloudOAuthInstance) GetJiraBaseURL() string { + return ci.JiraBaseURL +} + +func (ci *cloudOAuthInstance) GetManageAppsURL() string { + return fmt.Sprintf("%s/plugins/servlet/applinks/listApplicationLinks", ci.GetURL()) +} + +func (ci *cloudOAuthInstance) GetManageWebhooksURL() string { + return fmt.Sprintf("%s/plugins/servlet/webhooks", ci.GetURL()) +} + +func (ci *cloudOAuthInstance) GetMattermostKey() string { + return ci.MattermostKey +} + +func (ci *cloudOAuthInstance) getJiraCloudResourceID(client http.Client) (string, error) { + request, err := http.NewRequest( + http.MethodGet, + "https://api.atlassian.com/oauth/token/accessible-resources", + nil, + ) + if err != nil { + return "", fmt.Errorf("failed to get the request") + } + + response, err := client.Do(request) + if err != nil { + return "", fmt.Errorf("failed to get the accessible resources: %s", err.Error()) + } + + defer response.Body.Close() + contents, err := io.ReadAll(response.Body) + if err != nil { + return "", fmt.Errorf("failed to read accessible resources response: %s", err.Error()) + } + + var resources JiraAccessibleResources + if err = json.Unmarshal(contents, &resources); err != nil { + return "", errors.Wrap(err, "failed to unmarshal JiraAccessibleResources") + } + + // We return the first resource ID only + if len(resources) < 1 { + return "", errors.New("No resources are available for this Jira Cloud Account.") + } + + return resources[0].ID, nil +} diff --git a/server/instance_server.go b/server/instance_server.go index b87b07c86..2648ad16e 100644 --- a/server/instance_server.go +++ b/server/instance_server.go @@ -52,6 +52,10 @@ func (si *serverInstance) GetURL() string { return si.InstanceID.String() } +func (si *serverInstance) GetJiraBaseURL() string { + return si.GetURL() +} + func (si *serverInstance) GetManageAppsURL() string { return fmt.Sprintf("%s/plugins/servlet/applinks/listApplicationLinks", si.GetURL()) } diff --git a/server/instances.go b/server/instances.go index f233e7158..b7d278219 100644 --- a/server/instances.go +++ b/server/instances.go @@ -115,6 +115,17 @@ func (instances Instances) isAliasUnique(instanceID types.ID, alias string) (boo return true, "" } +// checkIfExists returns true if the specified instance ID already exists +func (instances Instances) checkIfExists(instanceID types.ID) bool { + for _, id := range instances.IDs() { + if id == instanceID { + return true + } + } + + return false +} + type instancesArray []*InstanceCommon func (p instancesArray) Len() int { return len(p) } @@ -135,7 +146,7 @@ func (p *Plugin) InstallInstance(instance Instance) error { err := UpdateInstances(p.instanceStore, func(instances *Instances) error { if !p.enterpriseChecker.HasEnterpriseFeatures() { - if instances != nil && len(instances.IDs()) > 0 { + if instances != nil && len(instances.IDs()) > 0 && !instances.checkIfExists(instance.GetID()) { return errors.Errorf(licenseErrorString) } } diff --git a/server/instances_test.go b/server/instances_test.go index 3c2b28da4..e52c1d194 100644 --- a/server/instances_test.go +++ b/server/instances_test.go @@ -110,7 +110,7 @@ func TestInstallInstance(t *testing.T) { testInstance0 := &testInstance{ InstanceCommon: InstanceCommon{ - InstanceID: mockInstance1URL, + InstanceID: mockInstance3URL, IsV2Legacy: true, Type: "testInstanceType", }, diff --git a/server/issue.go b/server/issue.go index 81d0713e2..bc5902e96 100644 --- a/server/issue.go +++ b/server/issue.go @@ -295,7 +295,7 @@ func (p *Plugin) CreateIssue(in *InCreateIssue) (*jira.Issue, error) { } // Reply with an ephemeral post with the Jira issue formatted as slack attachment. - msg := fmt.Sprintf("Created Jira issue [%s](%s/browse/%s)", created.Key, instance.GetURL(), created.Key) + msg := fmt.Sprintf("Created Jira issue [%s](%s/browse/%s)", created.Key, instance.GetJiraBaseURL(), created.Key) reply := &model.Post{ Message: msg, @@ -323,7 +323,7 @@ func (p *Plugin) CreateIssue(in *InCreateIssue) (*jira.Issue, error) { // Create a public post for all the channel members publicReply := &model.Post{ - Message: fmt.Sprintf("Created a Jira issue: %s", mdKeySummaryLink(createdIssue)), + Message: fmt.Sprintf("Created a Jira issue: %s", mdKeySummaryLink(createdIssue, instance)), ChannelId: channelID, RootId: rootID, UserId: in.mattermostUserID.String(), @@ -657,7 +657,7 @@ func (p *Plugin) AttachCommentToIssue(in *InAttachCommentToIssue) (*jira.Comment p.UpdateUserDefaults(in.mattermostUserID, in.InstanceID, "") - msg := fmt.Sprintf("Message attached to [%s](%s/browse/%s)", in.IssueKey, instance.GetURL(), in.IssueKey) + msg := fmt.Sprintf("Message attached to [%s](%s/browse/%s)", in.IssueKey, instance.GetJiraBaseURL(), in.IssueKey) // Reply to the post with the issue link that was created reply := &model.Post{ @@ -690,32 +690,6 @@ func getPermaLink(instance Instance, postID string, currentTeam string) string { return fmt.Sprintf("%v/%v/pl/%v", instance.Common().Plugin.GetSiteURL(), currentTeam, postID) } -func (p *Plugin) getIssueDataForCloudWebhook(instance Instance, issueKey string) (*jira.Issue, error) { - ci, ok := instance.(*cloudInstance) - if !ok { - return nil, errors.Errorf("Must be a JIRA Cloud instance, is %s", instance.Common().Type) - } - - jiraClient, err := ci.getClientForBot() - if err != nil { - return nil, err - } - - issue, resp, err := jiraClient.Issue.Get(issueKey, nil) - if err != nil { - switch { - case resp == nil: - return nil, errors.WithMessage(userFriendlyJiraError(nil, err), - "request to Jira failed") - - case resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusUnauthorized: - return nil, errors.New(`we couldn't find the issue key, or the cloud "bot" client does not have the appropriate permissions to view the issue`) - } - } - - return issue, nil -} - func getIssueCustomFieldValue(issue *jira.Issue, key string) StringSet { m, exists := issue.Fields.Unknowns.Value(key) if !exists || m == nil { @@ -762,6 +736,30 @@ func getIssueCustomFieldValue(issue *jira.Issue, key string) StringSet { return nil } +func (p *Plugin) getIssueDataForCloudWebhook(instance Instance, issueKey string) (*jira.Issue, error) { + ci, ok := instance.(*cloudInstance) + if !ok { + return nil, errors.Errorf("must be a Jira cloud instance, is %s", instance.Common().Type) + } + + jiraClient, err := ci.getClientForBot() + if err != nil { + return nil, err + } + + issue, resp, err := jiraClient.Issue.Get(issueKey, nil) + if err != nil { + switch { + case resp == nil: + return nil, errors.WithMessage(userFriendlyJiraError(nil, err), "request to Jira failed") + case resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusUnauthorized: + return nil, errors.New(`we couldn't find the issue key, or the cloud "bot" client does not have the appropriate permissions to view the issue`) + } + } + + return issue, nil +} + func getIssueFieldValue(issue *jira.Issue, key string) StringSet { key = strings.ToLower(key) switch key { @@ -821,7 +819,7 @@ func (p *Plugin) getIssueAsSlackAttachment(instance Instance, connection *Connec } } - return asSlackAttachment(instance.GetID(), client, issue, showActions) + return asSlackAttachment(instance, client, issue, showActions) } func (p *Plugin) UnassignIssue(instance Instance, mattermostUserID types.ID, issueKey string) (string, error) { @@ -847,7 +845,7 @@ func (p *Plugin) UnassignIssue(instance Instance, mattermostUserID types.ID, iss return "", err } - permalink := fmt.Sprintf("%v/browse/%v", instance.GetURL(), issueKey) + permalink := fmt.Sprintf("%v/browse/%v", instance.GetJiraBaseURL(), issueKey) msg := fmt.Sprintf("Unassigned Jira issue [%s](%s)", issueKey, permalink) return msg, nil @@ -926,7 +924,7 @@ func (p *Plugin) AssignIssue(instance Instance, mattermostUserID types.ID, issue return "", err } - permalink := fmt.Sprintf("%v/browse/%v", instance.GetURL(), issueKey) + permalink := fmt.Sprintf("%v/browse/%v", instance.GetJiraBaseURL(), issueKey) msg := fmt.Sprintf("`%s` assigned to Jira issue [%s](%s)", user.DisplayName, issueKey, permalink) return msg, nil @@ -987,7 +985,7 @@ func (p *Plugin) TransitionIssue(in *InTransitionIssue) (string, error) { } msg := fmt.Sprintf("[%s](%v/browse/%v) transitioned to `%s`", - in.IssueKey, instance.GetURL(), in.IssueKey, transition.To.Name) + in.IssueKey, instance.GetJiraBaseURL(), in.IssueKey, transition.To.Name) issue, err := client.GetIssue(in.IssueKey, nil) if err != nil { @@ -1003,7 +1001,7 @@ func (p *Plugin) TransitionIssue(in *InTransitionIssue) (string, error) { } } - attachments, err := asSlackAttachment(instance.GetID(), client, issue, true) + attachments, err := asSlackAttachment(instance, client, issue, true) if err != nil { return "", err } diff --git a/server/issue_parser.go b/server/issue_parser.go index 87d340b11..96f65fd05 100644 --- a/server/issue_parser.go +++ b/server/issue_parser.go @@ -6,7 +6,6 @@ package main import ( "fmt" "regexp" - "strings" jira "github.com/andygrunwald/go-jira" @@ -21,13 +20,8 @@ func parseJiraLinksToMarkdown(text string) string { return jiraLinkWithTextRegex.ReplaceAllString(text, "[${1}](${2})") } -func mdKeySummaryLink(issue *jira.Issue) string { - // Use Self URL only to extract the full hostname from it - pos := strings.LastIndex(issue.Self, "/rest/api") - if pos < 0 { - return "" - } - return fmt.Sprintf("[%s](%s%s)", issue.Key+": "+issue.Fields.Summary+" ("+issue.Fields.Status.Name+")", issue.Self[:pos], "/browse/"+issue.Key) +func mdKeySummaryLink(issue *jira.Issue, instance Instance) string { + return fmt.Sprintf("[%s: %s (%s)](%s%s)", issue.Key, issue.Fields.Summary, issue.Fields.Status.Name, instance.GetJiraBaseURL(), "/browse/"+issue.Key) } func reporterSummary(issue *jira.Issue) string { @@ -85,8 +79,8 @@ func getActions(instanceID types.ID, client Client, issue *jira.Issue) ([]*model return actions, nil } -func asSlackAttachment(instanceID types.ID, client Client, issue *jira.Issue, showActions bool) ([]*model.SlackAttachment, error) { - text := mdKeySummaryLink(issue) +func asSlackAttachment(instance Instance, client Client, issue *jira.Issue, showActions bool) ([]*model.SlackAttachment, error) { + text := mdKeySummaryLink(issue, instance) desc := truncate(issue.Fields.Description, 3000) desc = parseJiraLinksToMarkdown(desc) if desc != "" { @@ -118,7 +112,7 @@ func asSlackAttachment(instanceID types.ID, client Client, issue *jira.Issue, sh var actions []*model.PostAction var err error if showActions { - actions, err = getActions(instanceID, client, issue) + actions, err = getActions(instance.GetID(), client, issue) if err != nil { return []*model.SlackAttachment{}, err } diff --git a/server/kv.go b/server/kv.go index dd673d4b4..aa39768c1 100644 --- a/server/kv.go +++ b/server/kv.go @@ -138,6 +138,7 @@ func (store store) StoreConnection(instanceID, mattermostUserID types.ID, connec }() connection.PluginVersion = Manifest.Version + connection.MattermostUserID = mattermostUserID err := store.set(keyWithInstanceID(instanceID, mattermostUserID), connection) if err != nil { @@ -469,8 +470,7 @@ func (store *store) LoadInstance(instanceID types.ID) (Instance, error) { func (store *store) LoadInstanceFullKey(fullkey string) (Instance, error) { var data []byte - err := store.plugin.client.KV.Get(fullkey, &data) - if err != nil { + if err := store.plugin.client.KV.Get(fullkey, &data); err != nil { return nil, err } if data == nil { @@ -478,26 +478,31 @@ func (store *store) LoadInstanceFullKey(fullkey string) (Instance, error) { } si := serverInstance{} - err = json.Unmarshal(data, &si) - if err != nil { + if err := json.Unmarshal(data, &si); err != nil { return nil, err } switch si.Type { case CloudInstanceType: ci := cloudInstance{} - err = json.Unmarshal(data, &ci) - if err != nil { - return nil, errors.WithMessage(err, "failed to unmarshal stored Instance "+fullkey) + if err := json.Unmarshal(data, &ci); err != nil { + return nil, errors.WithMessage(err, fmt.Sprintf("failed to unmarshal stored instance %s", fullkey)) } if len(ci.RawAtlassianSecurityContext) > 0 { - err = json.Unmarshal([]byte(ci.RawAtlassianSecurityContext), &ci.AtlassianSecurityContext) - if err != nil { - return nil, errors.WithMessage(err, "failed to unmarshal stored Instance "+fullkey) + if err := json.Unmarshal([]byte(ci.RawAtlassianSecurityContext), &ci.AtlassianSecurityContext); err != nil { + return nil, errors.WithMessage(err, fmt.Sprintf("failed to unmarshal stored instance %s", fullkey)) } } ci.Plugin = store.plugin return &ci, nil + case CloudOAuthInstanceType: + ci := cloudOAuthInstance{} + if err := json.Unmarshal(data, &ci); err != nil { + return nil, errors.WithMessage(err, fmt.Sprintf("failed to unmarshal stored instance %s", fullkey)) + } + ci.Plugin = store.plugin + return &ci, nil + case ServerInstanceType: si.Plugin = store.plugin return &si, nil diff --git a/server/kv_mock_test.go b/server/kv_mock_test.go index 51b142f94..37393fec8 100644 --- a/server/kv_mock_test.go +++ b/server/kv_mock_test.go @@ -22,6 +22,7 @@ var _ Instance = (*testInstance)(nil) const ( mockInstance1URL = "jiraurl1" mockInstance2URL = "jiraurl2" + mockInstance3URL = "jiraurl3" ) var testInstance1 = &testInstance{ @@ -42,6 +43,9 @@ var testInstance2 = &testInstance{ func (ti testInstance) GetURL() string { return ti.InstanceID.String() } +func (ti testInstance) GetJiraBaseURL() string { + return ti.GetURL() +} func (ti testInstance) GetManageAppsURL() string { return fmt.Sprintf("%s/apps/manage", ti.InstanceID) } diff --git a/server/plugin.go b/server/plugin.go index c984412e0..632f39c23 100644 --- a/server/plugin.go +++ b/server/plugin.go @@ -116,7 +116,8 @@ type Plugin struct { otsStore OTSStore secretsStore SecretsStore - setupFlow *flow.Flow + setupFlow *flow.Flow + oauth2Flow *flow.Flow router *mux.Router @@ -276,6 +277,7 @@ func (p *Plugin) OnActivate() error { p.templates = templates p.setupFlow = p.NewSetupFlow() + p.oauth2Flow = p.NewOAuth2Flow() // Register /jira command and stash the loaded list of known instances for // later (autolink registration). diff --git a/server/setup_flow.go b/server/setup_flow.go index 05af75098..811b00b9a 100644 --- a/server/setup_flow.go +++ b/server/setup_flow.go @@ -19,9 +19,7 @@ const ( stepDelegateComplete flow.Name = "delegate-complete" stepDelegated flow.Name = "delegated" stepChooseEdition flow.Name = "choose-edition" - stepCloudAddedInstance flow.Name = "cloud-added" - stepCloudEnableDeveloperMode flow.Name = "cloud-enable-dev" - stepCloudUploadApp flow.Name = "cloud-upload-app" + stepCloudOAuthConfigure flow.Name = "cloud-oauth-configure" stepInstalledJiraApp flow.Name = "installed-app" stepServerAddAppLink flow.Name = "server-add-link" stepServerConfirmAppLink flow.Name = "server-confirm-link" @@ -44,11 +42,19 @@ const ( keyDelegatedTo = "Delegated" keyEdition = "Edition" keyJiraURL = "JiraURL" + keyInstance = "Instance" keyManageWebhooksURL = "ManageWebhooksURL" keyMattermostKey = "MattermostKey" keyPluginURL = "PluginURL" keyPublicKey = "PublicKey" keyWebhookURL = "WebhookURL" + keyOAuthCompleteURL = "OAuthCompleteURL" +) + +const ( + NameClientID = "client_id" + NameCLientSecret = "client_secret" + NameURL = "url" ) func (p *Plugin) NewSetupFlow() *flow.Flow { @@ -62,10 +68,8 @@ func (p *Plugin) NewSetupFlow() *flow.Flow { p.stepDelegateComplete(), p.stepChooseEdition(), - // Jira Cloud steps - p.stepCloudAddedInstance(), - p.stepCloudEnableDeveloperMode(), - p.stepCloudUploadApp(), + // Jira Cloud OAuth steps + p.stepCloudOAuthConfigure(), // Jira server steps p.stepServerAddAppLink(), @@ -87,6 +91,25 @@ func (p *Plugin) NewSetupFlow() *flow.Flow { InitHTTP(p.router) } +func (p *Plugin) NewOAuth2Flow() *flow.Flow { + pluginURL := fmt.Sprintf("%s/plugins/%s", *p.client.Configuration.GetConfig().ServiceSettings.SiteURL, Manifest.Id) + conf := p.getConfig() + return flow.NewFlow("setup-oauth2", p.client, pluginURL, conf.botUserID). + WithSteps( + p.stepCloudOAuthConfigure(), + p.stepInstalledJiraApp(), + p.stepWebhook(), + p.stepWebhookDone(), + p.stepConnect(), + p.stepConnected(), + p.stepAnnouncementQuestion(), + p.stepAnnouncementConfirmation(), + p.stepCancel(), + p.stepDone(), + ). + InitHTTP(p.router) +} + var cancelButton = flow.Button{ Name: "Cancel setup", Color: flow.ColorDanger, @@ -174,29 +197,30 @@ func (p *Plugin) stepDelegateComplete() flow.Step { func (p *Plugin) stepChooseEdition() flow.Step { return flow.NewStep(stepChooseEdition). WithPretext("##### :white_check_mark: Step 1: Which Jira edition do you use?"). - WithTitle("Cloud or Server (on-premise)."). - WithText("Choose whether you're using Jira Cloud or Jira Server (on-premise/Data Center) edition. " + + WithTitle("Cloud (OAuth 2.0) or Server (on-premise)."). + WithText("Choose whether you're using Jira Cloud (OAuth 2.0) or Jira Server (on-premise/Data Center) edition. " + "To integrate with more than one Jira instance, see the [documentation](https://mattermost.gitbook.io/plugin-jira/)"). - WithButton(flow.Button{ - Name: "Jira Cloud", - Color: flow.ColorPrimary, - Dialog: &model.Dialog{ - Title: "Enter your Jira Cloud URL", - IntroductionText: "Enter a Jira Cloud URL (typically, `https://yourorg.atlassian.net`), or just the organization part, `yourorg`", - SubmitLabel: "Continue", - Elements: []model.DialogElement{ - { - DisplayName: "Jira Cloud organization", - Name: "url", - Type: "text", - // text, not URL since normally just the org name needs - // to be entered. - SubType: "text", + WithButton( + flow.Button{ + Name: "Jira Cloud (OAuth 2.0)", + Color: flow.ColorPrimary, + Dialog: &model.Dialog{ + Title: "Enter your Jira Cloud URL", + IntroductionText: "Enter a Jira Cloud URL (typically, `https://yourorg.atlassian.net`), or just the organization part, `yourorg`", + SubmitLabel: "Continue", + Elements: []model.DialogElement{ + { + DisplayName: "Jira Cloud organization", + Name: "url", + Type: "text", + // text, not URL since normally just the org name needs + // to be entered. + SubType: "text", + }, }, }, - }, - OnDialogSubmit: p.submitCreateCloudInstance, - }). + OnDialogSubmit: p.initCreateCloudOAuthInstance, + }). WithButton(flow.Button{ Name: "Jira Server", Color: flow.ColorPrimary, @@ -271,44 +295,56 @@ func (p *Plugin) stepServerConfigureAppLink2() flow.Step { WithButton(cancelButton) } -func (p *Plugin) stepCloudAddedInstance() flow.Step { - return flow.NewStep(stepCloudAddedInstance). - WithText("Jira cloud {{.JiraURL}} has been added and is ready to configure."). - Next(stepCloudEnableDeveloperMode) -} - -func (p *Plugin) stepCloudEnableDeveloperMode() flow.Step { - return flow.NewStep(stepCloudEnableDeveloperMode). - WithPretext("##### :white_check_mark: Step 2: Configure the Mattermost app in Jira"). - WithTitle("Enable development mode."). - WithText("Integrating Mattermost with Jira Cloud requires setting your Jira instance to development mode (see _screenshot_). " + - "Enabling development mode allows you to install apps like Mattermost from outside the Atlassian Marketplace.\n" + - "Complete the following steps in Jira, then come back here to select **Continue**.\n\n" + - "1. Navigate to [**Settings > Apps > Manage Apps**]({{.JiraURL}}/plugins/servlet/upm?source=side_nav_manage_addons).\n" + - "2. Select **Settings** at the bottom of the page.\n" + - "3. Select **Enable development mode**, then select **Apply**.\n"). - WithImage("public/cloud-enable-dev-mode.png"). - OnRender(p.trackSetupWizard("setup_wizard_jira_config_start", map[string]interface{}{ - keyEdition: CloudInstanceType, - })). - WithButton(continueButton(stepCloudUploadApp)). - WithButton(cancelButton) -} - -func (p *Plugin) stepCloudUploadApp() flow.Step { - return flow.NewStep(stepCloudUploadApp). - WithTitle("Upload the Mattermost app to Jira."). - WithText("To finish the configuration, create a new app in your Jira instance.\n" + - "Complete the following steps, then come back here to select **Continue**.\n\n" + - "1. From [**Settings > Apps > Manage Apps**]({{.JiraURL}}/plugins/servlet/upm?source=side_nav_manage_addons) select **Upload app** (see _screenshot_).\n" + - "2. In the **From this URL field**, enter: `{{.ACURL}}` [link]({{.ACURL}}), then select **Upload**.\n" + - "3. Wait for the app to install. Once completed, you should see an \"Installed and ready to go!\" message.\n"). - WithImage("public/cloud-upload-app.png"). +func (p *Plugin) stepCloudOAuthConfigure() flow.Step { + return flow.NewStep(stepCloudOAuthConfigure). + WithPretext("##### :white_check_mark: Step 2: Register an OAuth 2.0 Application in Jira"). + WithText(fmt.Sprintf("Complete the following steps, then come back here to select **Configure**.\n\n"+ + "1. Create an OAuth 2.0 application in Jira from the [Developer console](https://developer.atlassian.com/console/myapps/create-3lo-app/).\n"+ + "2. Name your app according to its purpose, for example: `Mattermost Jira Plugin - `.\n"+ + "3. Accept the **Terms** and click **Create**.\n"+ + "4. Select **Permissions** in the left menu. Next to the JIRA API, select **Add**.\n"+ + "5. Then select **Configure** and ensure following scopes are selected:\n"+ + " %s\n"+ + "6. Select **Authorization** in the left menu.\n"+ + "7. Next to OAuth 2.0 (3LO), select **Add** and set the Callback URL as follows and click **Save Changes**:\n"+ + " {{.OAuthCompleteURL}}\n"+ + "8. Select **Settings** in the left menu.\n"+ + "9. Copy the **Client ID** and **Secret** and keep it handy.\n"+ + "10. Click on the **Configure** button below, enter these details and then **Continue**.", JiraScopes)). WithButton(flow.Button{ - Name: "Waiting for confirmation...", - Color: flow.ColorDefault, - Disabled: true, - }) + Name: "Configure", + Color: flow.ColorPrimary, + Dialog: &model.Dialog{ + Title: "Configure your Jira Cloud OAuth 2.0", + SubmitLabel: "Continue", + Elements: []model.DialogElement{ + { + DisplayName: "Jira Cloud organization", + Name: NameURL, + Type: "text", + Default: `{{.JiraURL}}`, + SubType: "text", + }, + { + DisplayName: "Jira OAuth Client ID", + Name: NameClientID, + Type: "text", + SubType: "text", + HelpText: "The client ID for the OAuth app registered with Jira", + }, + { + DisplayName: "Jira OAuth Client Secret", + Name: NameCLientSecret, + Type: "text", + SubType: "text", + HelpText: "The client secret for the OAuth app registered with Jira", + }, + }, + }, + OnDialogSubmit: p.submitCreateCloudOAuthInstance, + }). + OnRender(p.trackSetupWizard("setup_wizard_cloud_oauth2_configure", nil)). + WithButton(cancelButton) } func (p *Plugin) stepInstalledJiraApp() flow.Step { @@ -364,7 +400,7 @@ func (p *Plugin) stepWebhook() flow.Step { Color: flow.ColorPrimary, Dialog: &model.Dialog{ Title: "Jira Webhook URL", - IntroductionText: "Please scroll to select the entire URL if necessary. [link]({{.WebhookURL}})\n```\n{{.WebhookURL}}\n```\nOnce you have entered all options and the webhook URL, select **Create**", + IntroductionText: "Please scroll to select the entire URL if necessary.\n\n```{{.WebhookURL}}```\n\nOnce you have entered all options and the webhook URL, select **Create**", SubmitLabel: "Continue", }, OnDialogSubmit: flow.DialogGoto(stepWebhookDone), @@ -544,7 +580,7 @@ func (p *Plugin) submitDelegateSelection(f *flow.Flow, submission map[string]int var jiraOrgRegexp = regexp.MustCompile(`^[\w-]+$`) -func (p *Plugin) submitCreateCloudInstance(f *flow.Flow, submission map[string]interface{}) (flow.Name, flow.State, map[string]string, error) { +func (p *Plugin) initCreateCloudOAuthInstance(f *flow.Flow, submission map[string]interface{}) (flow.Name, flow.State, map[string]string, error) { jiraURL, _ := submission["url"].(string) if jiraURL == "" { return "", nil, nil, errors.New("no Jira cloud URL in the request") @@ -554,15 +590,60 @@ func (p *Plugin) submitCreateCloudInstance(f *flow.Flow, submission map[string]i jiraURL = fmt.Sprintf("https://%s.atlassian.net", jiraURL) } - jiraURL, err := p.installInactiveCloudInstance(jiraURL, f.UserID) + jiraURL, instance, err := p.installCloudOAuthInstance(jiraURL, "", "") + if err != nil { + return "", nil, nil, err + } + + return stepCloudOAuthConfigure, flow.State{ + keyEdition: string(CloudOAuthInstanceType), + keyJiraURL: jiraURL, + keyInstance: instance, + keyOAuthCompleteURL: p.GetPluginURL() + instancePath(routeOAuth2Complete, types.ID(jiraURL)), + keyConnectURL: p.GetPluginURL() + instancePath(routeUserConnect, types.ID(jiraURL)), + }, nil, nil +} + +func (p *Plugin) submitCreateCloudOAuthInstance(f *flow.Flow, submission map[string]interface{}) (flow.Name, flow.State, map[string]string, error) { + jiraURL, ok := submission[NameURL].(string) + if !ok { + return "", nil, nil, errors.New("invalid Jira cloud URL") + } + if jiraURL == "" { + return "", nil, nil, errors.New("no Jira cloud URL is present in the request") + } + jiraURL = strings.TrimSpace(jiraURL) + if jiraOrgRegexp.MatchString(jiraURL) { + jiraURL = fmt.Sprintf("https://%s.atlassian.net", jiraURL) + } + + clientID, ok := submission[NameClientID].(string) + if !ok { + return "", nil, nil, errors.New("invalid Jira OAuth Client ID") + } + if clientID == "" { + return "", nil, nil, errors.New("no Jira OAuth Client ID is present in the request") + } + + clientSecret, ok := submission[NameCLientSecret].(string) + if !ok { + return "", nil, nil, errors.New("invalid Jira OAuth Client Secret") + } + if clientSecret == "" { + return "", nil, nil, errors.New("no Jira OAuth Client Secret is present in the request") + } + + jiraURL, instance, err := p.installCloudOAuthInstance(jiraURL, clientID, clientSecret) if err != nil { return "", nil, nil, err } - return stepCloudAddedInstance, flow.State{ - keyEdition: string(CloudInstanceType), - keyJiraURL: jiraURL, - keyAtlassianConnectURL: p.GetPluginURL() + instancePath(routeACJSON, types.ID(jiraURL)), + return stepInstalledJiraApp, flow.State{ + keyEdition: string(CloudOAuthInstanceType), + keyJiraURL: jiraURL, + keyInstance: instance, + keyOAuthCompleteURL: fmt.Sprintf("%s%s", p.GetPluginURL(), instancePath(routeOAuth2Complete, types.ID(jiraURL))), + keyConnectURL: fmt.Sprintf("%s%s", p.GetPluginURL(), instancePath(routeUserConnect, types.ID(jiraURL))), }, nil, nil } diff --git a/server/user.go b/server/user.go index c2a51162f..890a076ec 100644 --- a/server/user.go +++ b/server/user.go @@ -11,6 +11,7 @@ import ( jira "github.com/andygrunwald/go-jira" "github.com/pkg/errors" + "golang.org/x/oauth2" "github.com/mattermost/mattermost-server/v6/model" @@ -28,10 +29,12 @@ type User struct { type Connection struct { jira.User PluginVersion string - Oauth1AccessToken string `json:",omitempty"` - Oauth1AccessSecret string `json:",omitempty"` + Oauth1AccessToken string `json:",omitempty"` + Oauth1AccessSecret string `json:",omitempty"` + OAuth2Token *oauth2.Token `json:",omitempty"` Settings *ConnectionSettings - DefaultProjectKey string `json:"default_project_key,omitempty"` + DefaultProjectKey string `json:"default_project_key,omitempty"` + MattermostUserID types.ID `json:"mattermost_user_id"` } func (c *Connection) JiraAccountID() types.ID { diff --git a/server/user_cloud_oauth.go b/server/user_cloud_oauth.go new file mode 100644 index 000000000..585e79110 --- /dev/null +++ b/server/user_cloud_oauth.go @@ -0,0 +1,118 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License for license information. + +package main + +import ( + "context" + "fmt" + "net/http" + "path" + "strings" + + "github.com/mattermost/mattermost-server/v6/model" + "github.com/pkg/errors" + "golang.org/x/oauth2" + + "github.com/mattermost/mattermost-plugin-jira/server/utils/types" +) + +const TokenExpiryTimeBufferInMinutes = 5 + +func (p *Plugin) httpOAuth2Complete(w http.ResponseWriter, r *http.Request, instanceID types.ID) (int, error) { + code := r.URL.Query().Get("code") + if code == "" { + return respondErr(w, http.StatusBadRequest, errors.New("Bad request: missing code")) + } + state := r.URL.Query().Get("state") + if state == "" { + return respondErr(w, http.StatusBadRequest, errors.New("Bad request: missing state")) + } + + stateArray := strings.Split(state, "_") + if len(stateArray) != 2 || stateArray[1] == "" { + return respondErr(w, http.StatusBadRequest, errors.New("Bad request: invalid state")) + } + + stateSecret := stateArray[0] + mattermostUserID := stateArray[1] + storedSecret, err := p.otsStore.LoadOneTimeSecret(mattermostUserID) + if err != nil { + return respondErr(w, http.StatusUnauthorized, errors.New("state not found or might be expired")) + } + parsed := strings.Split(storedSecret, "_") + if len(parsed) < 2 || parsed[0] != stateSecret { + return respondErr(w, http.StatusUnauthorized, errors.New("state token mismatch")) + } + + mmUser, appErr := p.API.GetUser(mattermostUserID) + if appErr != nil { + return respondErr(w, http.StatusInternalServerError, errors.WithMessage(appErr, fmt.Sprintf("failed to load user %s", mattermostUserID))) + } + + instance, err := p.instanceStore.LoadInstance(instanceID) + if err != nil { + return respondErr(w, http.StatusInternalServerError, errors.Wrap(err, "Error occurred while loading instance")) + } + + connection, err := p.GenerateInitialOAuthToken(mattermostUserID, code, instanceID) + if err != nil { + return respondErr(w, http.StatusInternalServerError, errors.Wrap(err, "Error occurred while generating initial oauth token")) + } + + client, err := instance.GetClient(connection) + if err != nil { + return respondErr(w, http.StatusInternalServerError, errors.Wrap(err, "Error occurred while getting client")) + } + + jiraUser, err := client.GetSelf() + if err != nil { + return respondErr(w, http.StatusInternalServerError, errors.Wrap(err, "Error occurred while getting Jira user")) + } + connection.User = *jiraUser + + // Set default settings when the user connects for the first time + connection.Settings = &ConnectionSettings{Notifications: true} + connection.MattermostUserID = types.ID(mattermostUserID) + + if err := p.connectUser(instance, types.ID(mattermostUserID), connection); err != nil { + return respondErr(w, http.StatusInternalServerError, errors.Wrap(err, fmt.Sprintf("Error occurred while connecting user. UserID: %s", mattermostUserID))) + } + + return p.respondTemplate(w, r, "text/html", struct { + MattermostDisplayName string + JiraDisplayName string + RevokeURL string + }{ + JiraDisplayName: jiraUser.DisplayName + " (" + jiraUser.Name + ")", + MattermostDisplayName: mmUser.GetDisplayName(model.ShowNicknameFullName), + RevokeURL: path.Join(p.GetPluginURLPath(), instancePath(routeUserDisconnect, instance.GetID())), + }) +} + +func (p *Plugin) GenerateInitialOAuthToken(mattermostUserID, code string, instanceID types.ID) (*Connection, error) { + instance, err := p.instanceStore.LoadInstance(instanceID) + if err != nil { + return nil, err + } + oAuthInstance, ok := instance.(*cloudOAuthInstance) + if !ok { + return nil, errors.Errorf("Not supported for instance type %s", instance.Common().Type) + } + + oAuthConf := oAuthInstance.GetOAuthConfig() + + token, err := oAuthConf.Exchange(context.Background(), code, oauth2.SetAuthURLParam("code_verifier", oAuthInstance.CodeVerifier)) + if err != nil { + p.client.Log.Error("error while exchanging authorization code for access token", "error", err) + return nil, errors.WithMessage(err, "error while exchanging authorization code for access token") + } + + connection, err := p.userStore.LoadConnection(instanceID, types.ID(mattermostUserID)) + if err != nil { + return nil, err + } + + connection.OAuth2Token = token + return connection, nil +} diff --git a/server/utils.go b/server/utils.go index ad9eb9600..ddda7ba6a 100644 --- a/server/utils.go +++ b/server/utils.go @@ -4,6 +4,9 @@ package main import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" "fmt" "regexp" "strings" @@ -159,3 +162,22 @@ func isEmbbedableMIME(mime string) bool { } return false } + +// getS256PKCEParams creates the code_challenge and code_verifier params for oauth2 +func getS256PKCEParams() (*PKCEParams, error) { + buf := make([]byte, PKCEByteArrayLength) + if _, err := rand.Read(buf); err != nil { + return nil, err + } + + verifier := base64.RawURLEncoding.EncodeToString(buf) + + h := sha256.New() + h.Write([]byte(verifier)) + challenge := base64.RawURLEncoding.EncodeToString(h.Sum(nil)) + + return &PKCEParams{ + CodeChallenge: challenge, + CodeVerifier: verifier, + }, nil +} diff --git a/server/webhook.go b/server/webhook.go index ea4d0e1e5..a5353f79a 100644 --- a/server/webhook.go +++ b/server/webhook.go @@ -142,7 +142,11 @@ func (wh *webhook) PostNotifications(p *Plugin, instanceID types.ID) ([]*model.P isCommentEvent := wh.Events().Intersection(commentEvents).Len() > 0 if isCommentEvent { - err = client.RESTGet(notification.commentSelf, nil, &struct{}{}) + if instance.Common().IsCloudInstance() { + err = client.RESTGet(fmt.Sprintf("/2/issue/%s/comment/%s", wh.Issue.ID, wh.Comment.ID), nil, &struct{}{}) + } else { + err = client.RESTGet(notification.commentSelf, nil, &struct{}{}) + } } else { _, err = client.GetIssue(wh.Issue.ID, nil) } diff --git a/server/webhook_jira.go b/server/webhook_jira.go index a80b90f3f..38797c4d0 100644 --- a/server/webhook_jira.go +++ b/server/webhook_jira.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/andygrunwald/go-jira" + "github.com/pkg/errors" "github.com/mattermost/mattermost-plugin-jira/server/utils/types" ) @@ -31,12 +32,61 @@ type JiraWebhook struct { IssueEventTypeName string `json:"issue_event_type_name"` } +func (jwh *JiraWebhook) expandIssue(p *Plugin, instanceID types.ID) error { + instance, err := p.instanceStore.LoadInstance(instanceID) + if err != nil { + return err + } + + if !instance.Common().IsCloudInstance() { + return nil + } + + // Jira Cloud comment event. We need to fetch issue data because it is not expanded in webhook payload. + isCommentEvent := jwh.WebhookEvent == commentCreated || jwh.WebhookEvent == commentUpdated || jwh.WebhookEvent == commentDeleted + if isCommentEvent { + if _, ok := instance.(*cloudInstance); ok { + issue, err := p.getIssueDataForCloudWebhook(instance, jwh.Issue.ID) + if err != nil { + return err + } + + jwh.Issue = *issue + } else if _, ok := instance.(*cloudOAuthInstance); ok { + mmUserID, err := p.userStore.LoadMattermostUserID(instanceID, jwh.Comment.Author.AccountID) + if err != nil { + return errors.Wrap(err, "Cannot create subscription posts for this comment as the Jira comment author is not connected to Mattermost.") + } + + conn, err := p.userStore.LoadConnection(instance.GetID(), mmUserID) + if err != nil { + return err + } + + client, err := instance.GetClient(conn) + if err != nil { + return err + } + + issue, err := client.GetIssue(jwh.Issue.ID, nil) + if err != nil { + return err + } + + jwh.Issue = *issue + } + } + + return nil +} + func (jwh *JiraWebhook) mdJiraLink(title, suffix string) string { // Use Self URL only to extract the full hostname from it pos := strings.LastIndex(jwh.Issue.Self, "/rest/api") if pos < 0 { return "" } + // TODO: For Jira OAuth, the Self URL is sent as https://api.atlassian.com/ instead of the Jira Instance URL - to check this and handle accordingly return fmt.Sprintf("[%s](%s%s)", title, jwh.Issue.Self[:pos], suffix) } @@ -77,25 +127,6 @@ func (jwh *JiraWebhook) mdIssueType() string { return strings.ToLower(jwh.Issue.Fields.Type.Name) } -func (jwh *JiraWebhook) expandIssue(p *Plugin, instanceID types.ID) error { - instance, err := p.instanceStore.LoadInstance(instanceID) - if err != nil { - return err - } - - // Jira Cloud comment event. We need to fetch issue data because it is not expanded in webhook payload. - isCommentEvent := jwh.WebhookEvent == commentCreated || jwh.WebhookEvent == commentUpdated || jwh.WebhookEvent == commentDeleted - if isCommentEvent && instance.Common().Type == "cloud" { - issue, err := p.getIssueDataForCloudWebhook(instance, jwh.Issue.ID) - if err != nil { - return err - } - jwh.Issue = *issue - } - - return nil -} - func mdAddRemove(from, to, add, remove string) string { added := mdDiff(from, to) removed := mdDiff(to, from)