Moderate severityNVD Advisory· Published Nov 14, 2025· Updated Nov 14, 2025
MS Teams plugin OAuth allows editing arbitrary posts
CVE-2025-55073
Description
Mattermost versions 10.11.x <= 10.11.3, 10.5.x <= 10.5.11, 10.12.x <= 10.12.0 fail to validate the relationship between the post being updated and the MSTeams plugin OAuth flow which allows an attacker to edit arbitrary posts via a crafted MSTeams plugin OAuth redirect URL.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
github.com/mattermost/mattermost-serverGo | >= 10.11.0, < 10.11.4 | 10.11.4 |
github.com/mattermost/mattermost-serverGo | >= 10.5.0, < 10.5.12 | 10.5.12 |
github.com/mattermost/mattermost-serverGo | >= 10.12.0, < 10.12.1 | 10.12.1 |
github.com/mattermost/mattermost/server/v8Go | < 8.0.0-20250929212932-a41db04d2746 | 8.0.0-20250929212932-a41db04d2746 |
Affected products
1- Range: 10.11.0
Patches
5375ce229f492MM 65084 server-side (#33861) (#34006) (#34044)
11 files changed · +309 −5
api/v4/source/users.yaml+53 −0 modified@@ -74,6 +74,59 @@ $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" + /api/v4/users/login/sso/code-exchange: + post: + tags: + - users + summary: Exchange SSO login code for session tokens + description: > + Exchange a short-lived login_code for session tokens using SAML code exchange (mobile SSO flow). + This endpoint is part of the mobile SSO code-exchange flow to prevent tokens + from appearing in deep links. + + ##### Permissions + + No permission required. + operationId: LoginSSOCodeExchange + requestBody: + content: + application/json: + schema: + type: object + required: + - login_code + - code_verifier + - state + properties: + login_code: + description: Short-lived one-time code from SSO callback + type: string + code_verifier: + description: SAML verifier to prove code possession + type: string + state: + description: State parameter to prevent CSRF attacks + type: string + description: SSO code exchange object + required: true + responses: + "200": + description: Code exchange successful + content: + application/json: + schema: + type: object + properties: + token: + description: Session token for authentication + type: string + csrf: + description: CSRF token for request validation + type: string + "400": + $ref: "#/components/responses/BadRequest" + "403": + $ref: "#/components/responses/Forbidden" /api/v4/users/logout: post: tags:
server/channels/api4/user.go+99 −0 modified@@ -4,6 +4,8 @@ package api4 import ( + "crypto/sha256" + "encoding/base64" "encoding/json" "fmt" "io" @@ -63,6 +65,7 @@ func (api *API) InitUser() { api.BaseRoutes.User.Handle("/mfa/generate", api.APISessionRequiredMfa(generateMfaSecret)).Methods(http.MethodPost) api.BaseRoutes.Users.Handle("/login", api.APIHandler(login)).Methods(http.MethodPost) + api.BaseRoutes.Users.Handle("/login/sso/code-exchange", api.APIHandler(loginSSOCodeExchange)).Methods(http.MethodPost) api.BaseRoutes.Users.Handle("/login/desktop_token", api.RateLimitedHandler(api.APIHandler(loginWithDesktopToken), model.RateLimitSettings{PerSec: model.NewPointer(2), MaxBurst: model.NewPointer(1)})).Methods(http.MethodPost) api.BaseRoutes.Users.Handle("/login/switch", api.APIHandler(switchAccountType)).Methods(http.MethodPost) api.BaseRoutes.Users.Handle("/login/cws", api.APIHandlerTrustRequester(loginCWS)).Methods(http.MethodPost) @@ -110,6 +113,102 @@ func (api *API) InitUser() { api.BaseRoutes.Users.Handle("/trigger-notify-admin-posts", api.APISessionRequired(handleTriggerNotifyAdminPosts)).Methods(http.MethodPost) } +// loginSSOCodeExchange exchanges a short-lived login_code for session tokens (mobile SAML code exchange) +func loginSSOCodeExchange(c *Context, w http.ResponseWriter, r *http.Request) { + if !c.App.Config().FeatureFlags.MobileSSOCodeExchange { + c.Err = model.NewAppError("loginSSOCodeExchange", "api.oauth.get_access_token.bad_request.app_error", nil, "feature disabled", http.StatusBadRequest) + return + } + props := model.MapFromJSON(r.Body) + loginCode := props["login_code"] + codeVerifier := props["code_verifier"] + state := props["state"] + + if loginCode == "" || codeVerifier == "" || state == "" { + c.SetInvalidParam("login_code | code_verifier | state") + return + } + + // Consume one-time code atomically + token, appErr := c.App.ConsumeTokenOnce(loginCode) + if appErr != nil { + c.Err = appErr + return + } + + // Check token expiration as fallback to cleanup process + if token.IsExpired() { + c.Err = model.NewAppError("loginSSOCodeExchange", "api.oauth.get_access_token.bad_request.app_error", nil, "token expired", http.StatusBadRequest) + return + } + + // Parse extra JSON + extra := model.MapFromJSON(strings.NewReader(token.Extra)) + userID := extra["user_id"] + codeChallenge := extra["code_challenge"] + method := strings.ToUpper(extra["code_challenge_method"]) + expectedState := extra["state"] + + if userID == "" || codeChallenge == "" || expectedState == "" { + c.Err = model.NewAppError("loginSSOCodeExchange", "api.oauth.get_access_token.bad_request.app_error", nil, "", http.StatusBadRequest) + return + } + + if state != expectedState { + c.Err = model.NewAppError("loginSSOCodeExchange", "api.oauth.get_access_token.bad_request.app_error", nil, "state mismatch", http.StatusBadRequest) + return + } + + // Verify SAML challenge + var computed string + switch strings.ToUpper(method) { + case "S256": + sum := sha256.Sum256([]byte(codeVerifier)) + computed = base64.RawURLEncoding.EncodeToString(sum[:]) + case "": + computed = codeVerifier + case "PLAIN": + // Explicitly reject plain method for security + c.Err = model.NewAppError("loginSSOCodeExchange", "api.oauth.get_access_token.bad_request.app_error", nil, "plain SAML challenge method not supported", + http.StatusBadRequest) + return + default: + // Reject unknown methods + c.Err = model.NewAppError("loginSSOCodeExchange", "api.oauth.get_access_token.bad_request.app_error", nil, "unsupported SAML challenge method", http.StatusBadRequest) + return + } + + if computed != codeChallenge { + c.Err = model.NewAppError("loginSSOCodeExchange", "api.oauth.get_access_token.bad_request.app_error", nil, "SAML challenge mismatch", http.StatusBadRequest) + return + } + + // Create session for this user + user, err := c.App.GetUser(userID) + if err != nil { + c.Err = err + return + } + + isMobile := utils.IsMobileRequest(r) + session, err2 := c.App.DoLogin(c.AppContext, w, r, user, "", isMobile, false, true) + if err2 != nil { + c.Err = err2 + return + } + c.AppContext = c.AppContext.WithSession(session) + c.App.AttachSessionCookies(c.AppContext, w, r) + + // Respond with tokens for mobile client to set + resp := map[string]string{ + "token": session.Token, + "csrf": session.GetCSRF(), + } + if err := json.NewEncoder(w).Encode(resp); err != nil { + c.Logger.Warn("Error while writing response", mlog.Err(err)) + } +} + func createUser(c *Context, w http.ResponseWriter, r *http.Request) { var user model.User if jsonErr := json.NewDecoder(r.Body).Decode(&user); jsonErr != nil {
server/channels/app/user.go+15 −0 modified@@ -1750,6 +1750,21 @@ func (a *App) GetTokenById(token string) (*model.Token, *model.AppError) { return rtoken, nil } +func (a *App) ConsumeTokenOnce(tokenStr string) (*model.Token, *model.AppError) { + token, err := a.Srv().Store().Token().ConsumeOnce(tokenStr) + if err != nil { + var status int + switch err.(type) { + case *store.ErrNotFound: + status = http.StatusNotFound + default: + status = http.StatusInternalServerError + } + return nil, model.NewAppError("ConsumeTokenOnce", "api.user.create_user.signup_link_invalid.app_error", nil, "", status).Wrap(err) + } + return token, nil +} + func (a *App) DeleteToken(token *model.Token) *model.AppError { err := a.Srv().Store().Token().Delete(token.Token) if err != nil {
server/channels/store/retrylayer/retrylayer.go+21 −0 modified@@ -14138,6 +14138,27 @@ func (s *RetryLayerTokenStore) Cleanup(expiryTime int64) { } +func (s *RetryLayerTokenStore) ConsumeOnce(tokenStr string) (*model.Token, error) { + + tries := 0 + for { + result, err := s.TokenStore.ConsumeOnce(tokenStr) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + func (s *RetryLayerTokenStore) Delete(token string) error { tries := 0
server/channels/store/sqlstore/tokens_store.go+15 −0 modified@@ -78,6 +78,21 @@ func (s SqlTokenStore) GetByToken(tokenString string) (*model.Token, error) { return &token, nil } +func (s SqlTokenStore) ConsumeOnce(tokenStr string) (*model.Token, error) { + var token model.Token + + query := `DELETE FROM Tokens WHERE Token = ? RETURNING *` + + if err := s.GetMaster().Get(&token, query, tokenStr); err != nil { + if err == sql.ErrNoRows { + return nil, store.NewErrNotFound("Token", tokenStr) + } + return nil, errors.Wrapf(err, "failed to consume token") + } + + return &token, nil +} + func (s SqlTokenStore) Cleanup(expiryTime int64) { if _, err := s.GetMaster().Exec("DELETE FROM Tokens WHERE CreateAt < ?", expiryTime); err != nil { mlog.Error("Unable to cleanup token store.")
server/channels/store/store.go+1 −0 modified@@ -693,6 +693,7 @@ type TokenStore interface { Save(recovery *model.Token) error Delete(token string) error GetByToken(token string) (*model.Token, error) + ConsumeOnce(tokenStr string) (*model.Token, error) Cleanup(expiryTime int64) GetAllTokensByType(tokenType string) ([]*model.Token, error) RemoveAllTokensByType(tokenType string) error
server/channels/store/storetest/mocks/TokenStore.go+30 −0 modified@@ -19,6 +19,36 @@ func (_m *TokenStore) Cleanup(expiryTime int64) { _m.Called(expiryTime) } +// ConsumeOnce provides a mock function with given fields: tokenStr +func (_m *TokenStore) ConsumeOnce(tokenStr string) (*model.Token, error) { + ret := _m.Called(tokenStr) + + if len(ret) == 0 { + panic("no return value specified for ConsumeOnce") + } + + var r0 *model.Token + var r1 error + if rf, ok := ret.Get(0).(func(string) (*model.Token, error)); ok { + return rf(tokenStr) + } + if rf, ok := ret.Get(0).(func(string) *model.Token); ok { + r0 = rf(tokenStr) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.Token) + } + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(tokenStr) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // Delete provides a mock function with given fields: token func (_m *TokenStore) Delete(token string) error { ret := _m.Called(token)
server/channels/store/timerlayer/timerlayer.go+16 −0 modified@@ -11106,6 +11106,22 @@ func (s *TimerLayerTokenStore) Cleanup(expiryTime int64) { } } +func (s *TimerLayerTokenStore) ConsumeOnce(tokenStr string) (*model.Token, error) { + start := time.Now() + + result, err := s.TokenStore.ConsumeOnce(tokenStr) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("TokenStore.ConsumeOnce", success, elapsed) + } + return result, err +} + func (s *TimerLayerTokenStore) Delete(token string) error { start := time.Now()
server/channels/web/saml.go+51 −5 modified@@ -37,6 +37,10 @@ func loginWithSaml(c *Context, w http.ResponseWriter, r *http.Request) { action := r.URL.Query().Get("action") isMobile := action == model.OAuthActionMobile redirectURL := html.EscapeString(r.URL.Query().Get("redirect_to")) + // Optional SAML challenge parameters for mobile code-exchange + state := r.URL.Query().Get("state") + codeChallenge := r.URL.Query().Get("code_challenge") + codeChallengeMethod := r.URL.Query().Get("code_challenge_method") relayProps := map[string]string{} relayState := "" @@ -61,6 +65,19 @@ func loginWithSaml(c *Context, w http.ResponseWriter, r *http.Request) { relayProps["redirect_to"] = redirectURL } + // Forward SAML challenge values via RelayState so the complete step can prefer code-exchange + if isMobile { + if state != "" { + relayProps["state"] = state + } + if codeChallenge != "" { + relayProps["code_challenge"] = codeChallenge + } + if codeChallengeMethod != "" { + relayProps["code_challenge_method"] = codeChallengeMethod + } + } + desktopToken := r.URL.Query().Get("desktop_token") if desktopToken != "" { relayProps["desktop_token"] = desktopToken @@ -220,7 +237,33 @@ func completeSaml(c *Context, w http.ResponseWriter, r *http.Request) { return } - // If it's not a desktop login we create a session for this SAML User that will be used in their browser or mobile app + // Decide between legacy token-in-URL vs SAML code-exchange for mobile + samlState := relayProps["state"] + samlChallenge := relayProps["code_challenge"] + samlMethod := relayProps["code_challenge_method"] + + if isMobile && hasRedirectURL && samlChallenge != "" && c.App.Config().FeatureFlags.MobileSSOCodeExchange { + // Issue one-time login_code bound to user and SAML challenge values; do not create a session here + extra := model.MapToJSON(map[string]string{ + "user_id": user.Id, + "state": samlState, + "code_challenge": samlChallenge, + "code_challenge_method": samlMethod, + }) + code := model.NewToken(model.TokenTypeSaml, extra) + if err := c.App.Srv().Store().Token().Save(code); err != nil { + handleError(model.NewAppError("completeSaml", "app.recover.save.app_error", nil, "", http.StatusInternalServerError).Wrap(err)) + return + } + + redirectURL = utils.AppendQueryParamsToURL(redirectURL, map[string]string{ + "login_code": code.Token, + }) + utils.RenderMobileAuthComplete(w, redirectURL) + return + } + + // Legacy: create a session and attach tokens (web/mobile without SAML code exchange) session, err := c.App.DoLogin(c.AppContext, w, r, user, "", isMobile, false, true) if err != nil { handleError(err) @@ -235,10 +278,13 @@ func completeSaml(c *Context, w http.ResponseWriter, r *http.Request) { if hasRedirectURL { if isMobile { // Mobile clients with redirect url support - redirectURL = utils.AppendQueryParamsToURL(redirectURL, map[string]string{ - model.SessionCookieToken: c.AppContext.Session().Token, - model.SessionCookieCsrf: c.AppContext.Session().GetCSRF(), - }) + // Legacy mobile path: return tokens only when SAML code exchange was not requested + if samlChallenge == "" { + redirectURL = utils.AppendQueryParamsToURL(redirectURL, map[string]string{ + model.SessionCookieToken: c.AppContext.Session().Token, + model.SessionCookieCsrf: c.AppContext.Session().GetCSRF(), + }) + } utils.RenderMobileAuthComplete(w, redirectURL) } else { http.Redirect(w, r, redirectURL, http.StatusFound)
server/public/model/feature_flags.go+4 −0 modified@@ -70,6 +70,9 @@ type FeatureFlags struct { AttributeBasedAccessControl bool ContentFlagging bool + + // Enable mobile SSO SAML code-exchange flow (no tokens in deep links) + MobileSSOCodeExchange bool } func (f *FeatureFlags) SetDefaults() { @@ -99,6 +102,7 @@ func (f *FeatureFlags) SetDefaults() { f.CustomProfileAttributes = true f.AttributeBasedAccessControl = true f.ContentFlagging = false + f.MobileSSOCodeExchange = true } // ToMap returns the feature flags as a map[string]string
server/public/model/token.go+4 −0 modified@@ -41,3 +41,7 @@ func (t *Token) IsValid() *AppError { return nil } + +func (t *Token) IsExpired() bool { + return GetMillis() > (t.CreateAt + MaxTokenExipryTime) +}
6c288aa62bb3MM 65084 server-side (#33861) (#34006) (#34043)
11 files changed · +309 −5
api/v4/source/users.yaml+53 −0 modified@@ -74,6 +74,59 @@ $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" + /api/v4/users/login/sso/code-exchange: + post: + tags: + - users + summary: Exchange SSO login code for session tokens + description: > + Exchange a short-lived login_code for session tokens using SAML code exchange (mobile SSO flow). + This endpoint is part of the mobile SSO code-exchange flow to prevent tokens + from appearing in deep links. + + ##### Permissions + + No permission required. + operationId: LoginSSOCodeExchange + requestBody: + content: + application/json: + schema: + type: object + required: + - login_code + - code_verifier + - state + properties: + login_code: + description: Short-lived one-time code from SSO callback + type: string + code_verifier: + description: SAML verifier to prove code possession + type: string + state: + description: State parameter to prevent CSRF attacks + type: string + description: SSO code exchange object + required: true + responses: + "200": + description: Code exchange successful + content: + application/json: + schema: + type: object + properties: + token: + description: Session token for authentication + type: string + csrf: + description: CSRF token for request validation + type: string + "400": + $ref: "#/components/responses/BadRequest" + "403": + $ref: "#/components/responses/Forbidden" /api/v4/users/logout: post: tags:
server/channels/api4/user.go+99 −0 modified@@ -4,6 +4,8 @@ package api4 import ( + "crypto/sha256" + "encoding/base64" "encoding/json" "fmt" "io" @@ -63,6 +65,7 @@ func (api *API) InitUser() { api.BaseRoutes.User.Handle("/mfa/generate", api.APISessionRequiredMfa(generateMfaSecret)).Methods(http.MethodPost) api.BaseRoutes.Users.Handle("/login", api.APIHandler(login)).Methods(http.MethodPost) + api.BaseRoutes.Users.Handle("/login/sso/code-exchange", api.APIHandler(loginSSOCodeExchange)).Methods(http.MethodPost) api.BaseRoutes.Users.Handle("/login/desktop_token", api.RateLimitedHandler(api.APIHandler(loginWithDesktopToken), model.RateLimitSettings{PerSec: model.NewPointer(2), MaxBurst: model.NewPointer(1)})).Methods(http.MethodPost) api.BaseRoutes.Users.Handle("/login/switch", api.APIHandler(switchAccountType)).Methods(http.MethodPost) api.BaseRoutes.Users.Handle("/login/cws", api.APIHandlerTrustRequester(loginCWS)).Methods(http.MethodPost) @@ -110,6 +113,102 @@ func (api *API) InitUser() { api.BaseRoutes.Users.Handle("/trigger-notify-admin-posts", api.APISessionRequired(handleTriggerNotifyAdminPosts)).Methods(http.MethodPost) } +// loginSSOCodeExchange exchanges a short-lived login_code for session tokens (mobile SAML code exchange) +func loginSSOCodeExchange(c *Context, w http.ResponseWriter, r *http.Request) { + if !c.App.Config().FeatureFlags.MobileSSOCodeExchange { + c.Err = model.NewAppError("loginSSOCodeExchange", "api.oauth.get_access_token.bad_request.app_error", nil, "feature disabled", http.StatusBadRequest) + return + } + props := model.MapFromJSON(r.Body) + loginCode := props["login_code"] + codeVerifier := props["code_verifier"] + state := props["state"] + + if loginCode == "" || codeVerifier == "" || state == "" { + c.SetInvalidParam("login_code | code_verifier | state") + return + } + + // Consume one-time code atomically + token, appErr := c.App.ConsumeTokenOnce(loginCode) + if appErr != nil { + c.Err = appErr + return + } + + // Check token expiration as fallback to cleanup process + if token.IsExpired() { + c.Err = model.NewAppError("loginSSOCodeExchange", "api.oauth.get_access_token.bad_request.app_error", nil, "token expired", http.StatusBadRequest) + return + } + + // Parse extra JSON + extra := model.MapFromJSON(strings.NewReader(token.Extra)) + userID := extra["user_id"] + codeChallenge := extra["code_challenge"] + method := strings.ToUpper(extra["code_challenge_method"]) + expectedState := extra["state"] + + if userID == "" || codeChallenge == "" || expectedState == "" { + c.Err = model.NewAppError("loginSSOCodeExchange", "api.oauth.get_access_token.bad_request.app_error", nil, "", http.StatusBadRequest) + return + } + + if state != expectedState { + c.Err = model.NewAppError("loginSSOCodeExchange", "api.oauth.get_access_token.bad_request.app_error", nil, "state mismatch", http.StatusBadRequest) + return + } + + // Verify SAML challenge + var computed string + switch strings.ToUpper(method) { + case "S256": + sum := sha256.Sum256([]byte(codeVerifier)) + computed = base64.RawURLEncoding.EncodeToString(sum[:]) + case "": + computed = codeVerifier + case "PLAIN": + // Explicitly reject plain method for security + c.Err = model.NewAppError("loginSSOCodeExchange", "api.oauth.get_access_token.bad_request.app_error", nil, "plain SAML challenge method not supported", + http.StatusBadRequest) + return + default: + // Reject unknown methods + c.Err = model.NewAppError("loginSSOCodeExchange", "api.oauth.get_access_token.bad_request.app_error", nil, "unsupported SAML challenge method", http.StatusBadRequest) + return + } + + if computed != codeChallenge { + c.Err = model.NewAppError("loginSSOCodeExchange", "api.oauth.get_access_token.bad_request.app_error", nil, "SAML challenge mismatch", http.StatusBadRequest) + return + } + + // Create session for this user + user, err := c.App.GetUser(userID) + if err != nil { + c.Err = err + return + } + + isMobile := utils.IsMobileRequest(r) + session, err2 := c.App.DoLogin(c.AppContext, w, r, user, "", isMobile, false, true) + if err2 != nil { + c.Err = err2 + return + } + c.AppContext = c.AppContext.WithSession(session) + c.App.AttachSessionCookies(c.AppContext, w, r) + + // Respond with tokens for mobile client to set + resp := map[string]string{ + "token": session.Token, + "csrf": session.GetCSRF(), + } + if err := json.NewEncoder(w).Encode(resp); err != nil { + c.Logger.Warn("Error while writing response", mlog.Err(err)) + } +} + func createUser(c *Context, w http.ResponseWriter, r *http.Request) { var user model.User if jsonErr := json.NewDecoder(r.Body).Decode(&user); jsonErr != nil {
server/channels/app/user.go+15 −0 modified@@ -1750,6 +1750,21 @@ func (a *App) GetTokenById(token string) (*model.Token, *model.AppError) { return rtoken, nil } +func (a *App) ConsumeTokenOnce(tokenStr string) (*model.Token, *model.AppError) { + token, err := a.Srv().Store().Token().ConsumeOnce(tokenStr) + if err != nil { + var status int + switch err.(type) { + case *store.ErrNotFound: + status = http.StatusNotFound + default: + status = http.StatusInternalServerError + } + return nil, model.NewAppError("ConsumeTokenOnce", "api.user.create_user.signup_link_invalid.app_error", nil, "", status).Wrap(err) + } + return token, nil +} + func (a *App) DeleteToken(token *model.Token) *model.AppError { err := a.Srv().Store().Token().Delete(token.Token) if err != nil {
server/channels/store/retrylayer/retrylayer.go+21 −0 modified@@ -14138,6 +14138,27 @@ func (s *RetryLayerTokenStore) Cleanup(expiryTime int64) { } +func (s *RetryLayerTokenStore) ConsumeOnce(tokenStr string) (*model.Token, error) { + + tries := 0 + for { + result, err := s.TokenStore.ConsumeOnce(tokenStr) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + func (s *RetryLayerTokenStore) Delete(token string) error { tries := 0
server/channels/store/sqlstore/tokens_store.go+15 −0 modified@@ -78,6 +78,21 @@ func (s SqlTokenStore) GetByToken(tokenString string) (*model.Token, error) { return &token, nil } +func (s SqlTokenStore) ConsumeOnce(tokenStr string) (*model.Token, error) { + var token model.Token + + query := `DELETE FROM Tokens WHERE Token = ? RETURNING *` + + if err := s.GetMaster().Get(&token, query, tokenStr); err != nil { + if err == sql.ErrNoRows { + return nil, store.NewErrNotFound("Token", tokenStr) + } + return nil, errors.Wrapf(err, "failed to consume token") + } + + return &token, nil +} + func (s SqlTokenStore) Cleanup(expiryTime int64) { if _, err := s.GetMaster().Exec("DELETE FROM Tokens WHERE CreateAt < ?", expiryTime); err != nil { mlog.Error("Unable to cleanup token store.")
server/channels/store/store.go+1 −0 modified@@ -693,6 +693,7 @@ type TokenStore interface { Save(recovery *model.Token) error Delete(token string) error GetByToken(token string) (*model.Token, error) + ConsumeOnce(tokenStr string) (*model.Token, error) Cleanup(expiryTime int64) GetAllTokensByType(tokenType string) ([]*model.Token, error) RemoveAllTokensByType(tokenType string) error
server/channels/store/storetest/mocks/TokenStore.go+30 −0 modified@@ -19,6 +19,36 @@ func (_m *TokenStore) Cleanup(expiryTime int64) { _m.Called(expiryTime) } +// ConsumeOnce provides a mock function with given fields: tokenStr +func (_m *TokenStore) ConsumeOnce(tokenStr string) (*model.Token, error) { + ret := _m.Called(tokenStr) + + if len(ret) == 0 { + panic("no return value specified for ConsumeOnce") + } + + var r0 *model.Token + var r1 error + if rf, ok := ret.Get(0).(func(string) (*model.Token, error)); ok { + return rf(tokenStr) + } + if rf, ok := ret.Get(0).(func(string) *model.Token); ok { + r0 = rf(tokenStr) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.Token) + } + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(tokenStr) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // Delete provides a mock function with given fields: token func (_m *TokenStore) Delete(token string) error { ret := _m.Called(token)
server/channels/store/timerlayer/timerlayer.go+16 −0 modified@@ -11106,6 +11106,22 @@ func (s *TimerLayerTokenStore) Cleanup(expiryTime int64) { } } +func (s *TimerLayerTokenStore) ConsumeOnce(tokenStr string) (*model.Token, error) { + start := time.Now() + + result, err := s.TokenStore.ConsumeOnce(tokenStr) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("TokenStore.ConsumeOnce", success, elapsed) + } + return result, err +} + func (s *TimerLayerTokenStore) Delete(token string) error { start := time.Now()
server/channels/web/saml.go+51 −5 modified@@ -37,6 +37,10 @@ func loginWithSaml(c *Context, w http.ResponseWriter, r *http.Request) { action := r.URL.Query().Get("action") isMobile := action == model.OAuthActionMobile redirectURL := html.EscapeString(r.URL.Query().Get("redirect_to")) + // Optional SAML challenge parameters for mobile code-exchange + state := r.URL.Query().Get("state") + codeChallenge := r.URL.Query().Get("code_challenge") + codeChallengeMethod := r.URL.Query().Get("code_challenge_method") relayProps := map[string]string{} relayState := "" @@ -61,6 +65,19 @@ func loginWithSaml(c *Context, w http.ResponseWriter, r *http.Request) { relayProps["redirect_to"] = redirectURL } + // Forward SAML challenge values via RelayState so the complete step can prefer code-exchange + if isMobile { + if state != "" { + relayProps["state"] = state + } + if codeChallenge != "" { + relayProps["code_challenge"] = codeChallenge + } + if codeChallengeMethod != "" { + relayProps["code_challenge_method"] = codeChallengeMethod + } + } + desktopToken := r.URL.Query().Get("desktop_token") if desktopToken != "" { relayProps["desktop_token"] = desktopToken @@ -220,7 +237,33 @@ func completeSaml(c *Context, w http.ResponseWriter, r *http.Request) { return } - // If it's not a desktop login we create a session for this SAML User that will be used in their browser or mobile app + // Decide between legacy token-in-URL vs SAML code-exchange for mobile + samlState := relayProps["state"] + samlChallenge := relayProps["code_challenge"] + samlMethod := relayProps["code_challenge_method"] + + if isMobile && hasRedirectURL && samlChallenge != "" && c.App.Config().FeatureFlags.MobileSSOCodeExchange { + // Issue one-time login_code bound to user and SAML challenge values; do not create a session here + extra := model.MapToJSON(map[string]string{ + "user_id": user.Id, + "state": samlState, + "code_challenge": samlChallenge, + "code_challenge_method": samlMethod, + }) + code := model.NewToken(model.TokenTypeSaml, extra) + if err := c.App.Srv().Store().Token().Save(code); err != nil { + handleError(model.NewAppError("completeSaml", "app.recover.save.app_error", nil, "", http.StatusInternalServerError).Wrap(err)) + return + } + + redirectURL = utils.AppendQueryParamsToURL(redirectURL, map[string]string{ + "login_code": code.Token, + }) + utils.RenderMobileAuthComplete(w, redirectURL) + return + } + + // Legacy: create a session and attach tokens (web/mobile without SAML code exchange) session, err := c.App.DoLogin(c.AppContext, w, r, user, "", isMobile, false, true) if err != nil { handleError(err) @@ -235,10 +278,13 @@ func completeSaml(c *Context, w http.ResponseWriter, r *http.Request) { if hasRedirectURL { if isMobile { // Mobile clients with redirect url support - redirectURL = utils.AppendQueryParamsToURL(redirectURL, map[string]string{ - model.SessionCookieToken: c.AppContext.Session().Token, - model.SessionCookieCsrf: c.AppContext.Session().GetCSRF(), - }) + // Legacy mobile path: return tokens only when SAML code exchange was not requested + if samlChallenge == "" { + redirectURL = utils.AppendQueryParamsToURL(redirectURL, map[string]string{ + model.SessionCookieToken: c.AppContext.Session().Token, + model.SessionCookieCsrf: c.AppContext.Session().GetCSRF(), + }) + } utils.RenderMobileAuthComplete(w, redirectURL) } else { http.Redirect(w, r, redirectURL, http.StatusFound)
server/public/model/feature_flags.go+4 −0 modified@@ -70,6 +70,9 @@ type FeatureFlags struct { AttributeBasedAccessControl bool ContentFlagging bool + + // Enable mobile SSO SAML code-exchange flow (no tokens in deep links) + MobileSSOCodeExchange bool } func (f *FeatureFlags) SetDefaults() { @@ -99,6 +102,7 @@ func (f *FeatureFlags) SetDefaults() { f.CustomProfileAttributes = true f.AttributeBasedAccessControl = true f.ContentFlagging = false + f.MobileSSOCodeExchange = true } // ToMap returns the feature flags as a map[string]string
server/public/model/token.go+4 −0 modified@@ -41,3 +41,7 @@ func (t *Token) IsValid() *AppError { return nil } + +func (t *Token) IsExpired() bool { + return GetMillis() > (t.CreateAt + MaxTokenExipryTime) +}
e47349ea0fc0MM 65084 server-side (#33861) (#34006) (#34045)
15 files changed · +373 −5
api/v4/source/users.yaml+53 −0 modified@@ -74,6 +74,59 @@ $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" + /api/v4/users/login/sso/code-exchange: + post: + tags: + - users + summary: Exchange SSO login code for session tokens + description: > + Exchange a short-lived login_code for session tokens using SAML code exchange (mobile SSO flow). + This endpoint is part of the mobile SSO code-exchange flow to prevent tokens + from appearing in deep links. + + ##### Permissions + + No permission required. + operationId: LoginSSOCodeExchange + requestBody: + content: + application/json: + schema: + type: object + required: + - login_code + - code_verifier + - state + properties: + login_code: + description: Short-lived one-time code from SSO callback + type: string + code_verifier: + description: SAML verifier to prove code possession + type: string + state: + description: State parameter to prevent CSRF attacks + type: string + description: SSO code exchange object + required: true + responses: + "200": + description: Code exchange successful + content: + application/json: + schema: + type: object + properties: + token: + description: Session token for authentication + type: string + csrf: + description: CSRF token for request validation + type: string + "400": + $ref: "#/components/responses/BadRequest" + "403": + $ref: "#/components/responses/Forbidden" /api/v4/users/logout: post: tags:
server/channels/api4/user.go+99 −0 modified@@ -4,6 +4,8 @@ package api4 import ( + "crypto/sha256" + "encoding/base64" "encoding/json" "fmt" "io" @@ -64,6 +66,7 @@ func (api *API) InitUser() { api.BaseRoutes.User.Handle("/mfa/generate", api.APISessionRequiredMfa(generateMfaSecret)).Methods(http.MethodPost) api.BaseRoutes.Users.Handle("/login", api.APIHandler(login)).Methods(http.MethodPost) + api.BaseRoutes.Users.Handle("/login/sso/code-exchange", api.APIHandler(loginSSOCodeExchange)).Methods(http.MethodPost) api.BaseRoutes.Users.Handle("/login/desktop_token", api.RateLimitedHandler(api.APIHandler(loginWithDesktopToken), model.RateLimitSettings{PerSec: model.NewPointer(2), MaxBurst: model.NewPointer(1)})).Methods(http.MethodPost) api.BaseRoutes.Users.Handle("/login/switch", api.APIHandler(switchAccountType)).Methods(http.MethodPost) api.BaseRoutes.Users.Handle("/login/cws", api.APIHandlerTrustRequester(loginCWS)).Methods(http.MethodPost) @@ -111,6 +114,102 @@ func (api *API) InitUser() { api.BaseRoutes.Users.Handle("/trigger-notify-admin-posts", api.APISessionRequired(handleTriggerNotifyAdminPosts)).Methods(http.MethodPost) } +// loginSSOCodeExchange exchanges a short-lived login_code for session tokens (mobile SAML code exchange) +func loginSSOCodeExchange(c *Context, w http.ResponseWriter, r *http.Request) { + if !c.App.Config().FeatureFlags.MobileSSOCodeExchange { + c.Err = model.NewAppError("loginSSOCodeExchange", "api.oauth.get_access_token.bad_request.app_error", nil, "feature disabled", http.StatusBadRequest) + return + } + props := model.MapFromJSON(r.Body) + loginCode := props["login_code"] + codeVerifier := props["code_verifier"] + state := props["state"] + + if loginCode == "" || codeVerifier == "" || state == "" { + c.SetInvalidParam("login_code | code_verifier | state") + return + } + + // Consume one-time code atomically + token, appErr := c.App.ConsumeTokenOnce(loginCode) + if appErr != nil { + c.Err = appErr + return + } + + // Check token expiration as fallback to cleanup process + if token.IsExpired() { + c.Err = model.NewAppError("loginSSOCodeExchange", "api.oauth.get_access_token.bad_request.app_error", nil, "token expired", http.StatusBadRequest) + return + } + + // Parse extra JSON + extra := model.MapFromJSON(strings.NewReader(token.Extra)) + userID := extra["user_id"] + codeChallenge := extra["code_challenge"] + method := strings.ToUpper(extra["code_challenge_method"]) + expectedState := extra["state"] + + if userID == "" || codeChallenge == "" || expectedState == "" { + c.Err = model.NewAppError("loginSSOCodeExchange", "api.oauth.get_access_token.bad_request.app_error", nil, "", http.StatusBadRequest) + return + } + + if state != expectedState { + c.Err = model.NewAppError("loginSSOCodeExchange", "api.oauth.get_access_token.bad_request.app_error", nil, "state mismatch", http.StatusBadRequest) + return + } + + // Verify SAML challenge + var computed string + switch strings.ToUpper(method) { + case "S256": + sum := sha256.Sum256([]byte(codeVerifier)) + computed = base64.RawURLEncoding.EncodeToString(sum[:]) + case "": + computed = codeVerifier + case "PLAIN": + // Explicitly reject plain method for security + c.Err = model.NewAppError("loginSSOCodeExchange", "api.oauth.get_access_token.bad_request.app_error", nil, "plain SAML challenge method not supported", + http.StatusBadRequest) + return + default: + // Reject unknown methods + c.Err = model.NewAppError("loginSSOCodeExchange", "api.oauth.get_access_token.bad_request.app_error", nil, "unsupported SAML challenge method", http.StatusBadRequest) + return + } + + if computed != codeChallenge { + c.Err = model.NewAppError("loginSSOCodeExchange", "api.oauth.get_access_token.bad_request.app_error", nil, "SAML challenge mismatch", http.StatusBadRequest) + return + } + + // Create session for this user + user, err := c.App.GetUser(userID) + if err != nil { + c.Err = err + return + } + + isMobile := utils.IsMobileRequest(r) + session, err2 := c.App.DoLogin(c.AppContext, w, r, user, "", isMobile, false, true) + if err2 != nil { + c.Err = err2 + return + } + c.AppContext = c.AppContext.WithSession(session) + c.App.AttachSessionCookies(c.AppContext, w, r) + + // Respond with tokens for mobile client to set + resp := map[string]string{ + "token": session.Token, + "csrf": session.GetCSRF(), + } + if err := json.NewEncoder(w).Encode(resp); err != nil { + c.Logger.Warn("Error while writing response", mlog.Err(err)) + } +} + func createUser(c *Context, w http.ResponseWriter, r *http.Request) { var user model.User if jsonErr := json.NewDecoder(r.Body).Decode(&user); jsonErr != nil {
server/channels/app/app_iface.go+1 −0 modified@@ -521,6 +521,7 @@ type AppIface interface { CompleteSwitchWithOAuth(c request.CTX, service string, userData io.Reader, email string, tokenUser *model.User) (*model.User, *model.AppError) Compliance() einterfaces.ComplianceInterface Config() *model.Config + ConsumeTokenOnce(tokenStr string) (*model.Token, *model.AppError) ConvertGroupMessageToChannel(c request.CTX, convertedByUserId string, gmConversionRequest *model.GroupMessageConversionRequestBody) (*model.Channel, *model.AppError) CopyFileInfos(rctx request.CTX, userID string, fileIDs []string) ([]string, *model.AppError) CopyWranglerPostlist(c request.CTX, wpl *model.WranglerPostList, targetChannel *model.Channel) (*model.Post, *model.AppError)
server/channels/app/layer_generators/opentracing_layer.go.tmpl+23 −0 modified@@ -7,6 +7,29 @@ package opentracing import ( + "context" + "io" + "net/http" + "net/url" + "reflect" + "time" + + "github.com/mattermost/mattermost/server/public/shared/i18n" + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/plugin" + "github.com/mattermost/mattermost/server/public/shared/httpservice" + "github.com/mattermost/mattermost/server/public/shared/mlog" + "github.com/mattermost/mattermost/server/public/shared/request" + "github.com/mattermost/mattermost/server/public/shared/timezones" + "github.com/mattermost/mattermost/server/v8/channels/app" + "github.com/mattermost/mattermost/server/v8/channels/app/platform" + "github.com/mattermost/mattermost/server/v8/channels/audit" + "github.com/mattermost/mattermost/server/v8/channels/store" + "github.com/mattermost/mattermost/server/v8/einterfaces" + "github.com/mattermost/mattermost/server/v8/platform/services/imageproxy" + "github.com/mattermost/mattermost/server/v8/platform/services/remotecluster" + "github.com/mattermost/mattermost/server/v8/platform/services/searchengine" + "github.com/mattermost/mattermost/server/v8/platform/shared/filestore" "github.com/opentracing/opentracing-go/ext" spanlog "github.com/opentracing/opentracing-go/log"
server/channels/app/opentracing/opentracing_layer.go+22 −0 modified@@ -1886,6 +1886,28 @@ func (a *OpenTracingAppLayer) Config() *model.Config { return resultVar0 } +func (a *OpenTracingAppLayer) ConsumeTokenOnce(tokenStr string) (*model.Token, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.ConsumeTokenOnce") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0, resultVar1 := a.app.ConsumeTokenOnce(tokenStr) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (a *OpenTracingAppLayer) ConvertBotToUser(c request.CTX, bot *model.Bot, userPatch *model.UserPatch, sysadmin bool) (*model.User, *model.AppError) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.ConvertBotToUser")
server/channels/app/user.go+15 −0 modified@@ -1710,6 +1710,21 @@ func (a *App) GetTokenById(token string) (*model.Token, *model.AppError) { return rtoken, nil } +func (a *App) ConsumeTokenOnce(tokenStr string) (*model.Token, *model.AppError) { + token, err := a.Srv().Store().Token().ConsumeOnce(tokenStr) + if err != nil { + var status int + switch err.(type) { + case *store.ErrNotFound: + status = http.StatusNotFound + default: + status = http.StatusInternalServerError + } + return nil, model.NewAppError("ConsumeTokenOnce", "api.user.create_user.signup_link_invalid.app_error", nil, "", status).Wrap(err) + } + return token, nil +} + func (a *App) DeleteToken(token *model.Token) *model.AppError { err := a.Srv().Store().Token().Delete(token.Token) if err != nil {
server/channels/store/opentracinglayer/opentracinglayer.go+18 −0 modified@@ -11772,6 +11772,24 @@ func (s *OpenTracingLayerTokenStore) Cleanup(expiryTime int64) { } +func (s *OpenTracingLayerTokenStore) ConsumeOnce(tokenStr string) (*model.Token, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "TokenStore.ConsumeOnce") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.TokenStore.ConsumeOnce(tokenStr) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + func (s *OpenTracingLayerTokenStore) Delete(token string) error { origCtx := s.Root.Store.Context() span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "TokenStore.Delete")
server/channels/store/retrylayer/retrylayer.go+21 −0 modified@@ -13467,6 +13467,27 @@ func (s *RetryLayerTokenStore) Cleanup(expiryTime int64) { } +func (s *RetryLayerTokenStore) ConsumeOnce(tokenStr string) (*model.Token, error) { + + tries := 0 + for { + result, err := s.TokenStore.ConsumeOnce(tokenStr) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + func (s *RetryLayerTokenStore) Delete(token string) error { tries := 0
server/channels/store/sqlstore/tokens_store.go+15 −0 modified@@ -62,6 +62,21 @@ func (s SqlTokenStore) GetByToken(tokenString string) (*model.Token, error) { return &token, nil } +func (s SqlTokenStore) ConsumeOnce(tokenStr string) (*model.Token, error) { + var token model.Token + + query := `DELETE FROM Tokens WHERE Token = ? RETURNING *` + + if err := s.GetMaster().Get(&token, query, tokenStr); err != nil { + if err == sql.ErrNoRows { + return nil, store.NewErrNotFound("Token", tokenStr) + } + return nil, errors.Wrapf(err, "failed to consume token") + } + + return &token, nil +} + func (s SqlTokenStore) Cleanup(expiryTime int64) { if _, err := s.GetMaster().Exec("DELETE FROM Tokens WHERE CreateAt < ?", expiryTime); err != nil { mlog.Error("Unable to cleanup token store.")
server/channels/store/store.go+1 −0 modified@@ -684,6 +684,7 @@ type TokenStore interface { Save(recovery *model.Token) error Delete(token string) error GetByToken(token string) (*model.Token, error) + ConsumeOnce(tokenStr string) (*model.Token, error) Cleanup(expiryTime int64) GetAllTokensByType(tokenType string) ([]*model.Token, error) RemoveAllTokensByType(tokenType string) error
server/channels/store/storetest/mocks/TokenStore.go+30 −0 modified@@ -19,6 +19,36 @@ func (_m *TokenStore) Cleanup(expiryTime int64) { _m.Called(expiryTime) } +// ConsumeOnce provides a mock function with given fields: tokenStr +func (_m *TokenStore) ConsumeOnce(tokenStr string) (*model.Token, error) { + ret := _m.Called(tokenStr) + + if len(ret) == 0 { + panic("no return value specified for ConsumeOnce") + } + + var r0 *model.Token + var r1 error + if rf, ok := ret.Get(0).(func(string) (*model.Token, error)); ok { + return rf(tokenStr) + } + if rf, ok := ret.Get(0).(func(string) *model.Token); ok { + r0 = rf(tokenStr) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.Token) + } + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(tokenStr) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // Delete provides a mock function with given fields: token func (_m *TokenStore) Delete(token string) error { ret := _m.Called(token)
server/channels/store/timerlayer/timerlayer.go+16 −0 modified@@ -10590,6 +10590,22 @@ func (s *TimerLayerTokenStore) Cleanup(expiryTime int64) { } } +func (s *TimerLayerTokenStore) ConsumeOnce(tokenStr string) (*model.Token, error) { + start := time.Now() + + result, err := s.TokenStore.ConsumeOnce(tokenStr) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("TokenStore.ConsumeOnce", success, elapsed) + } + return result, err +} + func (s *TimerLayerTokenStore) Delete(token string) error { start := time.Now()
server/channels/web/saml.go+51 −5 modified@@ -37,6 +37,10 @@ func loginWithSaml(c *Context, w http.ResponseWriter, r *http.Request) { action := r.URL.Query().Get("action") isMobile := action == model.OAuthActionMobile redirectURL := html.EscapeString(r.URL.Query().Get("redirect_to")) + // Optional SAML challenge parameters for mobile code-exchange + state := r.URL.Query().Get("state") + codeChallenge := r.URL.Query().Get("code_challenge") + codeChallengeMethod := r.URL.Query().Get("code_challenge_method") relayProps := map[string]string{} relayState := "" @@ -61,6 +65,19 @@ func loginWithSaml(c *Context, w http.ResponseWriter, r *http.Request) { relayProps["redirect_to"] = redirectURL } + // Forward SAML challenge values via RelayState so the complete step can prefer code-exchange + if isMobile { + if state != "" { + relayProps["state"] = state + } + if codeChallenge != "" { + relayProps["code_challenge"] = codeChallenge + } + if codeChallengeMethod != "" { + relayProps["code_challenge_method"] = codeChallengeMethod + } + } + desktopToken := r.URL.Query().Get("desktop_token") if desktopToken != "" { relayProps["desktop_token"] = desktopToken @@ -202,7 +219,33 @@ func completeSaml(c *Context, w http.ResponseWriter, r *http.Request) { return } - // If it's not a desktop login we create a session for this SAML User that will be used in their browser or mobile app + // Decide between legacy token-in-URL vs SAML code-exchange for mobile + samlState := relayProps["state"] + samlChallenge := relayProps["code_challenge"] + samlMethod := relayProps["code_challenge_method"] + + if isMobile && hasRedirectURL && samlChallenge != "" && c.App.Config().FeatureFlags.MobileSSOCodeExchange { + // Issue one-time login_code bound to user and SAML challenge values; do not create a session here + extra := model.MapToJSON(map[string]string{ + "user_id": user.Id, + "state": samlState, + "code_challenge": samlChallenge, + "code_challenge_method": samlMethod, + }) + code := model.NewToken(model.TokenTypeSaml, extra) + if err := c.App.Srv().Store().Token().Save(code); err != nil { + handleError(model.NewAppError("completeSaml", "app.recover.save.app_error", nil, "", http.StatusInternalServerError).Wrap(err)) + return + } + + redirectURL = utils.AppendQueryParamsToURL(redirectURL, map[string]string{ + "login_code": code.Token, + }) + utils.RenderMobileAuthComplete(w, redirectURL) + return + } + + // Legacy: create a session and attach tokens (web/mobile without SAML code exchange) session, err := c.App.DoLogin(c.AppContext, w, r, user, "", isMobile, false, true) if err != nil { handleError(err) @@ -217,10 +260,13 @@ func completeSaml(c *Context, w http.ResponseWriter, r *http.Request) { if hasRedirectURL { if isMobile { // Mobile clients with redirect url support - redirectURL = utils.AppendQueryParamsToURL(redirectURL, map[string]string{ - model.SessionCookieToken: c.AppContext.Session().Token, - model.SessionCookieCsrf: c.AppContext.Session().GetCSRF(), - }) + // Legacy mobile path: return tokens only when SAML code exchange was not requested + if samlChallenge == "" { + redirectURL = utils.AppendQueryParamsToURL(redirectURL, map[string]string{ + model.SessionCookieToken: c.AppContext.Session().Token, + model.SessionCookieCsrf: c.AppContext.Session().GetCSRF(), + }) + } utils.RenderMobileAuthComplete(w, redirectURL) } else { http.Redirect(w, r, redirectURL, http.StatusFound)
server/public/model/feature_flags.go+4 −0 modified@@ -59,6 +59,9 @@ type FeatureFlags struct { ExperimentalCrossTeamSearch bool CustomProfileAttributes bool + + // Enable mobile SSO SAML code-exchange flow (no tokens in deep links) + MobileSSOCodeExchange bool } func (f *FeatureFlags) SetDefaults() { @@ -84,6 +87,7 @@ func (f *FeatureFlags) SetDefaults() { f.ExperimentalAuditSettingsSystemConsoleUI = false f.ExperimentalCrossTeamSearch = false f.CustomProfileAttributes = false + f.MobileSSOCodeExchange = true } // ToMap returns the feature flags as a map[string]string
server/public/model/token.go+4 −0 modified@@ -41,3 +41,7 @@ func (t *Token) IsValid() *AppError { return nil } + +func (t *Token) IsExpired() bool { + return GetMillis() > (t.CreateAt + MaxTokenExipryTime) +}
a41db04d2746MM 65084 server-side (#33861)
9 files changed · +273 −5
api/v4/source/users.yaml+53 −0 modified@@ -74,6 +74,59 @@ $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" + /api/v4/users/login/sso/code-exchange: + post: + tags: + - users + summary: Exchange SSO login code for session tokens + description: > + Exchange a short-lived login_code for session tokens using SAML code exchange (mobile SSO flow). + This endpoint is part of the mobile SSO code-exchange flow to prevent tokens + from appearing in deep links. + + ##### Permissions + + No permission required. + operationId: LoginSSOCodeExchange + requestBody: + content: + application/json: + schema: + type: object + required: + - login_code + - code_verifier + - state + properties: + login_code: + description: Short-lived one-time code from SSO callback + type: string + code_verifier: + description: SAML verifier to prove code possession + type: string + state: + description: State parameter to prevent CSRF attacks + type: string + description: SSO code exchange object + required: true + responses: + "200": + description: Code exchange successful + content: + application/json: + schema: + type: object + properties: + token: + description: Session token for authentication + type: string + csrf: + description: CSRF token for request validation + type: string + "400": + $ref: "#/components/responses/BadRequest" + "403": + $ref: "#/components/responses/Forbidden" /api/v4/users/logout: post: tags:
server/channels/api4/user.go+99 −0 modified@@ -4,6 +4,8 @@ package api4 import ( + "crypto/sha256" + "encoding/base64" "encoding/json" "fmt" "io" @@ -63,6 +65,7 @@ func (api *API) InitUser() { api.BaseRoutes.User.Handle("/mfa/generate", api.APISessionRequiredMfa(generateMfaSecret)).Methods(http.MethodPost) api.BaseRoutes.Users.Handle("/login", api.APIHandler(login)).Methods(http.MethodPost) + api.BaseRoutes.Users.Handle("/login/sso/code-exchange", api.APIHandler(loginSSOCodeExchange)).Methods(http.MethodPost) api.BaseRoutes.Users.Handle("/login/desktop_token", api.RateLimitedHandler(api.APIHandler(loginWithDesktopToken), model.RateLimitSettings{PerSec: model.NewPointer(2), MaxBurst: model.NewPointer(1)})).Methods(http.MethodPost) api.BaseRoutes.Users.Handle("/login/switch", api.APIHandler(switchAccountType)).Methods(http.MethodPost) api.BaseRoutes.Users.Handle("/login/cws", api.APIHandlerTrustRequester(loginCWS)).Methods(http.MethodPost) @@ -110,6 +113,102 @@ func (api *API) InitUser() { api.BaseRoutes.Users.Handle("/trigger-notify-admin-posts", api.APISessionRequired(handleTriggerNotifyAdminPosts)).Methods(http.MethodPost) } +// loginSSOCodeExchange exchanges a short-lived login_code for session tokens (mobile SAML code exchange) +func loginSSOCodeExchange(c *Context, w http.ResponseWriter, r *http.Request) { + if !c.App.Config().FeatureFlags.MobileSSOCodeExchange { + c.Err = model.NewAppError("loginSSOCodeExchange", "api.oauth.get_access_token.bad_request.app_error", nil, "feature disabled", http.StatusBadRequest) + return + } + props := model.MapFromJSON(r.Body) + loginCode := props["login_code"] + codeVerifier := props["code_verifier"] + state := props["state"] + + if loginCode == "" || codeVerifier == "" || state == "" { + c.SetInvalidParam("login_code | code_verifier | state") + return + } + + // Consume one-time code atomically + token, appErr := c.App.ConsumeTokenOnce(loginCode) + if appErr != nil { + c.Err = appErr + return + } + + // Check token expiration as fallback to cleanup process + if token.IsExpired() { + c.Err = model.NewAppError("loginSSOCodeExchange", "api.oauth.get_access_token.bad_request.app_error", nil, "token expired", http.StatusBadRequest) + return + } + + // Parse extra JSON + extra := model.MapFromJSON(strings.NewReader(token.Extra)) + userID := extra["user_id"] + codeChallenge := extra["code_challenge"] + method := strings.ToUpper(extra["code_challenge_method"]) + expectedState := extra["state"] + + if userID == "" || codeChallenge == "" || expectedState == "" { + c.Err = model.NewAppError("loginSSOCodeExchange", "api.oauth.get_access_token.bad_request.app_error", nil, "", http.StatusBadRequest) + return + } + + if state != expectedState { + c.Err = model.NewAppError("loginSSOCodeExchange", "api.oauth.get_access_token.bad_request.app_error", nil, "state mismatch", http.StatusBadRequest) + return + } + + // Verify SAML challenge + var computed string + switch strings.ToUpper(method) { + case "S256": + sum := sha256.Sum256([]byte(codeVerifier)) + computed = base64.RawURLEncoding.EncodeToString(sum[:]) + case "": + computed = codeVerifier + case "PLAIN": + // Explicitly reject plain method for security + c.Err = model.NewAppError("loginSSOCodeExchange", "api.oauth.get_access_token.bad_request.app_error", nil, "plain SAML challenge method not supported", + http.StatusBadRequest) + return + default: + // Reject unknown methods + c.Err = model.NewAppError("loginSSOCodeExchange", "api.oauth.get_access_token.bad_request.app_error", nil, "unsupported SAML challenge method", http.StatusBadRequest) + return + } + + if computed != codeChallenge { + c.Err = model.NewAppError("loginSSOCodeExchange", "api.oauth.get_access_token.bad_request.app_error", nil, "SAML challenge mismatch", http.StatusBadRequest) + return + } + + // Create session for this user + user, err := c.App.GetUser(userID) + if err != nil { + c.Err = err + return + } + + isMobile := utils.IsMobileRequest(r) + session, err2 := c.App.DoLogin(c.AppContext, w, r, user, "", isMobile, false, true) + if err2 != nil { + c.Err = err2 + return + } + c.AppContext = c.AppContext.WithSession(session) + c.App.AttachSessionCookies(c.AppContext, w, r) + + // Respond with tokens for mobile client to set + resp := map[string]string{ + "token": session.Token, + "csrf": session.GetCSRF(), + } + if err := json.NewEncoder(w).Encode(resp); err != nil { + c.Logger.Warn("Error while writing response", mlog.Err(err)) + } +} + func createUser(c *Context, w http.ResponseWriter, r *http.Request) { var user model.User if jsonErr := json.NewDecoder(r.Body).Decode(&user); jsonErr != nil {
server/channels/app/user.go+15 −0 modified@@ -1750,6 +1750,21 @@ func (a *App) GetTokenById(token string) (*model.Token, *model.AppError) { return rtoken, nil } +func (a *App) ConsumeTokenOnce(tokenStr string) (*model.Token, *model.AppError) { + token, err := a.Srv().Store().Token().ConsumeOnce(tokenStr) + if err != nil { + var status int + switch err.(type) { + case *store.ErrNotFound: + status = http.StatusNotFound + default: + status = http.StatusInternalServerError + } + return nil, model.NewAppError("ConsumeTokenOnce", "api.user.create_user.signup_link_invalid.app_error", nil, "", status).Wrap(err) + } + return token, nil +} + func (a *App) DeleteToken(token *model.Token) *model.AppError { err := a.Srv().Store().Token().Delete(token.Token) if err != nil {
server/channels/store/sqlstore/tokens_store.go+15 −0 modified@@ -78,6 +78,21 @@ func (s SqlTokenStore) GetByToken(tokenString string) (*model.Token, error) { return &token, nil } +func (s SqlTokenStore) ConsumeOnce(tokenStr string) (*model.Token, error) { + var token model.Token + + query := `DELETE FROM Tokens WHERE Token = ? RETURNING *` + + if err := s.GetMaster().Get(&token, query, tokenStr); err != nil { + if err == sql.ErrNoRows { + return nil, store.NewErrNotFound("Token", tokenStr) + } + return nil, errors.Wrapf(err, "failed to consume token") + } + + return &token, nil +} + func (s SqlTokenStore) Cleanup(expiryTime int64) { if _, err := s.GetMaster().Exec("DELETE FROM Tokens WHERE CreateAt < ?", expiryTime); err != nil { mlog.Error("Unable to cleanup token store.")
server/channels/store/store.go+1 −0 modified@@ -692,6 +692,7 @@ type TokenStore interface { Save(recovery *model.Token) error Delete(token string) error GetByToken(token string) (*model.Token, error) + ConsumeOnce(tokenStr string) (*model.Token, error) Cleanup(expiryTime int64) GetAllTokensByType(tokenType string) ([]*model.Token, error) RemoveAllTokensByType(tokenType string) error
server/channels/store/storetest/mocks/TokenStore.go+30 −0 modified@@ -19,6 +19,36 @@ func (_m *TokenStore) Cleanup(expiryTime int64) { _m.Called(expiryTime) } +// ConsumeOnce provides a mock function with given fields: tokenStr +func (_m *TokenStore) ConsumeOnce(tokenStr string) (*model.Token, error) { + ret := _m.Called(tokenStr) + + if len(ret) == 0 { + panic("no return value specified for ConsumeOnce") + } + + var r0 *model.Token + var r1 error + if rf, ok := ret.Get(0).(func(string) (*model.Token, error)); ok { + return rf(tokenStr) + } + if rf, ok := ret.Get(0).(func(string) *model.Token); ok { + r0 = rf(tokenStr) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.Token) + } + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(tokenStr) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // Delete provides a mock function with given fields: token func (_m *TokenStore) Delete(token string) error { ret := _m.Called(token)
server/channels/web/saml.go+51 −5 modified@@ -37,6 +37,10 @@ func loginWithSaml(c *Context, w http.ResponseWriter, r *http.Request) { action := r.URL.Query().Get("action") isMobile := action == model.OAuthActionMobile redirectURL := html.EscapeString(r.URL.Query().Get("redirect_to")) + // Optional SAML challenge parameters for mobile code-exchange + state := r.URL.Query().Get("state") + codeChallenge := r.URL.Query().Get("code_challenge") + codeChallengeMethod := r.URL.Query().Get("code_challenge_method") relayProps := map[string]string{} relayState := "" @@ -61,6 +65,19 @@ func loginWithSaml(c *Context, w http.ResponseWriter, r *http.Request) { relayProps["redirect_to"] = redirectURL } + // Forward SAML challenge values via RelayState so the complete step can prefer code-exchange + if isMobile { + if state != "" { + relayProps["state"] = state + } + if codeChallenge != "" { + relayProps["code_challenge"] = codeChallenge + } + if codeChallengeMethod != "" { + relayProps["code_challenge_method"] = codeChallengeMethod + } + } + desktopToken := r.URL.Query().Get("desktop_token") if desktopToken != "" { relayProps["desktop_token"] = desktopToken @@ -220,7 +237,33 @@ func completeSaml(c *Context, w http.ResponseWriter, r *http.Request) { return } - // If it's not a desktop login we create a session for this SAML User that will be used in their browser or mobile app + // Decide between legacy token-in-URL vs SAML code-exchange for mobile + samlState := relayProps["state"] + samlChallenge := relayProps["code_challenge"] + samlMethod := relayProps["code_challenge_method"] + + if isMobile && hasRedirectURL && samlChallenge != "" && c.App.Config().FeatureFlags.MobileSSOCodeExchange { + // Issue one-time login_code bound to user and SAML challenge values; do not create a session here + extra := model.MapToJSON(map[string]string{ + "user_id": user.Id, + "state": samlState, + "code_challenge": samlChallenge, + "code_challenge_method": samlMethod, + }) + code := model.NewToken(model.TokenTypeSaml, extra) + if err := c.App.Srv().Store().Token().Save(code); err != nil { + handleError(model.NewAppError("completeSaml", "app.recover.save.app_error", nil, "", http.StatusInternalServerError).Wrap(err)) + return + } + + redirectURL = utils.AppendQueryParamsToURL(redirectURL, map[string]string{ + "login_code": code.Token, + }) + utils.RenderMobileAuthComplete(w, redirectURL) + return + } + + // Legacy: create a session and attach tokens (web/mobile without SAML code exchange) session, err := c.App.DoLogin(c.AppContext, w, r, user, "", isMobile, false, true) if err != nil { handleError(err) @@ -235,10 +278,13 @@ func completeSaml(c *Context, w http.ResponseWriter, r *http.Request) { if hasRedirectURL { if isMobile { // Mobile clients with redirect url support - redirectURL = utils.AppendQueryParamsToURL(redirectURL, map[string]string{ - model.SessionCookieToken: c.AppContext.Session().Token, - model.SessionCookieCsrf: c.AppContext.Session().GetCSRF(), - }) + // Legacy mobile path: return tokens only when SAML code exchange was not requested + if samlChallenge == "" { + redirectURL = utils.AppendQueryParamsToURL(redirectURL, map[string]string{ + model.SessionCookieToken: c.AppContext.Session().Token, + model.SessionCookieCsrf: c.AppContext.Session().GetCSRF(), + }) + } utils.RenderMobileAuthComplete(w, redirectURL) } else { http.Redirect(w, r, redirectURL, http.StatusFound)
server/public/model/feature_flags.go+5 −0 modified@@ -75,6 +75,10 @@ type FeatureFlags struct { InteractiveDialogAppsForm bool EnableMattermostEntry bool + + // Enable mobile SSO SAML code-exchange flow (no tokens in deep links) + MobileSSOCodeExchange bool + // FEATURE_FLAG_REMOVAL: ChannelAdminManageABACRules - Remove this field when feature is GA // Enable channel admins to manage ABAC rules for their channels ChannelAdminManageABACRules bool @@ -109,6 +113,7 @@ func (f *FeatureFlags) SetDefaults() { f.ContentFlagging = false f.InteractiveDialogAppsForm = true f.EnableMattermostEntry = true + f.MobileSSOCodeExchange = true // FEATURE_FLAG_REMOVAL: ChannelAdminManageABACRules - Remove this default when feature is GA f.ChannelAdminManageABACRules = false // Default to false for safety }
server/public/model/token.go+4 −0 modified@@ -41,3 +41,7 @@ func (t *Token) IsValid() *AppError { return nil } + +func (t *Token) IsExpired() bool { + return GetMillis() > (t.CreateAt + MaxTokenExipryTime) +}
b822cea06bf5Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
8- github.com/advisories/GHSA-ff85-qw3h-g9vpghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-55073ghsaADVISORY
- github.com/mattermost/mattermost/commit/375ce229f4923205394d8f27925372b2cbf28130ghsaWEB
- github.com/mattermost/mattermost/commit/6c288aa62bb3343183ec1d0a06360d14aa0193e9ghsaWEB
- github.com/mattermost/mattermost/commit/a41db04d2746ab549d056db4ede4cd803f64989cghsaWEB
- github.com/mattermost/mattermost/commit/b822cea06bf5683a176e2c92711241bd29cd9389ghsaWEB
- github.com/mattermost/mattermost/commit/e47349ea0fc072ee1dfb196d9bb1c8fd1a589224ghsaWEB
- mattermost.com/security-updatesghsaWEB
News mentions
0No linked articles in our index yet.