diff --git a/changes/31545-fix-sso-trailing-slash b/changes/31545-fix-sso-trailing-slash
new file mode 100644
index 0000000000..05760186db
--- /dev/null
+++ b/changes/31545-fix-sso-trailing-slash
@@ -0,0 +1 @@
+- Fixed an issue where SSO URLs with trailing slashes would cause authentication failures due to double slashes in the ACS URL. Both regular SSO and MDM SSO URLs now properly handle trailing slashes.
diff --git a/ee/server/service/mdm.go b/ee/server/service/mdm.go
index df90777ec9..f2c4bbd53d 100644
--- a/ee/server/service/mdm.go
+++ b/ee/server/service/mdm.go
@@ -710,7 +710,13 @@ func (svc *Service) InitiateMDMAppleSSO(ctx context.Context, initiator string) (
}
serverURL := appConfig.MDMUrl()
- acsURL := serverURL + svc.config.Server.URLPrefix + "/api/v1/fleet/mdm/sso/callback"
+ // Parse the URL and use JoinPath to avoid double slashes
+ parsedURL, err := url.Parse(serverURL)
+ if err != nil {
+ return "", 0, "", ctxerr.Wrap(ctx, err, "invalid MDM URL")
+ }
+ parsedURL = parsedURL.JoinPath(svc.config.Server.URLPrefix, "/api/v1/fleet/mdm/sso/callback")
+ acsURL := parsedURL.String()
samlProvider, err := sso.SAMLProviderFromConfiguredMetadata(ctx,
mdmSSOSettings.EntityID,
diff --git a/server/mdm/apple/apple_mdm.go b/server/mdm/apple/apple_mdm.go
index 276d06eaef..4664aaf7cf 100644
--- a/server/mdm/apple/apple_mdm.go
+++ b/server/mdm/apple/apple_mdm.go
@@ -171,7 +171,11 @@ func (d *DEPService) buildJSONProfile(ctx context.Context, setupAsstJSON json.Ra
endUserAuthEnabled = team.Config.MDM.MacOSSetup.EnableEndUserAuthentication
}
if endUserAuthEnabled {
- jsonProf.ConfigurationWebURL = appCfg.MDMUrl() + "/mdm/sso"
+ mdmSSOURL, err := commonmdm.ResolveURL(appCfg.MDMUrl(), "/mdm/sso", false)
+ if err != nil {
+ return nil, fmt.Errorf("resolve MDM SSO URL: %w", err)
+ }
+ jsonProf.ConfigurationWebURL = mdmSSOURL
}
}
diff --git a/server/service/sessions.go b/server/service/sessions.go
index 9a4ca571d5..8aa8c37bbc 100644
--- a/server/service/sessions.go
+++ b/server/service/sessions.go
@@ -446,7 +446,13 @@ func (svc *Service) InitiateSSO(ctx context.Context, redirectURL string) (sessio
if appConfig.SSOSettings != nil && appConfig.SSOSettings.SSOServerURL != "" {
ssoURL = appConfig.SSOSettings.SSOServerURL
}
- acsURL := ssoURL + svc.config.Server.URLPrefix + "/api/v1/fleet/sso/callback"
+ // Parse the URL and use JoinPath to avoid double slashes
+ parsedURL, err := url.Parse(ssoURL)
+ if err != nil {
+ return "", 0, "", ctxerr.Wrap(ctx, badRequest("invalid SSO URL: "+err.Error()))
+ }
+ parsedURL = parsedURL.JoinPath(svc.config.Server.URLPrefix, "/api/v1/fleet/sso/callback")
+ acsURL := parsedURL.String()
// If entityID is not explicitly set, default to host name.
//
@@ -660,32 +666,30 @@ func (svc *Service) InitSSOCallback(
if appConfig.SSOSettings != nil && appConfig.SSOSettings.SSOServerURL != "" {
ssoURL = appConfig.SSOSettings.SSOServerURL
}
- acsURL, err := url.Parse(ssoURL + svc.config.Server.URLPrefix + "/api/v1/fleet/sso/callback")
+ // Parse the URL and use JoinPath to avoid double slashes
+ parsedURL, err := url.Parse(ssoURL)
if err != nil {
- return nil, "", ctxerr.Wrap(ctx, err, "failed to parse ACS URL")
+ return nil, "", ctxerr.Wrap(ctx, newSSOError(err, ssoOtherError), "invalid SSO URL")
}
+ baseSSO := parsedURL.String()
+
+ // Now construct the ACS URL
+ parsedURL = parsedURL.JoinPath(svc.config.Server.URLPrefix, "/api/v1/fleet/sso/callback")
expectedAudiences := []string{
appConfig.SSOSettings.EntityID,
- appConfig.ServerSettings.ServerURL,
- appConfig.ServerSettings.ServerURL + svc.config.Server.URLPrefix + "/api/v1/fleet/sso/callback", // ACS with server URL
- }
- // Add SSO server URL to expected audiences if configured
- if appConfig.SSOSettings != nil && appConfig.SSOSettings.SSOServerURL != "" {
- expectedAudiences = append(expectedAudiences,
- appConfig.SSOSettings.SSOServerURL,
- appConfig.SSOSettings.SSOServerURL+svc.config.Server.URLPrefix+"/api/v1/fleet/sso/callback", // ACS with SSO server URL
- )
+ baseSSO, // Base SSO URL
+ parsedURL.String(), // Use the already-constructed ACS URL
}
samlProvider, requestID, redirectURL, err := sso.SAMLProviderFromSessionOrConfiguredMetadata(
- ctx, sessionID, svc.ssoSessionStore, acsURL, appConfig.SSOSettings, expectedAudiences,
+ ctx, sessionID, svc.ssoSessionStore, parsedURL, appConfig.SSOSettings, expectedAudiences,
)
if err != nil {
return nil, "", ctxerr.Wrap(ctx, err, "failed to create provider from metadata")
}
// Parse and verify SAMLResponse (verifies fields, expected IDs and signature).
- auth, err = sso.ParseAndVerifySAMLResponse(samlProvider, samlResponse, requestID, acsURL)
+ auth, err = sso.ParseAndVerifySAMLResponse(samlProvider, samlResponse, requestID, parsedURL)
if err != nil {
// We actually don't return 401 to clients and instead return an HTML page with /login?status=error,
// but to be consistent we will return fleet.AuthFailedError which is used for unauthorized access.
diff --git a/server/service/sessions_test.go b/server/service/sessions_test.go
index f8de66e61c..e0163940ed 100644
--- a/server/service/sessions_test.go
+++ b/server/service/sessions_test.go
@@ -17,6 +17,23 @@ import (
"github.com/stretchr/testify/require"
)
+// testSSOMetadata returns a valid SAML metadata XML for testing
+func testSSOMetadata() string {
+ return `
+
+
+
+
+
+ MIIDXTCCAkWgAwIBAgIJALmVVuDWu4NYMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwHhcNMTYxMjMxMTQzNDQ3WhcNNDgwNjI1MTQzNDQ3WjBFMQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzUCFozgNb1h1M0jzNRSCjhOBnR+uVbVpaWfXYIR+AhWDdEe5ryY+CgavOg8bfLybyzFdehlYdDRgkedEB/GjG8aJw06l0qF4jDOAw0kEygWCu2mcH7XOxRt+YAH3TVHa/Hu1W3WjzkobqqqLQ8gkKWWM27fOgAZ6GieaJBN6VBSMMcPey3HWLBmc+TYJmv1dbaO2jHhKh8pfKw0W12VM8P1PIO8gv4Phu/uuJYieBWKixBEyy0lHjyixYFCR12xdh4CA47q958ZRGnnDUGFVE1QhgRacJCOZ9bd5t9mr8KLaVBYTCJo5ERE8jymab5dPqe5qKfJsCZiqWglbjUo9twIDAQABo1AwTjAdBgNVHQ4EFgQUxpuwcs/CYQOyui+r1G+3KxBNhxkwHwYDVR0jBBgwFoAUxpuwcs/CYQOyui+r1G+3KxBNhxkwDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAAiWUKs/2x/viNCKi3Y6blEuCtAGhzOOZ9EjrvJ8+COH3Rag3tVBWrcBZ3/uhhPq5gy9lqw4OkvEws99/5jFsX1FJ6MKBgqfuy7yh5s1YfM0ANHYczMmYpZeAcQf2CGAaVfwTTfSlzNLsF2lW/ly7yapFzlYSJLGoVE+OHEu8g5SlNACUEfkXw+5Eghh+KzlIN7R6Q7r2ixWNFBC/jWf7NKUfJyX8qIG5md1YUeT6GBW9Bm2/1/RiO24JTaYlfLdKK9TYb8sG5B+OLab2DImG99CJ25RkAcSobWNF5zD0O6lgOo3cEdB/ksCq3hmtlC/DlLZ/D8CJ+7VuZnS1rR2naQ==
+
+
+
+
+
+`
+}
+
func TestSessionAuth(t *testing.T) {
ds := new(mock.Store)
svc, ctx := newTestService(t, ds, nil, nil)
@@ -440,7 +457,7 @@ func TestGetSSOUser(t *testing.T) {
func TestInitiateSSOWithSSOServerURL(t *testing.T) {
ds := new(mock.Store)
- pool := redistest.SetupRedis(t, t.Name(), false, false, false)
+ pool := redistest.NopRedis()
svc, ctx := newTestServiceWithConfig(t, ds, config.TestConfig(), nil, nil, &TestServerOpts{
Pool: pool,
@@ -457,19 +474,7 @@ func TestInitiateSSOWithSSOServerURL(t *testing.T) {
SSOProviderSettings: fleet.SSOProviderSettings{
EntityID: "fleet",
IDPName: "TestIDP",
- Metadata: `
-
-
-
-
-
- MIIDXTCCAkWgAwIBAgIJALmVVuDWu4NYMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwHhcNMTYxMjMxMTQzNDQ3WhcNNDgwNjI1MTQzNDQ3WjBFMQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzUCFozgNb1h1M0jzNRSCjhOBnR+uVbVpaWfXYIR+AhWDdEe5ryY+CgavOg8bfLybyzFdehlYdDRgkedEB/GjG8aJw06l0qF4jDOAw0kEygWCu2mcH7XOxRt+YAH3TVHa/Hu1W3WjzkobqqqLQ8gkKWWM27fOgAZ6GieaJBN6VBSMMcPey3HWLBmc+TYJmv1dbaO2jHhKh8pfKw0W12VM8P1PIO8gv4Phu/uuJYieBWKixBEyy0lHjyixYFCR12xdh4CA47q958ZRGnnDUGFVE1QhgRacJCOZ9bd5t9mr8KLaVBYTCJo5ERE8jymab5dPqe5qKfJsCZiqWglbjUo9twIDAQABo1AwTjAdBgNVHQ4EFgQUxpuwcs/CYQOyui+r1G+3KxBNhxkwHwYDVR0jBBgwFoAUxpuwcs/CYQOyui+r1G+3KxBNhxkwDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAAiWUKs/2x/viNCKi3Y6blEuCtAGhzOOZ9EjrvJ8+COH3Rag3tVBWrcBZ3/uhhPq5gy9lqw4OkvEws99/5jFsX1FJ6MKBgqfuy7yh5s1YfM0ANHYczMmYpZeAcQf2CGAaVfwTTfSlzNLsF2lW/ly7yapFzlYSJLGoVE+OHEu8g5SlNACUEfkXw+5Eghh+KzlIN7R6Q7r2ixWNFBC/jWf7NKUfJyX8qIG5md1YUeT6GBW9Bm2/1/RiO24JTaYlfLdKK9TYb8sG5B+OLab2DImG99CJ25RkAcSobWNF5zD0O6lgOo3cEdB/ksCq3hmtlC/DlLZ/D8CJ+7VuZnS1rR2naQ==
-
-
-
-
-
-`,
+ Metadata: testSSOMetadata(),
},
},
}
@@ -488,3 +493,101 @@ func TestInitiateSSOWithSSOServerURL(t *testing.T) {
// We can't directly test the ACS URL in the SAML request here since it's embedded in the XML,
// but the integration test verifies this works correctly
}
+
+func TestInitiateSSOWithTrailingSlash(t *testing.T) {
+ ds := new(mock.Store)
+ pool := redistest.NopRedis()
+
+ svc, ctx := newTestServiceWithConfig(t, ds, config.TestConfig(), nil, nil, &TestServerOpts{
+ Pool: pool,
+ })
+
+ testCases := []struct {
+ name string
+ serverURL string
+ ssoServerURL string
+ }{
+ {
+ name: "server URL with trailing slash",
+ serverURL: "https://fleet.example.com/",
+ ssoServerURL: "",
+ },
+ {
+ name: "SSO server URL with trailing slash",
+ serverURL: "https://fleet.example.com",
+ ssoServerURL: "https://admin.fleet.example.com/",
+ },
+ {
+ name: "both URLs with trailing slash",
+ serverURL: "https://fleet.example.com/",
+ ssoServerURL: "https://admin.fleet.example.com/",
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ // Mock app config
+ appConfig := &fleet.AppConfig{
+ ServerSettings: fleet.ServerSettings{
+ ServerURL: tc.serverURL,
+ },
+ SSOSettings: &fleet.SSOSettings{
+ EnableSSO: true,
+ SSOServerURL: tc.ssoServerURL,
+ SSOProviderSettings: fleet.SSOProviderSettings{
+ EntityID: "fleet",
+ IDPName: "TestIDP",
+ Metadata: testSSOMetadata(),
+ },
+ },
+ }
+
+ ds.AppConfigFunc = func(_ context.Context) (*fleet.AppConfig, error) {
+ return appConfig, nil
+ }
+
+ // Test that InitiateSSO works
+ sessionID, _, idpURL, err := svc.InitiateSSO(ctx, "/dashboard")
+ require.NoError(t, err)
+ require.NotEmpty(t, sessionID)
+ require.NotEmpty(t, idpURL)
+ })
+ }
+}
+
+func TestInitiateSSOWithInvalidURL(t *testing.T) {
+ ds := new(mock.Store)
+ pool := redistest.NopRedis()
+
+ svc, ctx := newTestServiceWithConfig(t, ds, config.TestConfig(), nil, nil, &TestServerOpts{
+ Pool: pool,
+ })
+
+ // Mock app config with invalid URL
+ appConfig := &fleet.AppConfig{
+ ServerSettings: fleet.ServerSettings{
+ ServerURL: "not-a-valid-url://%%%",
+ },
+ SSOSettings: &fleet.SSOSettings{
+ EnableSSO: true,
+ SSOProviderSettings: fleet.SSOProviderSettings{
+ EntityID: "fleet",
+ IDPName: "TestIDP",
+ Metadata: testSSOMetadata(),
+ },
+ },
+ }
+
+ ds.AppConfigFunc = func(_ context.Context) (*fleet.AppConfig, error) {
+ return appConfig, nil
+ }
+
+ // Test that invalid URL returns bad request error
+ _, _, _, err := svc.InitiateSSO(ctx, "/dashboard")
+ require.Error(t, err)
+
+ // Verify it's a bad request error
+ var badReqErr *fleet.BadRequestError
+ require.ErrorAs(t, err, &badReqErr)
+ require.Contains(t, badReqErr.Message, "invalid SSO URL")
+}