Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[MI-3159] Implement OAuth for jira cloud instances #56

Merged
merged 33 commits into from
Jun 15, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
c24cb05
Initial commit to support OAuth2 authentication
srkgupta Mar 23, 2023
333f7d3
Implemented modal to configure OAuth2 credentials
srkgupta Mar 30, 2023
a86aa0a
Made the field names consistent
srkgupta Apr 3, 2023
ee2b7d4
Resolved merge conflicts with latest master
srkgupta Apr 3, 2023
93ff5d4
Removed install_cloud_oauth.md template
srkgupta Apr 3, 2023
1bdf374
Added telemetry tracking for oauth2 setup flow
srkgupta Apr 3, 2023
3ca9321
Fixed typo
srkgupta Apr 3, 2023
6b5cfe2
Implemented few review comments
srkgupta Apr 4, 2023
2a6b068
Reverted webapp changes
srkgupta Apr 17, 2023
4082df6
Reverted package-lock.json file
srkgupta Apr 17, 2023
16ec261
Merge branch 'master' into MM-51310_oauth2_authentication
mattermost-build Apr 17, 2023
5e885c9
Fixed depreciation
srkgupta Apr 17, 2023
1dc03cd
Using refresh token to get another access token
srkgupta Apr 24, 2023
b64b4d5
Implemented review comments & QA findings
srkgupta Apr 25, 2023
326992f
Implemented few additional review comments
srkgupta Apr 27, 2023
67f44e8
fixed enterprise check while updating instances
srkgupta May 5, 2023
6ede115
Renamed function name for easy understanding
srkgupta May 5, 2023
2a5459f
Fixed failing instances test
srkgupta May 5, 2023
beca07e
Merge branch 'master' into MM-51310_oauth2_authentication
mickmister Jun 1, 2023
9868066
fix merge issue
mickmister Jun 1, 2023
2f03e43
fix merge issue 2
mickmister Jun 1, 2023
dc58dd5
check for cloud oauth instance for comment webhooks
mickmister Jun 2, 2023
4fb10f4
[MI-3153] Fixed issue: comment webhook notification not working
raghavaggarwal2308 Jun 9, 2023
6c72ae3
[MI-3153] Added PKCE along with authorization code for getting the ac…
raghavaggarwal2308 Jun 9, 2023
ee1820c
[MI-3153] Review fixes
raghavaggarwal2308 Jun 12, 2023
7bf8615
[MI-3159] Review fixes of comments given by Kshitij
raghavaggarwal2308 Jun 12, 2023
46a259c
[MI-3159] Review fixes given by Ayush
raghavaggarwal2308 Jun 12, 2023
9005b9f
[MI-3159] Removed the code which was forcing the template window to c…
raghavaggarwal2308 Jun 12, 2023
9972277
[MI-3153] Review fixes
raghavaggarwal2308 Jun 13, 2023
c47d12c
Merge branch 'MI-3159' of github.com:Brightscout/mattermost-plugin-ji…
raghavaggarwal2308 Jun 13, 2023
6b5a62b
[MI-3159] Review fixes
raghavaggarwal2308 Jun 14, 2023
74ca215
[MI-3159] Review fix
raghavaggarwal2308 Jun 15, 2023
04085d1
Merge pull request #55 from Brightscout/MI-3153
raghavaggarwal2308 Jun 15, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
raghavaggarwal2308 marked this conversation as resolved.
Show resolved Hide resolved
}

.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;
raghavaggarwal2308 marked this conversation as resolved.
Show resolved Hide resolved
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>
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