Skip to content

Commit

Permalink
MM-51310 Implement OAuth2 Authentication (reopened) (#949)
Browse files Browse the repository at this point in the history
* 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 <build@mattermost.com>
Co-authored-by: Raghav Aggarwal <raghav.aggarwal@brightscout.com>
Co-authored-by: Carrie Warner (Mattermost) <74422101+cwarnermm@users.noreply.github.com>
Co-authored-by: Katie Wiersgalla <39744472+wiersgallak@users.noreply.github.com>
  • Loading branch information
6 people authored Aug 29, 2023
1 parent 5a9c903 commit 57856e4
Show file tree
Hide file tree
Showing 22 changed files with 844 additions and 203 deletions.
77 changes: 77 additions & 0 deletions assets/templates/oauth2/complete.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<!DOCTYPE html>
<html lang="en">
<head>
<style>
body {
color: rgb(23, 43, 77);
letter-spacing: -0.01em;
}

.flex-parent {
padding: 50px;
}

.btn {
-webkit-transition: all 0.15s ease;
-webkit-transition-delay: 0s;
transition-delay: 0s;
-moz-transition: all 0.15s ease;
-o-transition: all 0.15s ease;
transition: all 0.15s ease false;
padding-right: 0 1em;
font-size: inherit;
border: none;
height: 2.4em;
border-radius: 4px;
cursor: pointer;
}

.btn-primary {
color: rgb(255, 255, 255);
background: rgb(0, 82, 204);
}

.btn-primary:hover,
.btn-primary:active {
background: rgb(0, 101, 255);
}

.btn-link {
color: rgb(80, 95, 121);
background: rgb(244, 245, 247);
padding: 10px;
}

.btn-link:hover,
.btn-link:active {
background: rgb(235, 236, 240);
}

.accounts-container {
padding: 1.6em 0 0.8em;
opacity: .6;
}
.icon--check {
margin-right: 4px;
}
</style>
<link rel="stylesheet" href="https://unpkg.com/@atlaskit/css-reset@2.0.0/dist/bundle.css" media="all">
</head>
<body>
<div class="flex-parent">
<h3>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="15" class="icon--check" viewBox="0 0 18 14">
<path fill="#0052CC" d="M100.649576,76.1740942 C100.649576,76.4531124 100.537969,76.7321306 100.337075,76.9330237 L90.7388497,86.5312494 C90.5379566,86.7321425 90.2589384,86.8437498 89.9799202,86.8437498 C89.700902,86.8437498 89.4218838,86.7321425 89.2209908,86.5312494 L83.6629484,80.9732071 C83.4620553,80.772314 83.350448,80.4932958 83.350448,80.2142776 C83.350448,79.9352594 83.4620553,79.6562412 83.6629484,79.4553481 L85.1808074,77.9374892 C85.3817005,77.7365961 85.6607186,77.6249888 85.9397368,77.6249888 C86.218755,77.6249888 86.4977732,77.7365961 86.6986663,77.9374892 L89.9799202,81.2299038 L97.3013575,73.8973058 C97.5022506,73.6964127 97.7812688,73.5848054 98.060287,73.5848054 C98.3393052,73.5848054 98.6183234,73.6964127 98.8192165,73.8973058 L100.337075,75.4151648 C100.537969,75.6160579 100.649576,75.895076 100.649576,76.1740942 Z" transform="translate(-83 -73)"/>
</svg>
Mattermost user is now connected to Jira
</h3>
<div class="accounts-container">
<div>Mattermost account: {{ .MattermostDisplayName }}</div>
<div>Jira account: {{ .JiraDisplayName }}</div>
<div>It is now safe to close this browser window.</div>
</div>
<a href="javascript:window.close();" class="btn btn-link">Close</a>
<a href="{{ .RevokeURL }}" class="btn btn-link">Disconnect</a>
</div>
</body>
</html>
1 change: 0 additions & 1 deletion readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 4 additions & 4 deletions server/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down
142 changes: 98 additions & 44 deletions server/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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,
}
Expand All @@ -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 <jiraURL>\n" +
"* `/jira instance install server [jiraURL]` - Connect Mattermost to a Jira Server or Data Center instance located at <jiraURL>\n" +
"* `/jira instance install cloud-oauth [jiraURL]` - Connect Mattermost to a Jira Cloud instance using OAuth 2.0 located at <jiraURL>\n" +
"* `/jira instance install cloud [jiraURL]` - Connect Mattermost to a Jira Cloud instance located at <jiraURL>. (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 <jiraURL>\n" +
"* `/jira instance uninstall server [jiraURL]` - Disconnect Mattermost from a Jira Server or Data Center instance located at <jiraURL>\n" +
"* `/jira instance uninstall cloud-oauth [jiraURL]` - Disconnect Mattermost from a Jira Cloud instance using OAuth 2.0 located at <jiraURL>\n" +
"* `/jira instance uninstall cloud [jiraURL]` - Disconnect Mattermost from a Jira Cloud instance located at <jiraURL>\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" +
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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.")
Expand All @@ -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.")
Expand Down Expand Up @@ -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.")
Expand Down Expand Up @@ -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) {
Expand Down
4 changes: 4 additions & 0 deletions server/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ const (
routeUserConnect = "/user/connect"
routeUserDisconnect = "/user/disconnect"
routeSharePublicly = "/share-issue-publicly"
routeOAuth2Complete = "/oauth2/complete.html"
)

const routePrefixInstance = "instance"
Expand Down Expand Up @@ -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)
Expand Down
10 changes: 8 additions & 2 deletions server/instance.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -23,6 +24,7 @@ type Instance interface {
GetManageAppsURL() string
GetManageWebhooksURL() string
GetURL() string
GetJiraBaseURL() string

Common() *InstanceCommon
types.Value
Expand Down Expand Up @@ -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
}
4 changes: 4 additions & 0 deletions server/instance_cloud.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
Expand Down
Loading

0 comments on commit 57856e4

Please sign in to comment.