mattermost-community-enterp.../channels/web/oauth_test.go
Claude ec1f89217a Merge: Complete Mattermost Server with Community Enterprise
Full Mattermost server source with integrated Community Enterprise features.
Includes vendor directory for offline/air-gapped builds.

Structure:
- enterprise-impl/: Enterprise feature implementations
- enterprise-community/: Init files that register implementations
- enterprise/: Bridge imports (community_imports.go)
- vendor/: All dependencies for offline builds

Build (online):
  go build ./cmd/mattermost

Build (offline/air-gapped):
  go build -mod=vendor ./cmd/mattermost

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 23:59:07 +09:00

1150 lines
41 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package web
import (
"context"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"slices"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/utils"
"github.com/mattermost/mattermost/server/v8/einterfaces"
)
func TestOAuthComplete_AccessDenied(t *testing.T) {
th := Setup(t).InitBasic(t)
c := &Context{
App: th.App,
Params: &Params{
Service: "TestService",
},
AppContext: request.EmptyContext(th.TestLogger),
}
responseWriter := httptest.NewRecorder()
request, err := http.NewRequest(http.MethodGet, th.App.GetSiteURL()+"/signup/TestService/complete?error=access_denied", nil)
require.NoError(t, err)
completeOAuth(c, responseWriter, request)
response := responseWriter.Result()
assert.Equal(t, http.StatusTemporaryRedirect, response.StatusCode)
location, err := url.Parse(response.Header.Get("Location"))
require.NoError(t, err)
assert.Equal(t, "oauth_access_denied", location.Query().Get("type"))
assert.Equal(t, "TestService", location.Query().Get("service"))
}
func TestAuthorizeOAuthApp(t *testing.T) {
th := Setup(t).InitBasic(t)
th.Login(t, apiClient, th.SystemAdminUser)
enableOAuth := *th.App.Config().ServiceSettings.EnableOAuthServiceProvider
defer func() {
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableOAuthServiceProvider = enableOAuth })
}()
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableOAuthServiceProvider = true })
oapp := &model.OAuthApp{
Name: GenerateTestAppName(),
Homepage: "https://nowhere.com",
Description: "test",
CallbackUrls: []string{"https://nowhere.com"},
CreatorId: th.SystemAdminUser.Id,
ClientSecret: model.NewId(), // Explicitly set secret to make it confidential
}
rapp, appErr := th.App.CreateOAuthApp(oapp)
require.Nil(t, appErr)
authRequest := &model.AuthorizeRequest{
ResponseType: model.AuthCodeResponseType,
ClientId: rapp.Id,
RedirectURI: rapp.CallbackUrls[0],
Scope: "",
State: "123",
}
// Test auth code flow
ruri, _, err := apiClient.AuthorizeOAuthApp(context.Background(), authRequest)
require.NoError(t, err)
require.NotEmpty(t, ruri, "redirect url should be set")
ru, err := url.Parse(ruri)
require.NoError(t, err)
require.NotNil(t, ru, "redirect url unparseable")
require.NotEmpty(t, ru.Query().Get("code"), "authorization code not returned")
require.Equal(t, ru.Query().Get("state"), authRequest.State, "returned state doesn't match")
// Test implicit flow
authRequest.ResponseType = model.ImplicitResponseType
ruri, _, err = apiClient.AuthorizeOAuthApp(context.Background(), authRequest)
require.NoError(t, err)
require.False(t, ruri == "", "redirect url should be set")
ru, err = url.Parse(ruri)
require.NoError(t, err)
require.NotNil(t, ru, "redirect url unparseable")
values, err := url.ParseQuery(ru.Fragment)
require.NoError(t, err)
assert.False(t, values.Get("access_token") == "", "access_token not returned")
assert.Equal(t, authRequest.State, values.Get("state"), "returned state doesn't match")
oldToken := apiClient.AuthToken
apiClient.AuthToken = values.Get("access_token")
_, resp, err := apiClient.AuthorizeOAuthApp(context.Background(), authRequest)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
apiClient.AuthToken = oldToken
authRequest.RedirectURI = ""
_, resp, err = apiClient.AuthorizeOAuthApp(context.Background(), authRequest)
require.Error(t, err)
CheckBadRequestStatus(t, resp)
authRequest.RedirectURI = "http://somewhereelse.com"
_, resp, err = apiClient.AuthorizeOAuthApp(context.Background(), authRequest)
require.Error(t, err)
CheckBadRequestStatus(t, resp)
authRequest.RedirectURI = rapp.CallbackUrls[0]
authRequest.ResponseType = ""
_, resp, err = apiClient.AuthorizeOAuthApp(context.Background(), authRequest)
require.Error(t, err)
CheckBadRequestStatus(t, resp)
authRequest.ResponseType = model.AuthCodeResponseType
authRequest.ClientId = ""
_, resp, err = apiClient.AuthorizeOAuthApp(context.Background(), authRequest)
require.Error(t, err)
CheckBadRequestStatus(t, resp)
authRequest.ClientId = model.NewId()
_, resp, err = apiClient.AuthorizeOAuthApp(context.Background(), authRequest)
require.Error(t, err)
CheckNotFoundStatus(t, resp)
// test callback URI doesn't have malformed query parameters
oappWithQueryParamInCallback := &model.OAuthApp{
Name: GenerateTestAppName(),
Homepage: "https://nowhere.com",
Description: "test",
CallbackUrls: []string{"https://nowhere.com?simply=lovely"},
CreatorId: th.SystemAdminUser.Id,
ClientSecret: model.NewId(), // Explicitly set secret to make it confidential
}
rapp, appErr = th.App.CreateOAuthApp(oappWithQueryParamInCallback)
require.Nil(t, appErr)
authRequest = &model.AuthorizeRequest{
ResponseType: model.AuthCodeResponseType,
ClientId: rapp.Id,
RedirectURI: rapp.CallbackUrls[0],
Scope: "",
State: "/oauthcallback?sesskey=abcd&other=123",
}
uriResponse, _, err := apiClient.AuthorizeOAuthApp(context.Background(), authRequest)
require.NoError(t, err)
ru, err = url.Parse(uriResponse)
require.NoError(t, err)
require.NotEmpty(t, uriResponse, "redirect url should be set")
require.NotNil(t, ru, "redirect url unparseable")
// require no query parameter to have "?"
require.False(t, strings.Contains(ru.RawQuery, "?"), "should not malform query parameters")
require.NotEmpty(t, ru.Query().Get("code"), "authorization code not returned")
// test state is not encoded multiple times
require.Equal(t, ru.Query().Get("state"), authRequest.State, "returned state doesn't match")
// test state is URL encoded at least once
require.Empty(t, ru.Query().Get("other"), "state's query parameters should not leak")
}
func TestDeauthorizeOAuthApp(t *testing.T) {
th := Setup(t).InitBasic(t)
th.Login(t, apiClient, th.SystemAdminUser)
enableOAuth := th.App.Config().ServiceSettings.EnableOAuthServiceProvider
defer func() {
th.App.UpdateConfig(func(cfg *model.Config) { cfg.ServiceSettings.EnableOAuthServiceProvider = enableOAuth })
}()
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableOAuthServiceProvider = true })
oapp := &model.OAuthApp{
Name: GenerateTestAppName(),
Homepage: "https://nowhere.com",
Description: "test",
CallbackUrls: []string{"https://nowhere.com"},
CreatorId: th.SystemAdminUser.Id,
ClientSecret: model.NewId(), // Explicitly set secret to make it confidential
}
rapp, appErr := th.App.CreateOAuthApp(oapp)
require.Nil(t, appErr)
authRequest := &model.AuthorizeRequest{
ResponseType: model.AuthCodeResponseType,
ClientId: rapp.Id,
RedirectURI: rapp.CallbackUrls[0],
Scope: "",
State: "123",
}
_, _, err := apiClient.AuthorizeOAuthApp(context.Background(), authRequest)
require.NoError(t, err)
_, err = apiClient.DeauthorizeOAuthApp(context.Background(), rapp.Id)
require.NoError(t, err)
resp, err := apiClient.DeauthorizeOAuthApp(context.Background(), "junk")
require.Error(t, err)
CheckBadRequestStatus(t, resp)
_, err = apiClient.DeauthorizeOAuthApp(context.Background(), model.NewId())
require.NoError(t, err)
th.Logout(apiClient)
resp, err = apiClient.DeauthorizeOAuthApp(context.Background(), rapp.Id)
require.Error(t, err)
CheckUnauthorizedStatus(t, resp)
}
func TestOAuthAccessToken(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
th := Setup(t).InitBasic(t)
th.Login(t, apiClient, th.SystemAdminUser)
enableOAuth := th.App.Config().ServiceSettings.EnableOAuthServiceProvider
defer func() {
th.App.UpdateConfig(func(cfg *model.Config) { cfg.ServiceSettings.EnableOAuthServiceProvider = enableOAuth })
}()
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableOAuthServiceProvider = true })
defaultRolePermissions := th.SaveDefaultRolePermissions()
defer func() {
th.RestoreDefaultRolePermissions(defaultRolePermissions)
}()
th.AddPermissionToRole(model.PermissionManageOAuth.Id, model.TeamUserRoleId)
th.AddPermissionToRole(model.PermissionManageOAuth.Id, model.SystemUserRoleId)
oauthApp := &model.OAuthApp{
Name: "TestApp5" + model.NewId(),
Homepage: "https://nowhere.com",
Description: "test",
CallbackUrls: []string{"https://nowhere.com"},
CreatorId: th.SystemAdminUser.Id,
ClientSecret: model.NewId(), // Explicitly set secret to make it confidential
}
oauthApp, appErr := th.App.CreateOAuthApp(oauthApp)
require.Nil(t, appErr)
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableOAuthServiceProvider = false })
data := url.Values{"grant_type": []string{"junk"}, "client_id": []string{"12345678901234567890123456"}, "client_secret": []string{"12345678901234567890123456"}, "code": []string{"junk"}, "redirect_uri": []string{oauthApp.CallbackUrls[0]}}
_, _, err := apiClient.GetOAuthAccessToken(context.Background(), data)
require.Error(t, err, "should have failed - oauth providing turned off")
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableOAuthServiceProvider = true })
authRequest := &model.AuthorizeRequest{
ResponseType: model.AuthCodeResponseType,
ClientId: oauthApp.Id,
RedirectURI: oauthApp.CallbackUrls[0],
Scope: "all",
State: "123",
}
redirect, _, err := apiClient.AuthorizeOAuthApp(context.Background(), authRequest)
require.NoError(t, err)
rurl, err := url.Parse(redirect)
require.NoError(t, err)
data = url.Values{"grant_type": []string{"junk"}, "client_id": []string{oauthApp.Id}, "client_secret": []string{oauthApp.ClientSecret}, "code": []string{rurl.Query().Get("code")}, "redirect_uri": []string{oauthApp.CallbackUrls[0]}}
_, resp, err := apiClient.GetOAuthAccessToken(context.Background(), data)
require.Error(t, err, "should have failed - bad grant type")
CheckBadRequestStatus(t, resp)
data.Set("grant_type", model.AccessTokenGrantType)
data.Set("client_id", "")
_, _, err = apiClient.GetOAuthAccessToken(context.Background(), data)
require.Error(t, err, "should have failed - missing client id")
data.Set("client_id", "junk")
_, _, err = apiClient.GetOAuthAccessToken(context.Background(), data)
require.Error(t, err, "should have failed - bad client id")
data.Set("client_id", oauthApp.Id)
data.Set("client_secret", "")
_, _, err = apiClient.GetOAuthAccessToken(context.Background(), data)
require.Error(t, err, "should have failed - missing client secret")
data.Set("client_secret", "junk")
_, _, err = apiClient.GetOAuthAccessToken(context.Background(), data)
require.Error(t, err, "should have failed - bad client secret")
data.Set("client_secret", oauthApp.ClientSecret)
data.Set("code", "")
_, _, err = apiClient.GetOAuthAccessToken(context.Background(), data)
require.Error(t, err, "should have failed - missing code")
data.Set("code", "junk")
_, _, err = apiClient.GetOAuthAccessToken(context.Background(), data)
require.Error(t, err, "should have failed - bad code")
data.Set("code", rurl.Query().Get("code"))
data.Set("redirect_uri", "junk")
_, _, err = apiClient.GetOAuthAccessToken(context.Background(), data)
require.Error(t, err, "should have failed - non-matching redirect uri")
// reset data for successful request
data.Set("grant_type", model.AccessTokenGrantType)
data.Set("client_id", oauthApp.Id)
data.Set("client_secret", oauthApp.ClientSecret)
data.Set("code", rurl.Query().Get("code"))
data.Set("redirect_uri", oauthApp.CallbackUrls[0])
token := ""
refreshToken := ""
rsp, _, err := apiClient.GetOAuthAccessToken(context.Background(), data)
require.NoError(t, err)
require.NotEmpty(t, rsp.AccessToken, "access token not returned")
require.NotEmpty(t, rsp.RefreshToken, "refresh token not returned")
token, refreshToken = rsp.AccessToken, rsp.RefreshToken
require.Equal(t, rsp.TokenType, model.AccessTokenType, "access token type incorrect")
_, err = apiClient.DoAPIGet(context.Background(), "/oauth_test", "")
require.NoError(t, err)
apiClient.SetOAuthToken("")
_, err = apiClient.DoAPIGet(context.Background(), "/oauth_test", "")
require.Error(t, err, "should have failed - no access token provided")
apiClient.SetOAuthToken("badtoken")
_, err = apiClient.DoAPIGet(context.Background(), "/oauth_test", "")
require.Error(t, err, "should have failed - bad token provided")
apiClient.SetOAuthToken(token)
_, err = apiClient.DoAPIGet(context.Background(), "/oauth_test", "")
require.NoError(t, err)
_, _, err = apiClient.GetOAuthAccessToken(context.Background(), data)
require.Error(t, err, "should have failed - tried to reuse auth code")
data.Set("grant_type", model.RefreshTokenGrantType)
data.Set("client_id", oauthApp.Id)
data.Set("client_secret", oauthApp.ClientSecret)
data.Set("refresh_token", "")
data.Set("redirect_uri", oauthApp.CallbackUrls[0])
data.Del("code")
_, _, err = apiClient.GetOAuthAccessToken(context.Background(), data)
require.Error(t, err, "Should have failed - refresh token empty")
data.Set("refresh_token", refreshToken)
rsp, _, err = apiClient.GetOAuthAccessToken(context.Background(), data)
require.NoError(t, err)
require.NotEmpty(t, rsp.AccessToken, "access token not returned")
require.NotEmpty(t, rsp.RefreshToken, "refresh token not returned")
require.NotEqual(t, rsp.RefreshToken, refreshToken, "refresh token did not update")
require.Equal(t, rsp.TokenType, model.AccessTokenType, "access token type incorrect")
apiClient.SetOAuthToken(rsp.AccessToken)
_, err = apiClient.DoAPIGet(context.Background(), "/oauth_test", "")
require.NoError(t, err)
data.Set("refresh_token", rsp.RefreshToken)
rsp, _, err = apiClient.GetOAuthAccessToken(context.Background(), data)
require.NoError(t, err)
require.NotEmpty(t, rsp.AccessToken, "access token not returned")
require.NotEmpty(t, rsp.RefreshToken, "refresh token not returned")
require.NotEqual(t, rsp.RefreshToken, refreshToken, "refresh token did not update")
require.Equal(t, rsp.TokenType, model.AccessTokenType, "access token type incorrect")
apiClient.SetOAuthToken(rsp.AccessToken)
_, err = apiClient.DoAPIGet(context.Background(), "/oauth_test", "")
require.NoError(t, err)
authData := &model.AuthData{ClientId: oauthApp.Id, RedirectUri: oauthApp.CallbackUrls[0], UserId: th.BasicUser.Id, Code: model.NewId(), ExpiresIn: -1}
_, err = th.App.Srv().Store().OAuth().SaveAuthData(authData)
require.NoError(t, err)
data.Set("grant_type", model.AccessTokenGrantType)
data.Set("client_id", oauthApp.Id)
data.Set("client_secret", oauthApp.ClientSecret)
data.Set("redirect_uri", oauthApp.CallbackUrls[0])
data.Set("code", authData.Code)
data.Del("refresh_token")
_, _, err = apiClient.GetOAuthAccessToken(context.Background(), data)
require.Error(t, err, "Should have failed - code is expired")
apiClient.ClearOAuthToken()
}
func TestMobileLoginWithOAuth(t *testing.T) {
th := Setup(t).InitBasic(t)
c := &Context{
App: th.App,
AppContext: th.Context,
Logger: th.TestLogger,
Params: &Params{
Service: "gitlab",
},
}
siteURL := "http://localhost:8065"
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.SiteURL = siteURL
*cfg.GitLabSettings.Enable = true
})
provider := &MattermostTestProvider{}
einterfaces.RegisterOAuthProvider(model.ServiceGitlab, provider)
t.Run("Should redirect to the SSO login page when valid URL Scheme is passed as redirect_to parameter", func(t *testing.T) {
responseWriter := httptest.NewRecorder()
request, err := http.NewRequest(http.MethodGet, th.App.GetSiteURL()+"/oauth/gitlab/mobile_login?redirect_to="+url.QueryEscape("mmauth://"), nil)
require.NoError(t, err)
mobileLoginWithOAuth(c, responseWriter, request)
assert.Equal(t, responseWriter.Code, 302)
assert.NotContains(t, responseWriter.Body.String(), siteURL)
})
t.Run("Should include SiteURL in the output when invalid URL Scheme is passed", func(t *testing.T) {
einterfaces.RegisterOAuthProvider(model.ServiceGitlab, provider)
responseWriter := httptest.NewRecorder()
request, err := http.NewRequest(http.MethodGet, th.App.GetSiteURL()+"/oauth/gitlab/mobile_login?redirect_to="+url.QueryEscape("randomScheme://"), nil)
require.NoError(t, err)
mobileLoginWithOAuth(c, responseWriter, request)
body := responseWriter.Body.String()
assert.NotContains(t, body, "randomScheme://")
assert.Contains(t, body, siteURL)
})
t.Run("Should not include the redirect URL consisting of javascript protocol", func(t *testing.T) {
responseWriter := httptest.NewRecorder()
request, err := http.NewRequest(http.MethodGet, th.App.GetSiteURL()+"/oauth/gitlab/mobile_login?redirect_to="+url.QueryEscape("javascript:alert('hello')"), nil)
require.NoError(t, err)
mobileLoginWithOAuth(c, responseWriter, request)
body := responseWriter.Body.String()
assert.NotContains(t, body, "javascript:alert('hello')")
assert.Contains(t, body, siteURL)
})
t.Run("Should not include the redirect URL consisting of javascript protocol in mixed case", func(t *testing.T) {
responseWriter := httptest.NewRecorder()
request, err := http.NewRequest(http.MethodGet, th.App.GetSiteURL()+"/oauth/gitlab/mobile_login?redirect_to="+url.QueryEscape("JaVasCript:alert('hello')"), nil)
require.NoError(t, err)
mobileLoginWithOAuth(c, responseWriter, request)
body := responseWriter.Body.String()
assert.NotContains(t, body, "JaVasCript:alert('hello')")
assert.Contains(t, body, siteURL)
})
}
func TestOAuthComplete(t *testing.T) {
if testing.Short() {
t.SkipNow()
}
th := Setup(t).InitBasic(t)
th.Login(t, apiClient, th.SystemAdminUser)
gitLabSettingsEnable := th.App.Config().GitLabSettings.Enable
gitLabSettingsAuthEndpoint := th.App.Config().GitLabSettings.AuthEndpoint
gitLabSettingsId := th.App.Config().GitLabSettings.Id
gitLabSettingsSecret := th.App.Config().GitLabSettings.Secret
gitLabSettingsTokenEndpoint := th.App.Config().GitLabSettings.TokenEndpoint
gitLabSettingsUserAPIEndpoint := th.App.Config().GitLabSettings.UserAPIEndpoint
enableOAuthServiceProvider := th.App.Config().ServiceSettings.EnableOAuthServiceProvider
defer func() {
th.App.UpdateConfig(func(cfg *model.Config) { cfg.GitLabSettings.Enable = gitLabSettingsEnable })
th.App.UpdateConfig(func(cfg *model.Config) { cfg.GitLabSettings.AuthEndpoint = gitLabSettingsAuthEndpoint })
th.App.UpdateConfig(func(cfg *model.Config) { cfg.GitLabSettings.Id = gitLabSettingsId })
th.App.UpdateConfig(func(cfg *model.Config) { cfg.GitLabSettings.Secret = gitLabSettingsSecret })
th.App.UpdateConfig(func(cfg *model.Config) { cfg.GitLabSettings.TokenEndpoint = gitLabSettingsTokenEndpoint })
th.App.UpdateConfig(func(cfg *model.Config) { cfg.GitLabSettings.UserAPIEndpoint = gitLabSettingsUserAPIEndpoint })
th.App.UpdateConfig(func(cfg *model.Config) { cfg.ServiceSettings.EnableOAuthServiceProvider = enableOAuthServiceProvider })
}()
r, err := HTTPGet(apiClient.URL+"/login/gitlab/complete?code=123", apiClient.HTTPClient, "", true)
assert.Error(t, err)
closeBody(r)
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.GitLabSettings.Enable = true })
r, err = HTTPGet(apiClient.URL+"/login/gitlab/complete?code=123&state=!#$#F@#Yˆ&~ñ", apiClient.HTTPClient, "", true)
assert.Error(t, err)
closeBody(r)
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.GitLabSettings.AuthEndpoint = apiClient.URL + "/oauth/authorize" })
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.GitLabSettings.Id = model.NewId() })
stateProps := map[string]string{}
stateProps["action"] = model.OAuthActionLogin
stateProps["team_id"] = th.BasicTeam.Id
stateProps["redirect_to"] = *th.App.Config().GitLabSettings.AuthEndpoint
state := base64.StdEncoding.EncodeToString([]byte(model.MapToJSON(stateProps)))
r, err = HTTPGet(apiClient.URL+"/login/gitlab/complete?code=123&state="+url.QueryEscape(state), apiClient.HTTPClient, "", true)
assert.Error(t, err)
closeBody(r)
stateProps["hash"] = utils.HashSha256(*th.App.Config().GitLabSettings.Id)
state = base64.StdEncoding.EncodeToString([]byte(model.MapToJSON(stateProps)))
r, err = HTTPGet(apiClient.URL+"/login/gitlab/complete?code=123&state="+url.QueryEscape(state), apiClient.HTTPClient, "", true)
assert.Error(t, err)
closeBody(r)
// We are going to use mattermost as the provider emulating gitlab
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableOAuthServiceProvider = true })
defaultRolePermissions := th.SaveDefaultRolePermissions()
defer func() {
th.RestoreDefaultRolePermissions(defaultRolePermissions)
}()
th.AddPermissionToRole(model.PermissionManageOAuth.Id, model.TeamUserRoleId)
th.AddPermissionToRole(model.PermissionManageOAuth.Id, model.SystemUserRoleId)
oauthApp := &model.OAuthApp{
Name: "TestApp5" + model.NewId(),
Homepage: "https://nowhere.com",
Description: "test",
ClientSecret: model.NewId(), // Explicitly set secret to make it confidential
CallbackUrls: []string{
apiClient.URL + "/signup/" + model.ServiceGitlab + "/complete",
apiClient.URL + "/login/" + model.ServiceGitlab + "/complete",
},
CreatorId: th.SystemAdminUser.Id,
IsTrusted: true,
}
oauthApp, appErr := th.App.CreateOAuthApp(oauthApp)
require.Nil(t, appErr)
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.GitLabSettings.Id = oauthApp.Id })
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.GitLabSettings.Secret = oauthApp.ClientSecret })
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.GitLabSettings.AuthEndpoint = apiClient.URL + "/oauth/authorize" })
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.GitLabSettings.TokenEndpoint = apiClient.URL + "/oauth/access_token" })
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.GitLabSettings.UserAPIEndpoint = apiClient.APIURL + "/users/me" })
provider := &MattermostTestProvider{}
authRequest := &model.AuthorizeRequest{
ResponseType: model.AuthCodeResponseType,
ClientId: oauthApp.Id,
RedirectURI: oauthApp.CallbackUrls[0],
Scope: "all",
State: "123",
}
redirect, _, err := apiClient.AuthorizeOAuthApp(context.Background(), authRequest)
require.NoError(t, err)
rurl, err := url.Parse(redirect)
require.NoError(t, err)
code := rurl.Query().Get("code")
stateProps["action"] = model.OAuthActionEmailToSSO
delete(stateProps, "team_id")
stateProps["redirect_to"] = *th.App.Config().GitLabSettings.AuthEndpoint
stateProps["hash"] = utils.HashSha256(*th.App.Config().GitLabSettings.Id)
stateProps["redirect_to"] = "/oauth/authorize"
state = base64.StdEncoding.EncodeToString([]byte(model.MapToJSON(stateProps)))
r, err = HTTPGet(apiClient.URL+"/login/"+model.ServiceGitlab+"/complete?code="+url.QueryEscape(code)+"&state="+url.QueryEscape(state), apiClient.HTTPClient, "", false)
if err == nil {
closeBody(r)
}
einterfaces.RegisterOAuthProvider(model.ServiceGitlab, provider)
redirect, _, err = apiClient.AuthorizeOAuthApp(context.Background(), authRequest)
require.NoError(t, err)
rurl, _ = url.Parse(redirect)
code = rurl.Query().Get("code")
r, err = HTTPGet(apiClient.URL+"/login/"+model.ServiceGitlab+"/complete?code="+url.QueryEscape(code)+"&state="+url.QueryEscape(state), apiClient.HTTPClient, "", false)
if err == nil {
closeBody(r)
}
_, nErr := th.App.Srv().Store().User().UpdateAuthData(
th.BasicUser.Id, model.ServiceGitlab, &th.BasicUser.Email, th.BasicUser.Email, true)
require.NoError(t, nErr)
redirect, _, err = apiClient.AuthorizeOAuthApp(context.Background(), authRequest)
require.NoError(t, err)
rurl, _ = url.Parse(redirect)
code = rurl.Query().Get("code")
stateProps["action"] = model.OAuthActionLogin
state = base64.StdEncoding.EncodeToString([]byte(model.MapToJSON(stateProps)))
if r, err = HTTPGet(apiClient.URL+"/login/"+model.ServiceGitlab+"/complete?code="+url.QueryEscape(code)+"&state="+url.QueryEscape(state), apiClient.HTTPClient, "", false); err == nil {
closeBody(r)
}
redirect, _, err = apiClient.AuthorizeOAuthApp(context.Background(), authRequest)
require.NoError(t, err)
rurl, _ = url.Parse(redirect)
code = rurl.Query().Get("code")
delete(stateProps, "action")
state = base64.StdEncoding.EncodeToString([]byte(model.MapToJSON(stateProps)))
if r, err = HTTPGet(apiClient.URL+"/login/"+model.ServiceGitlab+"/complete?code="+url.QueryEscape(code)+"&state="+url.QueryEscape(state), apiClient.HTTPClient, "", false); err == nil {
closeBody(r)
}
redirect, _, err = apiClient.AuthorizeOAuthApp(context.Background(), authRequest)
require.NoError(t, err)
rurl, _ = url.Parse(redirect)
code = rurl.Query().Get("code")
stateProps["action"] = model.OAuthActionSignup
state = base64.StdEncoding.EncodeToString([]byte(model.MapToJSON(stateProps)))
if r, err := HTTPGet(apiClient.URL+"/login/"+model.ServiceGitlab+"/complete?code="+url.QueryEscape(code)+"&state="+url.QueryEscape(state), apiClient.HTTPClient, "", false); err == nil {
closeBody(r)
}
}
func TestOAuthComplete_ErrorMessages(t *testing.T) {
th := Setup(t).InitBasic(t)
c := &Context{
App: th.App,
AppContext: th.Context,
Logger: th.TestLogger,
Params: &Params{
Service: "gitlab",
},
}
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.GitLabSettings.Enable = true })
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableOAuthServiceProvider = true })
provider := &MattermostTestProvider{}
einterfaces.RegisterOAuthProvider(model.ServiceGitlab, provider)
responseWriter := httptest.NewRecorder()
// Renders for web & mobile app with webview
request, err := http.NewRequest(http.MethodGet, th.App.GetSiteURL()+"/signup/gitlab/complete?code=1234", nil)
require.NoError(t, err)
completeOAuth(c, responseWriter, request)
assert.Contains(t, responseWriter.Body.String(), "<!-- web error message -->")
// Renders for mobile app with redirect url
stateProps := map[string]string{}
stateProps["action"] = model.OAuthActionMobile
stateProps["redirect_to"] = th.App.Config().NativeAppSettings.AppCustomURLSchemes[0]
state := base64.StdEncoding.EncodeToString([]byte(model.MapToJSON(stateProps)))
request2, err := http.NewRequest(http.MethodGet, th.App.GetSiteURL()+"/signup/gitlab/complete?code=1234&state="+url.QueryEscape(state), nil)
require.NoError(t, err)
completeOAuth(c, responseWriter, request2)
assert.Contains(t, responseWriter.Body.String(), "<!-- mobile app message -->")
}
func HTTPGet(url string, httpClient *http.Client, authToken string, followRedirect bool) (*http.Response, error) {
rq, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
rq.Close = true
if authToken != "" {
rq.Header.Set(model.HeaderAuth, authToken)
}
if !followRedirect {
httpClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
}
}
rp, err := httpClient.Do(rq)
if err != nil {
return nil, err
} else if rp.StatusCode == 304 {
return rp, nil
} else if rp.StatusCode == 307 {
return rp, nil
} else if rp.StatusCode >= 300 {
defer closeBody(rp)
return rp, model.AppErrorFromJSON(rp.Body)
}
return rp, nil
}
func closeBody(r *http.Response) {
if r != nil && r.Body != nil {
_, _ = io.ReadAll(r.Body) // Discard and ignore errors - just draining the body
r.Body.Close()
}
}
type MattermostTestProvider struct {
}
func (m *MattermostTestProvider) GetUserFromJSON(_ request.CTX, data io.Reader, tokenUser *model.User) (*model.User, error) {
var user model.User
if err := json.NewDecoder(data).Decode(&user); err != nil {
return nil, err
}
user.AuthData = &user.Email
return &user, nil
}
func (m *MattermostTestProvider) GetSSOSettings(_ request.CTX, config *model.Config, service string) (*model.SSOSettings, error) {
return &config.GitLabSettings, nil
}
func (m *MattermostTestProvider) GetUserFromIdToken(_ request.CTX, token string) (*model.User, error) {
return nil, nil
}
func (m *MattermostTestProvider) IsSameUser(_ request.CTX, dbUser, oauthUser *model.User) bool {
return dbUser.AuthData == oauthUser.AuthData
}
func GenerateTestAppName() string {
return "fakeoauthapp" + model.NewRandomString(10)
}
func checkHTTPStatus(t *testing.T, resp *model.Response, expectedStatus int) {
t.Helper()
require.NotNilf(t, resp, "Unexpected nil response, expected http status:%v", expectedStatus)
require.Equalf(t, expectedStatus, resp.StatusCode, "Expected http status:%v, got %v", expectedStatus, resp.StatusCode)
}
func CheckForbiddenStatus(t *testing.T, resp *model.Response) {
t.Helper()
checkHTTPStatus(t, resp, http.StatusForbidden)
}
func CheckUnauthorizedStatus(t *testing.T, resp *model.Response) {
t.Helper()
checkHTTPStatus(t, resp, http.StatusUnauthorized)
}
func CheckNotFoundStatus(t *testing.T, resp *model.Response) {
t.Helper()
checkHTTPStatus(t, resp, http.StatusNotFound)
}
func CheckBadRequestStatus(t *testing.T, resp *model.Response) {
t.Helper()
checkHTTPStatus(t, resp, http.StatusBadRequest)
}
func (th *TestHelper) Login(tb testing.TB, client *model.Client4, user *model.User) {
tb.Helper()
session := &model.Session{
UserId: user.Id,
Roles: user.GetRawRoles(),
IsOAuth: false,
}
session, appErr := th.App.CreateSession(th.Context, session)
require.Nil(tb, appErr)
client.AuthToken = session.Token
client.AuthType = model.HeaderBearer
}
func (th *TestHelper) Logout(client *model.Client4) {
client.AuthToken = ""
}
func (th *TestHelper) SaveDefaultRolePermissions() map[string][]string {
results := make(map[string][]string)
for _, roleName := range []string{
"system_user",
"system_admin",
"team_user",
"team_admin",
"channel_user",
"channel_admin",
} {
role, err1 := th.App.GetRoleByName(th.Context, roleName)
if err1 != nil {
panic(err1)
}
results[roleName] = role.Permissions
}
return results
}
func (th *TestHelper) RestoreDefaultRolePermissions(data map[string][]string) {
for roleName, permissions := range data {
role, err1 := th.App.GetRoleByName(th.Context, roleName)
if err1 != nil {
panic(err1)
}
if strings.Join(role.Permissions, " ") == strings.Join(permissions, " ") {
continue
}
role.Permissions = permissions
_, err2 := th.App.UpdateRole(role)
if err2 != nil {
panic(err2)
}
}
}
// func (th *TestHelper) RemovePermissionFromRole(permission string, roleName string) {
// utils.DisableDebugLogForTest()
// role, err1 := th.App.GetRoleByName(roleName)
// if err1 != nil {
// utils.EnableDebugLogForTest()
// panic(err1)
// }
// var newPermissions []string
// for _, p := range role.Permissions {
// if p != permission {
// newPermissions = append(newPermissions, p)
// }
// }
// if strings.Join(role.Permissions, " ") == strings.Join(newPermissions, " ") {
// utils.EnableDebugLogForTest()
// return
// }
// role.Permissions = newPermissions
// _, err2 := th.App.UpdateRole(role)
// if err2 != nil {
// utils.EnableDebugLogForTest()
// panic(err2)
// }
// utils.EnableDebugLogForTest()
// }
func (th *TestHelper) AddPermissionToRole(permission string, roleName string) {
role, err1 := th.App.GetRoleByName(th.Context, roleName)
if err1 != nil {
panic(err1)
}
if slices.Contains(role.Permissions, permission) {
return
}
role.Permissions = append(role.Permissions, permission)
_, err2 := th.App.UpdateRole(role)
if err2 != nil {
panic(err2)
}
}
func TestFullyQualifiedRedirectURL(t *testing.T) {
const siteURL = "https://xxx.yyy/mm"
for target, expected := range map[string]string{
"": siteURL,
"/": siteURL + "/",
"some-path": siteURL + "/some-path",
"/some-path": siteURL + "/some-path",
"/some-path/": siteURL + "/some-path/",
"/some-path?foo=bar": siteURL + "/some-path?foo=bar",
"/some-path#section": siteURL + "/some-path#section",
"../bad-path": siteURL,
"/index.html": siteURL + "/index.html",
"//evil.com": siteURL,
"https://xxx.yyy/mm": siteURL,
"https://xxx.yyy/mm//double-concat": siteURL + "//double-concat",
"https://xxx.yyy/other-path/": siteURL,
"https://xxx.yyy/mm/some-path": siteURL + "/some-path",
"https://yyy.zzz/mm/some-path": siteURL,
"https://xxx.yyy/mm/some-path?foo=bar": siteURL + "/some-path?foo=bar",
"https://xxx.yyy/mm/some-path#section": siteURL + "/some-path#section",
"https://xxx.yyy/mm/../malicious-path": siteURL,
":foo": siteURL,
"mmauth://callback": "mmauth://callback",
"mmauth://xxx.yyy/mm": siteURL, // invalid mobile URL (wrong host)
} {
t.Run(target, func(t *testing.T) {
require.Equal(t, expected, fullyQualifiedRedirectURL(siteURL, target, []string{"mmauth://"}))
})
}
}
func TestAuthorizeOAuthPage(t *testing.T) {
th := Setup(t).InitBasic(t)
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.EnableOAuthServiceProvider = true
})
// Helper function to create context with session
createContext := func() *Context {
c := &Context{
App: th.App,
AppContext: th.Context,
Logger: th.TestLogger,
}
session := &model.Session{
UserId: th.BasicUser.Id,
Token: model.NewId(),
}
c.AppContext = c.AppContext.WithSession(session)
return c
}
t.Run("PublicClient_PKCERequired", func(t *testing.T) {
dcrRequest := &model.ClientRegistrationRequest{
ClientName: model.NewPointer("Public Client Test"),
RedirectURIs: []string{"https://example.com/callback"},
TokenEndpointAuthMethod: model.NewPointer(model.ClientAuthMethodNone),
ClientURI: model.NewPointer("https://example.com"),
}
publicApp, appErr := th.App.RegisterOAuthClient(th.Context, dcrRequest, th.BasicUser.Id)
require.Nil(t, appErr)
c := createContext()
responseWriter := httptest.NewRecorder()
requestURL := "/oauth/authorize?response_type=code&client_id=" + publicApp.Id + "&redirect_uri=" + url.QueryEscape(publicApp.CallbackUrls[0]) + "&state=test_state"
request := httptest.NewRequest(http.MethodGet, requestURL, nil)
authorizeOAuthPage(c, responseWriter, request)
assert.Contains(t, responseWriter.Body.String(), "PKCE+is+required+for+public+clients")
})
t.Run("PublicClient_WithPKCE_Success", func(t *testing.T) {
dcrRequest := &model.ClientRegistrationRequest{
ClientName: model.NewPointer("Public Client Test"),
RedirectURIs: []string{"https://example.com/callback"},
TokenEndpointAuthMethod: model.NewPointer(model.ClientAuthMethodNone),
ClientURI: model.NewPointer("https://example.com"),
}
publicApp, appErr := th.App.RegisterOAuthClient(th.Context, dcrRequest, th.BasicUser.Id)
require.Nil(t, appErr)
publicApp.IsTrusted = true
existingApp, getErr := th.App.GetOAuthApp(publicApp.Id)
require.Nil(t, getErr)
_, updateErr := th.App.UpdateOAuthApp(existingApp, publicApp)
require.Nil(t, updateErr)
c := createContext()
responseWriter := httptest.NewRecorder()
codeChallenge := "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
codeChallengeMethod := model.PKCECodeChallengeMethodS256
request := httptest.NewRequest(http.MethodGet,
"/oauth/authorize?response_type=code&client_id="+publicApp.Id+"&redirect_uri="+url.QueryEscape(publicApp.CallbackUrls[0])+
"&state=test_state&code_challenge="+codeChallenge+"&code_challenge_method="+codeChallengeMethod, nil)
authorizeOAuthPage(c, responseWriter, request)
assert.Equal(t, http.StatusFound, responseWriter.Code)
location := responseWriter.Header().Get("Location")
assert.Contains(t, location, "https://example.com/callback")
assert.Contains(t, location, "code=")
assert.Contains(t, location, "state=test_state")
})
t.Run("ConfidentialClient_PKCEOptional", func(t *testing.T) {
confidentialApp := &model.OAuthApp{
Name: "Confidential Client Test",
CreatorId: th.BasicUser.Id,
Homepage: "https://example.com",
Description: "test confidential client",
CallbackUrls: []string{"https://example.com/callback"},
ClientSecret: "test-secret",
}
confidentialApp, appErr := th.App.CreateOAuthApp(confidentialApp)
require.Nil(t, appErr)
confidentialApp.IsTrusted = true
existingApp, getErr := th.App.GetOAuthApp(confidentialApp.Id)
require.Nil(t, getErr)
_, updateErr := th.App.UpdateOAuthApp(existingApp, confidentialApp)
require.Nil(t, updateErr)
c := createContext()
responseWriter := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet,
"/oauth/authorize?response_type=code&client_id="+confidentialApp.Id+"&redirect_uri="+url.QueryEscape(confidentialApp.CallbackUrls[0])+"&state=test_state", nil)
authorizeOAuthPage(c, responseWriter, request)
assert.Equal(t, http.StatusFound, responseWriter.Code)
location := responseWriter.Header().Get("Location")
assert.Contains(t, location, "https://example.com/callback")
assert.Contains(t, location, "code=")
assert.Contains(t, location, "state=test_state")
})
}
func TestAuthorizeOAuthAppHandler(t *testing.T) {
th := Setup(t).InitBasic(t)
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.EnableOAuthServiceProvider = true
})
// Helper function to create context with session
createContext := func() *Context {
c := &Context{
App: th.App,
AppContext: th.Context,
Logger: th.TestLogger,
}
session := &model.Session{
UserId: th.BasicUser.Id,
Token: model.NewId(),
}
c.AppContext = c.AppContext.WithSession(session)
return c
}
t.Run("PublicClient_PKCEParameters", func(t *testing.T) {
dcrRequest := &model.ClientRegistrationRequest{
ClientName: model.NewPointer("Public Client Test"),
RedirectURIs: []string{"https://example.com/callback"},
TokenEndpointAuthMethod: model.NewPointer(model.ClientAuthMethodNone),
ClientURI: model.NewPointer("https://example.com"),
}
publicApp, appErr := th.App.RegisterOAuthClient(th.Context, dcrRequest, th.BasicUser.Id)
require.Nil(t, appErr)
c := createContext()
authRequest := &model.AuthorizeRequest{
ResponseType: model.AuthCodeResponseType,
ClientId: publicApp.Id,
RedirectURI: publicApp.CallbackUrls[0],
State: "test_state",
Scope: "user",
CodeChallenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
CodeChallengeMethod: model.PKCECodeChallengeMethodS256,
}
requestBodyBytes, err := json.Marshal(authRequest)
require.NoError(t, err)
requestBody := strings.NewReader(string(requestBodyBytes))
responseWriter := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/oauth/authorize", requestBody)
request.Header.Set("Content-Type", "application/json")
authorizeOAuthApp(c, responseWriter, request)
assert.Equal(t, http.StatusOK, responseWriter.Code)
var response map[string]string
err = json.Unmarshal(responseWriter.Body.Bytes(), &response)
require.NoError(t, err)
redirectURL := response["redirect"]
assert.NotEmpty(t, redirectURL)
assert.Contains(t, redirectURL, "code=")
})
}
func TestGetAccessToken(t *testing.T) {
th := Setup(t).InitBasic(t)
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.EnableOAuthServiceProvider = true
})
// Helper function to create context
createContext := func() *Context {
return &Context{
App: th.App,
AppContext: th.Context,
Logger: th.TestLogger,
}
}
t.Run("PublicClient_NoClientSecret", func(t *testing.T) {
dcrRequest := &model.ClientRegistrationRequest{
ClientName: model.NewPointer("Public Client Test"),
RedirectURIs: []string{"https://example.com/callback"},
TokenEndpointAuthMethod: model.NewPointer(model.ClientAuthMethodNone),
ClientURI: model.NewPointer("https://example.com"),
}
publicApp, appErr := th.App.RegisterOAuthClient(th.Context, dcrRequest, th.BasicUser.Id)
require.Nil(t, appErr)
codeVerifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
codeChallenge := "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
authRequest := &model.AuthorizeRequest{
ResponseType: model.AuthCodeResponseType,
ClientId: publicApp.Id,
RedirectURI: publicApp.CallbackUrls[0],
State: "test_state",
Scope: "user",
CodeChallenge: codeChallenge,
CodeChallengeMethod: model.PKCECodeChallengeMethodS256,
}
redirectURL, appErr := th.App.AllowOAuthAppAccessToUser(th.Context, th.BasicUser.Id, authRequest)
require.Nil(t, appErr)
uri, err := url.Parse(redirectURL)
require.NoError(t, err)
code := uri.Query().Get("code")
require.NotEmpty(t, code)
c := createContext()
formData := url.Values{}
formData.Set("grant_type", model.AccessTokenGrantType)
formData.Set("code", code)
formData.Set("redirect_uri", publicApp.CallbackUrls[0])
formData.Set("client_id", publicApp.Id)
formData.Set("code_verifier", codeVerifier)
responseWriter := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/oauth/access_token", strings.NewReader(formData.Encode()))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
getAccessToken(c, responseWriter, request)
assert.Equal(t, http.StatusOK, responseWriter.Code)
var tokenResponse model.AccessResponse
err = json.Unmarshal(responseWriter.Body.Bytes(), &tokenResponse)
require.NoError(t, err)
assert.NotEmpty(t, tokenResponse.AccessToken)
assert.Equal(t, model.AccessTokenType, tokenResponse.TokenType)
assert.Empty(t, tokenResponse.RefreshToken)
})
}