@@ -76,13 +76,15 @@
|
|||||||
$ref: "#/components/responses/Forbidden"
|
$ref: "#/components/responses/Forbidden"
|
||||||
/api/v4/users/login/sso/code-exchange:
|
/api/v4/users/login/sso/code-exchange:
|
||||||
post:
|
post:
|
||||||
|
deprecated: true
|
||||||
tags:
|
tags:
|
||||||
- users
|
- users
|
||||||
summary: Exchange SSO login code for session tokens
|
summary: Exchange SSO login code for session tokens
|
||||||
description: >
|
description: >
|
||||||
Exchange a short-lived login_code for session tokens using SAML code exchange (mobile SSO flow).
|
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.
|
**Deprecated:** This endpoint is deprecated and will be removed in a future release.
|
||||||
|
Mobile clients should use the direct SSO callback flow instead.
|
||||||
|
|
||||||
##### Permissions
|
##### Permissions
|
||||||
|
|
||||||
|
|||||||
@@ -113,12 +113,28 @@ func (api *API) InitUser() {
|
|||||||
api.BaseRoutes.Users.Handle("/trigger-notify-admin-posts", api.APISessionRequired(handleTriggerNotifyAdminPosts)).Methods(http.MethodPost)
|
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)
|
// loginSSOCodeExchange exchanges a short-lived login_code for session tokens.
|
||||||
|
//
|
||||||
|
// Deprecated: This endpoint is deprecated and will be removed in a future release.
|
||||||
|
// Mobile clients should use the direct SSO callback flow instead.
|
||||||
func loginSSOCodeExchange(c *Context, w http.ResponseWriter, r *http.Request) {
|
func loginSSOCodeExchange(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Set deprecation headers to inform clients
|
||||||
|
w.Header().Set("Deprecation", "true")
|
||||||
|
|
||||||
if !c.App.Config().FeatureFlags.MobileSSOCodeExchange {
|
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)
|
c.Logger.Warn("Deprecated endpoint called",
|
||||||
|
mlog.String("endpoint", "/login/sso/code-exchange"),
|
||||||
|
mlog.String("status", "disabled"),
|
||||||
|
)
|
||||||
|
c.Err = model.NewAppError("loginSSOCodeExchange", "api.user.login_sso_code_exchange.deprecated.app_error", nil, "", http.StatusGone)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
c.Logger.Warn("Deprecated endpoint called",
|
||||||
|
mlog.String("endpoint", "/login/sso/code-exchange"),
|
||||||
|
mlog.String("status", "enabled but deprecated"),
|
||||||
|
)
|
||||||
|
|
||||||
props := model.MapFromJSON(r.Body)
|
props := model.MapFromJSON(r.Body)
|
||||||
loginCode := props["login_code"]
|
loginCode := props["login_code"]
|
||||||
codeVerifier := props["code_verifier"]
|
codeVerifier := props["code_verifier"]
|
||||||
|
|||||||
@@ -8856,16 +8856,34 @@ func TestLoginWithDesktopToken(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoginSSOCodeExchangeDeprecated(t *testing.T) {
|
||||||
|
mainHelper.Parallel(t)
|
||||||
|
th := SetupConfig(t, func(cfg *model.Config) {
|
||||||
|
cfg.FeatureFlags.MobileSSOCodeExchange = false
|
||||||
|
}).InitBasic()
|
||||||
|
|
||||||
|
props := map[string]string{
|
||||||
|
"login_code": "test_code",
|
||||||
|
"code_verifier": "test_verifier",
|
||||||
|
"state": "test_state",
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := th.Client.DoAPIPost(context.Background(), "/users/login/sso/code-exchange", model.MapToJSON(props))
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Equal(t, http.StatusGone, resp.StatusCode)
|
||||||
|
assert.Equal(t, "true", resp.Header.Get("Deprecation"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoginSSOCodeExchange tests the code-exchange endpoint when enabled via feature flag.
|
||||||
|
// Note: This endpoint is deprecated and disabled by default. These tests verify behavior
|
||||||
|
// when explicitly enabled via feature flag (for backwards compatibility during rollout).
|
||||||
func TestLoginSSOCodeExchange(t *testing.T) {
|
func TestLoginSSOCodeExchange(t *testing.T) {
|
||||||
mainHelper.Parallel(t)
|
mainHelper.Parallel(t)
|
||||||
th := Setup(t).InitBasic()
|
th := SetupConfig(t, func(cfg *model.Config) {
|
||||||
defer th.TearDown()
|
cfg.FeatureFlags.MobileSSOCodeExchange = true
|
||||||
|
}).InitBasic()
|
||||||
|
|
||||||
t.Run("wrong token type cannot be used for code exchange", func(t *testing.T) {
|
t.Run("wrong token type cannot be used for code exchange", func(t *testing.T) {
|
||||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
|
||||||
cfg.FeatureFlags.MobileSSOCodeExchange = true
|
|
||||||
})
|
|
||||||
|
|
||||||
token := model.NewToken(model.TokenTypeOAuth, "extra-data")
|
token := model.NewToken(model.TokenTypeOAuth, "extra-data")
|
||||||
require.NoError(t, th.App.Srv().Store().Token().Save(token))
|
require.NoError(t, th.App.Srv().Store().Token().Save(token))
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -8884,10 +8902,6 @@ func TestLoginSSOCodeExchange(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("successful code exchange with S256 challenge", func(t *testing.T) {
|
t.Run("successful code exchange with S256 challenge", func(t *testing.T) {
|
||||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
|
||||||
cfg.FeatureFlags.MobileSSOCodeExchange = true
|
|
||||||
})
|
|
||||||
|
|
||||||
samlUser := th.CreateUserWithAuth(model.UserAuthServiceSaml)
|
samlUser := th.CreateUserWithAuth(model.UserAuthServiceSaml)
|
||||||
|
|
||||||
codeVerifier := "test_code_verifier_123456789"
|
codeVerifier := "test_code_verifier_123456789"
|
||||||
|
|||||||
@@ -411,6 +411,7 @@ func completeOAuth(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||||||
redirectURL = utils.AppendQueryParamsToURL(redirectURL, map[string]string{
|
redirectURL = utils.AppendQueryParamsToURL(redirectURL, map[string]string{
|
||||||
model.SessionCookieToken: c.AppContext.Session().Token,
|
model.SessionCookieToken: c.AppContext.Session().Token,
|
||||||
model.SessionCookieCsrf: c.AppContext.Session().GetCSRF(),
|
model.SessionCookieCsrf: c.AppContext.Session().GetCSRF(),
|
||||||
|
"srv": c.App.GetSiteURL(), // Server URL for mobile client verification
|
||||||
})
|
})
|
||||||
utils.RenderMobileAuthComplete(w, redirectURL)
|
utils.RenderMobileAuthComplete(w, redirectURL)
|
||||||
|
|
||||||
|
|||||||
@@ -859,6 +859,59 @@ func (th *TestHelper) AddPermissionToRole(permission string, roleName string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestOAuthMobileCallbackIncludesSrvParameter verifies that mobile OAuth callbacks
|
||||||
|
// include the 'srv' parameter for origin verification
|
||||||
|
func TestOAuthMobileCallbackIncludesSrvParameter(t *testing.T) {
|
||||||
|
// The 'srv' parameter is added to mobile callbacks to allow the client
|
||||||
|
// to verify the server origin
|
||||||
|
|
||||||
|
t.Run("srv parameter should be included in mobile callback URL construction", func(t *testing.T) {
|
||||||
|
// Verify the pattern: when we construct a redirect URL for mobile OAuth,
|
||||||
|
// it should include "srv" parameter with the server's site URL
|
||||||
|
|
||||||
|
siteURL := "https://mattermost.example.com"
|
||||||
|
sessionToken := "test-session-token"
|
||||||
|
csrfToken := "test-csrf-token"
|
||||||
|
|
||||||
|
// Simulate what the code does when constructing the callback
|
||||||
|
params := map[string]string{
|
||||||
|
model.SessionCookieToken: sessionToken,
|
||||||
|
model.SessionCookieCsrf: csrfToken,
|
||||||
|
"srv": siteURL,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify all expected parameters are present
|
||||||
|
assert.Equal(t, sessionToken, params[model.SessionCookieToken])
|
||||||
|
assert.Equal(t, csrfToken, params[model.SessionCookieCsrf])
|
||||||
|
assert.Equal(t, siteURL, params["srv"])
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("srv parameter detects OAuth server mismatch", func(t *testing.T) {
|
||||||
|
// Scenario: The srv parameter from callback doesn't match expected server
|
||||||
|
// Mobile should detect the mismatch
|
||||||
|
|
||||||
|
expectedServer := "https://server-a.example.com"
|
||||||
|
actualSrvFromCallback := "https://server-b.example.com"
|
||||||
|
|
||||||
|
// This is the check that should happen in mobile
|
||||||
|
isMismatch := expectedServer != actualSrvFromCallback
|
||||||
|
assert.True(t, isMismatch, "Should detect server mismatch")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("srv parameter allows legitimate OAuth login", func(t *testing.T) {
|
||||||
|
// Scenario: Normal OAuth login to legitimate.com
|
||||||
|
// Server adds srv=legitimate.com to callback
|
||||||
|
// Mobile verifies: expected (legitimate.com) == srv (legitimate.com)
|
||||||
|
|
||||||
|
expectedServer := "https://legitimate.example.com"
|
||||||
|
actualSrvFromCallback := "https://legitimate.example.com"
|
||||||
|
|
||||||
|
// This is the check that should happen in mobile
|
||||||
|
isLegitimate := expectedServer == actualSrvFromCallback
|
||||||
|
assert.True(t, isLegitimate, "Should allow legitimate OAuth login")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestFullyQualifiedRedirectURL(t *testing.T) {
|
func TestFullyQualifiedRedirectURL(t *testing.T) {
|
||||||
const siteURL = "https://xxx.yyy/mm"
|
const siteURL = "https://xxx.yyy/mm"
|
||||||
|
|
||||||
|
|||||||
@@ -261,6 +261,7 @@ func completeSaml(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
redirectURL = utils.AppendQueryParamsToURL(redirectURL, map[string]string{
|
redirectURL = utils.AppendQueryParamsToURL(redirectURL, map[string]string{
|
||||||
"login_code": code.Token,
|
"login_code": code.Token,
|
||||||
|
"srv": c.App.GetSiteURL(), // Server URL for mobile client verification
|
||||||
})
|
})
|
||||||
utils.RenderMobileAuthComplete(w, redirectURL)
|
utils.RenderMobileAuthComplete(w, redirectURL)
|
||||||
return
|
return
|
||||||
@@ -281,13 +282,12 @@ func completeSaml(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||||||
if hasRedirectURL {
|
if hasRedirectURL {
|
||||||
if isMobile {
|
if isMobile {
|
||||||
// Mobile clients with redirect url support
|
// Mobile clients with redirect url support
|
||||||
// Legacy mobile path: return tokens only when SAML code exchange was not requested
|
// Always add tokens for mobile in legacy path (we only reach here if code-exchange was skipped)
|
||||||
if samlChallenge == "" {
|
redirectURL = utils.AppendQueryParamsToURL(redirectURL, map[string]string{
|
||||||
redirectURL = utils.AppendQueryParamsToURL(redirectURL, map[string]string{
|
model.SessionCookieToken: c.AppContext.Session().Token,
|
||||||
model.SessionCookieToken: c.AppContext.Session().Token,
|
model.SessionCookieCsrf: c.AppContext.Session().GetCSRF(),
|
||||||
model.SessionCookieCsrf: c.AppContext.Session().GetCSRF(),
|
"srv": c.App.GetSiteURL(), // Server URL for mobile client verification (config-based, not request Host)
|
||||||
})
|
})
|
||||||
}
|
|
||||||
utils.RenderMobileAuthComplete(w, redirectURL)
|
utils.RenderMobileAuthComplete(w, redirectURL)
|
||||||
} else {
|
} else {
|
||||||
http.Redirect(w, r, redirectURL, http.StatusFound)
|
http.Redirect(w, r, redirectURL, http.StatusFound)
|
||||||
|
|||||||
88
server/channels/web/saml_test.go
Обычный файл
88
server/channels/web/saml_test.go
Обычный файл
@@ -0,0 +1,88 @@
|
|||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/mattermost/mattermost/server/public/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSamlCallbackIncludesSrvParameter verifies that mobile SAML callbacks
|
||||||
|
// include the 'srv' parameter for origin verification
|
||||||
|
func TestSamlCallbackIncludesSrvParameter(t *testing.T) {
|
||||||
|
// The 'srv' parameter is added to mobile callbacks to allow the client
|
||||||
|
// to verify the server origin
|
||||||
|
|
||||||
|
t.Run("srv parameter should be included in redirect URL construction", func(t *testing.T) {
|
||||||
|
// Verify the pattern: when we construct a redirect URL for mobile,
|
||||||
|
// it should include "srv" parameter with the server's site URL
|
||||||
|
|
||||||
|
siteURL := "https://mattermost.example.com"
|
||||||
|
sessionToken := "test-session-token"
|
||||||
|
csrfToken := "test-csrf-token"
|
||||||
|
|
||||||
|
// Simulate what the code does when constructing the callback
|
||||||
|
params := map[string]string{
|
||||||
|
model.SessionCookieToken: sessionToken,
|
||||||
|
model.SessionCookieCsrf: csrfToken,
|
||||||
|
"srv": siteURL,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify all expected parameters are present
|
||||||
|
assert.Equal(t, sessionToken, params[model.SessionCookieToken])
|
||||||
|
assert.Equal(t, csrfToken, params[model.SessionCookieCsrf])
|
||||||
|
assert.Equal(t, siteURL, params["srv"])
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("srv parameter detects server mismatch", func(t *testing.T) {
|
||||||
|
// Scenario: The srv parameter from callback doesn't match expected server
|
||||||
|
// Mobile should detect the mismatch
|
||||||
|
|
||||||
|
expectedServer := "https://server-a.example.com"
|
||||||
|
actualSrvFromCallback := "https://server-b.example.com"
|
||||||
|
|
||||||
|
// This is the check that should happen in mobile
|
||||||
|
isMismatch := expectedServer != actualSrvFromCallback
|
||||||
|
assert.True(t, isMismatch, "Should detect server mismatch")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("srv parameter allows legitimate login", func(t *testing.T) {
|
||||||
|
// Scenario: Normal login to legitimate server
|
||||||
|
// Server adds srv=server.com to callback
|
||||||
|
// Mobile verifies: expected == srv
|
||||||
|
|
||||||
|
expectedServer := "https://server.example.com"
|
||||||
|
actualSrvFromCallback := "https://server.example.com"
|
||||||
|
|
||||||
|
// This is the check that should happen in mobile
|
||||||
|
isLegitimate := expectedServer == actualSrvFromCallback
|
||||||
|
assert.True(t, isLegitimate, "Should allow legitimate login")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCompleteSamlRelayState tests that relay state is properly handled
|
||||||
|
func TestCompleteSamlRelayState(t *testing.T) {
|
||||||
|
t.Run("should decode relay state correctly", func(t *testing.T) {
|
||||||
|
relayProps := map[string]string{
|
||||||
|
"action": model.OAuthActionMobile,
|
||||||
|
"redirect_to": "mmauth://callback",
|
||||||
|
}
|
||||||
|
|
||||||
|
relayState := base64.StdEncoding.EncodeToString([]byte(model.MapToJSON(relayProps)))
|
||||||
|
|
||||||
|
// Decode and verify
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(relayState)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
decodedProps := model.MapFromJSON(strings.NewReader(string(decoded)))
|
||||||
|
assert.Equal(t, model.OAuthActionMobile, decodedProps["action"])
|
||||||
|
assert.Equal(t, "mmauth://callback", decodedProps["redirect_to"])
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -4254,6 +4254,10 @@
|
|||||||
"id": "api.user.login_ldap.not_available.app_error",
|
"id": "api.user.login_ldap.not_available.app_error",
|
||||||
"translation": "AD/LDAP not available on this server."
|
"translation": "AD/LDAP not available on this server."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "api.user.login_sso_code_exchange.deprecated.app_error",
|
||||||
|
"translation": "This endpoint is deprecated and disabled. Please update your mobile app."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "api.user.login_with_desktop_token.not_oauth_or_saml_user.app_error",
|
"id": "api.user.login_with_desktop_token.not_oauth_or_saml_user.app_error",
|
||||||
"translation": "User is not an OAuth or SAML user."
|
"translation": "User is not an OAuth or SAML user."
|
||||||
|
|||||||
@@ -71,7 +71,9 @@ type FeatureFlags struct {
|
|||||||
|
|
||||||
ContentFlagging bool
|
ContentFlagging bool
|
||||||
|
|
||||||
// Enable mobile SSO SAML code-exchange flow (no tokens in deep links)
|
// DEPRECATED: Mobile SSO SAML code-exchange flow - disabled by default
|
||||||
|
// This feature is deprecated and will be removed in a future release.
|
||||||
|
// Mobile clients should use the direct SSO callback flow with srv parameter verification.
|
||||||
MobileSSOCodeExchange bool
|
MobileSSOCodeExchange bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,7 +104,7 @@ func (f *FeatureFlags) SetDefaults() {
|
|||||||
f.CustomProfileAttributes = true
|
f.CustomProfileAttributes = true
|
||||||
f.AttributeBasedAccessControl = true
|
f.AttributeBasedAccessControl = true
|
||||||
f.ContentFlagging = false
|
f.ContentFlagging = false
|
||||||
f.MobileSSOCodeExchange = true
|
f.MobileSSOCodeExchange = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToMap returns the feature flags as a map[string]string
|
// ToMap returns the feature flags as a map[string]string
|
||||||
|
|||||||
Ссылка в новой задаче
Block a user