diff --git a/changes/sso-idp-mdm b/changes/sso-idp-mdm
new file mode 100644
index 0000000000..8ba5083a37
--- /dev/null
+++ b/changes/sso-idp-mdm
@@ -0,0 +1 @@
+* Added functionality to gate Apple MDM login behind SAML authentication.
diff --git a/cmd/fleet/serve.go b/cmd/fleet/serve.go
index 201fa2dbd3..d87a8debb2 100644
--- a/cmd/fleet/serve.go
+++ b/cmd/fleet/serve.go
@@ -607,6 +607,7 @@ the way that the Fleet server works.
depStorage,
apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPushService),
mdmPushCertTopic,
+ ssoSessionStore,
)
if err != nil {
initFatal(err, "initial Fleet Premium service")
diff --git a/docker-compose.yml b/docker-compose.yml
index 7268b9f404..b6a3c8d27d 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -83,11 +83,9 @@ services:
saml_idp:
image: fleetdm/docker-idp:latest
- environment:
- SIMPLESAMLPHP_SP_ENTITY_ID: "https://localhost:8080"
- SIMPLESAMLPHP_SP_ASSERTION_CONSUMER_SERVICE: "https://localhost:8080/api/v1/fleet/sso/callback"
volumes:
- ./tools/saml/users.php:/var/www/simplesamlphp/config/authsources.php
+ - ./tools/saml/config.php:/var/www/simplesamlphp/metadata/saml20-sp-remote.php
ports:
- "9080:8080"
- "9443:8443"
diff --git a/docs/Contributing/Testing-and-local-development.md b/docs/Contributing/Testing-and-local-development.md
index 209f2873e8..54b8caf615 100644
--- a/docs/Contributing/Testing-and-local-development.md
+++ b/docs/Contributing/Testing-and-local-development.md
@@ -38,6 +38,7 @@
- [Testing MDM](#testing-mdm)
- [Testing manual enrollment](#testing-manual-enrollment)
- [Testing DEP enrollment](#testing-dep-enrollment)
+ - [Gating the DEP profile behind SSO](#gating-the-dep-profile-behind-sso)
- [Nudge](#nudge)
## License key
@@ -605,6 +606,20 @@ Reference the [Apple DEP Profile documentation](https://developer.apple.com/docu
3. Boot the machine, it should automatically enroll into MDM.
+##### Gating the DEP profile behind SSO
+
+To gate DEP enrollments behind SSO, you can use the same configuration values as those described in [Testing SSO](#testing-sso):
+
+```yaml
+mdm:
+ end_user_authentication:
+ entity_id: https://localhost:8080
+ idp_name: SimpleSAML
+ issuer_uri: http://localhost:9080/simplesaml/saml2/idp/SSOService.php
+ metadata: ""
+ metadata_url: http://localhost:9080/simplesaml/saml2/idp/metadata.php
+```
+
### Nudge
We use [Nudge](https://github.com/macadmins/nudge) to enforce macOS updates. Our integration is tightly managed by Orbit:
diff --git a/ee/server/service/mdm.go b/ee/server/service/mdm.go
index 3df3ab4526..caca8db7ee 100644
--- a/ee/server/service/mdm.go
+++ b/ee/server/service/mdm.go
@@ -12,11 +12,15 @@ import (
"github.com/fleetdm/fleet/v4/pkg/file"
"github.com/fleetdm/fleet/v4/server/authz"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
+ "github.com/fleetdm/fleet/v4/server/contexts/logging"
"github.com/fleetdm/fleet/v4/server/fleet"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
"github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig"
+ "github.com/fleetdm/fleet/v4/server/sso"
kitlog "github.com/go-kit/kit/log"
+ "github.com/go-kit/kit/log/level"
"github.com/google/uuid"
+ "github.com/micromdm/nanodep/godep"
"github.com/micromdm/nanodep/storage"
)
@@ -386,3 +390,125 @@ func (svc *Service) DeleteMDMAppleSetupAssistant(ctx context.Context, teamID *ui
return nil
}
+
+func (svc *Service) InitiateMDMAppleSSO(ctx context.Context) (string, error) {
+ // skipauth: User context does not yet exist. Unauthenticated users may
+ // initiate SSO.
+ svc.authz.SkipAuthorization(ctx)
+
+ logging.WithLevel(logging.WithNoUser(ctx), level.Info)
+
+ appConfig, err := svc.ds.AppConfig(ctx)
+ if err != nil {
+ return "", ctxerr.Wrap(ctx, err, "getting app config")
+ }
+
+ settings := appConfig.MDM.EndUserAuthentication.SSOProviderSettings
+
+ // For now, until we get to #10999, we assume that SSO is disabled if
+ // no settings are provided.
+ if settings.IsEmpty() {
+ err := &fleet.BadRequestError{Message: "organization not configured to use sso"}
+ return "", ctxerr.Wrap(ctx, err, "initiate mdm sso")
+ }
+
+ metadata, err := sso.GetMetadata(&settings)
+ if err != nil {
+ return "", ctxerr.Wrap(ctx, err, "InitiateSSO getting metadata")
+ }
+
+ serverURL := appConfig.ServerSettings.ServerURL
+ authSettings := sso.Settings{
+ Metadata: metadata,
+ AssertionConsumerServiceURL: serverURL + svc.config.Server.URLPrefix + "/api/v1/fleet/mdm/sso/callback",
+ SessionStore: svc.ssoSessionStore,
+ OriginalURL: "/api/v1/fleet/mdm/sso/callback",
+ }
+
+ idpURL, err := sso.CreateAuthorizationRequest(&authSettings, settings.EntityID)
+ if err != nil {
+ return "", ctxerr.Wrap(ctx, err, "InitiateSSO creating authorization")
+ }
+
+ return idpURL, nil
+
+}
+
+func (svc *Service) InitiateMDMAppleSSOCallback(ctx context.Context, auth fleet.Auth) ([]byte, error) {
+ // skipauth: User context does not yet exist. Unauthenticated users may
+ // hit the SSO callback.
+ svc.authz.SkipAuthorization(ctx)
+
+ logging.WithLevel(logging.WithNoUser(ctx), level.Info)
+
+ appConfig, err := svc.ds.AppConfig(ctx)
+ if err != nil {
+ return nil, ctxerr.Wrap(ctx, err, "get config for sso")
+ }
+
+ _, metadata, err := svc.ssoSessionStore.Fullfill(auth.RequestID())
+ if err != nil {
+ return nil, ctxerr.Wrap(ctx, err, "validate request in session")
+ }
+
+ err = sso.ValidateAudiences(
+ *metadata,
+ auth,
+ appConfig.SSOSettings.EntityID,
+ appConfig.ServerSettings.ServerURL,
+ appConfig.ServerSettings.ServerURL+svc.config.Server.URLPrefix+"/api/v1/fleet/mdm/sso/callback",
+ )
+
+ if err != nil {
+ return nil, ctxerr.Wrap(ctx, err, "validating sso response")
+ }
+
+ return apple_mdm.GenerateEnrollmentProfileMobileconfig(
+ appConfig.OrgInfo.OrgName,
+ appConfig.ServerSettings.ServerURL,
+ svc.config.MDM.AppleSCEPChallenge,
+ svc.mdmPushCertTopic,
+ )
+}
+
+func (svc *Service) MDMAppleSyncDEPPRofile(ctx context.Context) error {
+ profiles, err := svc.ds.ListMDMAppleEnrollmentProfiles(ctx)
+ if err != nil {
+ return ctxerr.Wrap(ctx, err, "listing profiles")
+ }
+
+ // Grab the first automatic enrollment profile we find, the current
+ // behavior is that the last enrollment profile that was uploaded is
+ // the one assigned to newly enrolled devices.
+ //
+ // TODO: this will change after #10995 where there can be a DEP profile
+ // per team.
+ var depProf *fleet.MDMAppleEnrollmentProfile
+ for _, prof := range profiles {
+ if prof.Type == "automatic" {
+ depProf = prof
+ break
+ }
+ }
+
+ if depProf == nil {
+ return svc.depService.CreateDefaultProfile(ctx)
+ }
+
+ appCfg, err := svc.ds.AppConfig(ctx)
+ if err != nil {
+ return ctxerr.Wrap(ctx, err, "fetching app config")
+ }
+
+ enrollURL, err := apple_mdm.EnrollURL(depProf.Token, appCfg)
+ if err != nil {
+ return ctxerr.Wrap(ctx, err, "generating enroll URL")
+ }
+
+ var jsonProf *godep.Profile
+ if err := json.Unmarshal(*depProf.DEPProfile, &jsonProf); err != nil {
+ return ctxerr.Wrap(ctx, err, "unmarshalling DEP profile")
+ }
+
+ return svc.depService.RegisterProfileWithAppleDEPServer(ctx, jsonProf, enrollURL)
+}
diff --git a/ee/server/service/service.go b/ee/server/service/service.go
index 48f00bd768..d18538668d 100644
--- a/ee/server/service/service.go
+++ b/ee/server/service/service.go
@@ -7,6 +7,8 @@ import (
"github.com/fleetdm/fleet/v4/server/authz"
"github.com/fleetdm/fleet/v4/server/config"
"github.com/fleetdm/fleet/v4/server/fleet"
+ apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
+ "github.com/fleetdm/fleet/v4/server/sso"
kitlog "github.com/go-kit/kit/log"
"github.com/micromdm/nanodep/storage"
)
@@ -23,6 +25,8 @@ type Service struct {
depStorage storage.AllStorage
mdmAppleCommander fleet.MDMAppleCommandIssuer
mdmPushCertTopic string
+ ssoSessionStore sso.SessionStore
+ depService *apple_mdm.DEPService
}
func NewService(
@@ -35,6 +39,7 @@ func NewService(
depStorage storage.AllStorage,
mdmAppleCommander fleet.MDMAppleCommandIssuer,
mdmPushCertTopic string,
+ sso sso.SessionStore,
) (*Service, error) {
authorizer, err := authz.NewAuthorizer()
if err != nil {
@@ -51,6 +56,8 @@ func NewService(
depStorage: depStorage,
mdmAppleCommander: mdmAppleCommander,
mdmPushCertTopic: mdmPushCertTopic,
+ ssoSessionStore: sso,
+ depService: apple_mdm.NewDEPService(ds, depStorage, logger, false),
}
// Override methods that can't be easily overriden via
@@ -62,6 +69,7 @@ func NewService(
MDMAppleEnableFileVaultAndEscrow: eeservice.MDMAppleEnableFileVaultAndEscrow,
MDMAppleDisableFileVaultAndEscrow: eeservice.MDMAppleDisableFileVaultAndEscrow,
DeleteMDMAppleSetupAssistant: eeservice.DeleteMDMAppleSetupAssistant,
+ MDMAppleSyncDEPPRofile: eeservice.MDMAppleSyncDEPPRofile,
DeleteMDMAppleBootstrapPackage: eeservice.DeleteMDMAppleBootstrapPackage,
})
diff --git a/frontend/components/DataError/DataError.tsx b/frontend/components/DataError/DataError.tsx
index 989a767ab9..66bcb2a63f 100644
--- a/frontend/components/DataError/DataError.tsx
+++ b/frontend/components/DataError/DataError.tsx
@@ -1,4 +1,5 @@
import React from "react";
+import classnames from "classnames";
import CustomLink from "components/CustomLink";
import Icon from "components/Icon";
@@ -8,11 +9,18 @@ const baseClass = "data-error";
interface IDataErrorProps {
children?: JSX.Element | string;
card?: boolean;
+ className?: string;
}
-const DataError = ({ children, card }: IDataErrorProps): JSX.Element => {
+const DataError = ({
+ children,
+ card,
+ className,
+}: IDataErrorProps): JSX.Element => {
+ const classes = classnames(baseClass, className);
+
return (
-
+
diff --git a/frontend/components/Spinner/_styles.scss b/frontend/components/Spinner/_styles.scss
index 45d38ac800..12a9cf6ee2 100644
--- a/frontend/components/Spinner/_styles.scss
+++ b/frontend/components/Spinner/_styles.scss
@@ -2,9 +2,6 @@
display: flex;
align-items: center;
justify-content: center;
- animation: fade-and-scale 150ms ease-out;
- animation-delay: 250ms;
- opacity: 0;
&.centered {
margin: 120px auto;
}
diff --git a/frontend/interfaces/mdm.ts b/frontend/interfaces/mdm.ts
index ea5180857c..7b0387777a 100644
--- a/frontend/interfaces/mdm.ts
+++ b/frontend/interfaces/mdm.ts
@@ -110,3 +110,7 @@ export interface IMdmScript {
created_at: string;
updated_at: string;
}
+
+export interface IMdmSSOReponse {
+ url: string;
+}
diff --git a/frontend/pages/MDMAppleSSOPage/MDMAppleSSOPage.tsx b/frontend/pages/MDMAppleSSOPage/MDMAppleSSOPage.tsx
new file mode 100644
index 0000000000..8fdc299f76
--- /dev/null
+++ b/frontend/pages/MDMAppleSSOPage/MDMAppleSSOPage.tsx
@@ -0,0 +1,37 @@
+import React from "react";
+import { useQuery } from "react-query";
+import { AxiosError } from "axios";
+
+import mdmAPI from "services/entities/mdm";
+
+import DataError from "components/DataError";
+import Spinner from "components/Spinner/Spinner";
+import { IMdmSSOReponse } from "interfaces/mdm";
+
+const baseClass = "mdm-apple-sso-page";
+
+const SSOError = () => {
+ return (
+
+ Please contact your IT admin at +1-(415)-651-2575.
+
+ );
+};
+
+const DEPSSOLoginPage = () => {
+ const { error } = useQuery(
+ ["dep_sso"],
+ () => mdmAPI.initiateMDMAppleSSO(),
+ {
+ retry: false,
+ refetchOnWindowFocus: false,
+ onSuccess: ({ url }) => {
+ window.location.href = url;
+ },
+ }
+ );
+
+ return {error ? : }
;
+};
+
+export default DEPSSOLoginPage;
diff --git a/frontend/pages/MDMAppleSSOPage/_styles.scss b/frontend/pages/MDMAppleSSOPage/_styles.scss
new file mode 100644
index 0000000000..51728f0e07
--- /dev/null
+++ b/frontend/pages/MDMAppleSSOPage/_styles.scss
@@ -0,0 +1,14 @@
+.mdm-apple-sso-page {
+ height: 100vh; // expend height to make entire viewport a white background
+ background-color: $core-white;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ &__sso-error {
+ p {
+ font-size: $x-small;
+ margin: 12px 0 0;
+ }
+ }
+}
diff --git a/frontend/pages/MDMAppleSSOPage/index.ts b/frontend/pages/MDMAppleSSOPage/index.ts
new file mode 100644
index 0000000000..2f1faed1a5
--- /dev/null
+++ b/frontend/pages/MDMAppleSSOPage/index.ts
@@ -0,0 +1 @@
+export { default } from "./MDMAppleSSOPage";
diff --git a/frontend/router/index.tsx b/frontend/router/index.tsx
index dc9f3a0455..f545bf8769 100644
--- a/frontend/router/index.tsx
+++ b/frontend/router/index.tsx
@@ -39,6 +39,7 @@ import PolicyPage from "pages/policies/PolicyPage";
import QueryPage from "pages/queries/QueryPage";
import RegistrationPage from "pages/RegistrationPage";
import ResetPasswordPage from "pages/ResetPasswordPage";
+import MDMAppleSSOPage from "pages/MDMAppleSSOPage";
import SoftwareDetailsPage from "pages/software/SoftwareDetailsPage";
import ApiOnlyUser from "pages/ApiOnlyUser";
import Fleet403 from "pages/errors/Fleet403";
@@ -96,6 +97,7 @@ const routes = (
/>
+
diff --git a/frontend/services/entities/mdm.ts b/frontend/services/entities/mdm.ts
index 41cadb2923..16ceb181e0 100644
--- a/frontend/services/entities/mdm.ts
+++ b/frontend/services/entities/mdm.ts
@@ -98,4 +98,9 @@ export default {
team_id: teamId,
});
},
+
+ initiateMDMAppleSSO: () => {
+ const { MDM_APPLE_SSO } = endpoints;
+ return sendRequest("POST", MDM_APPLE_SSO, {});
+ },
};
diff --git a/frontend/utilities/endpoints.ts b/frontend/utilities/endpoints.ts
index 35998f9ebc..1d58e0b062 100644
--- a/frontend/utilities/endpoints.ts
+++ b/frontend/utilities/endpoints.ts
@@ -47,6 +47,7 @@ export default {
MDM_UPDATE_APPLE_SETTINGS: `/${API_VERSION}/fleet/mdm/apple/settings`,
MDM_PROFILES_AGGREGATE_STATUSES: `/${API_VERSION}/fleet/mdm/apple/profiles/summary`,
MDM_APPLE_DISK_ENCRYPTION_AGGREGATE: `/${API_VERSION}/fleet/mdm/apple/filevault/summary`,
+ MDM_APPLE_SSO: `/${API_VERSION}/fleet/mdm/sso`,
// Should below 2 endpoints be consistent?
HOST_MDM: (id: number) => `/${API_VERSION}/fleet/hosts/${id}/mdm`,
HOST_MDM_UNENROLL: (id: number) =>
diff --git a/server/datastore/mysql/apple_mdm.go b/server/datastore/mysql/apple_mdm.go
index 53047b2612..053c15f3fc 100644
--- a/server/datastore/mysql/apple_mdm.go
+++ b/server/datastore/mysql/apple_mdm.go
@@ -242,6 +242,7 @@ SELECT
updated_at
FROM
mdm_apple_enrollment_profiles
+ORDER BY created_at DESC
`,
); err != nil {
return nil, ctxerr.Wrap(ctx, err, "list enrollment profiles")
diff --git a/server/fleet/app.go b/server/fleet/app.go
index 8ff0a1af04..43d8858b11 100644
--- a/server/fleet/app.go
+++ b/server/fleet/app.go
@@ -49,6 +49,10 @@ type SSOProviderSettings struct {
IDPName string `json:"idp_name"`
}
+func (s SSOProviderSettings) IsEmpty() bool {
+ return s == (SSOProviderSettings{})
+}
+
// SSOSettings wire format for SSO settings
type SSOSettings struct {
SSOProviderSettings
diff --git a/server/fleet/app_test.go b/server/fleet/app_test.go
index 662a901563..a3805d1ab6 100644
--- a/server/fleet/app_test.go
+++ b/server/fleet/app_test.go
@@ -113,3 +113,8 @@ func TestMacOSUpdatesValidate(t *testing.T) {
}
})
}
+
+func TestSSOSettingsIsEmpty(t *testing.T) {
+ require.True(t, (SSOProviderSettings{}).IsEmpty())
+ require.False(t, (SSOProviderSettings{EntityID: "fleet"}).IsEmpty())
+}
diff --git a/server/fleet/service.go b/server/fleet/service.go
index 1a141a5534..db87319af6 100644
--- a/server/fleet/service.go
+++ b/server/fleet/service.go
@@ -29,6 +29,7 @@ type EnterpriseOverrides struct {
MDMAppleEnableFileVaultAndEscrow func(ctx context.Context, teamID *uint) error
MDMAppleDisableFileVaultAndEscrow func(ctx context.Context, teamID *uint) error
DeleteMDMAppleSetupAssistant func(ctx context.Context, teamID *uint) error
+ MDMAppleSyncDEPPRofile func(ctx context.Context) error
DeleteMDMAppleBootstrapPackage func(ctx context.Context, teamID *uint) error
}
@@ -155,9 +156,21 @@ type Service interface {
// prompted to log in.
InitiateSSO(ctx context.Context, redirectURL string) (string, error)
+ // InitiateMDMAppleSSO initiates SSO for MDM flows, this method is
+ // different from InitiateSSO because it receives a different
+ // configuration and only supports a subset of the features (eg: we
+ // don't want to allow IdP initiated authentications)
+ InitiateMDMAppleSSO(ctx context.Context) (string, error)
+
// InitSSOCallback handles the IDP response and ensures the credentials
// are valid
InitSSOCallback(ctx context.Context, auth Auth) (string, error)
+
+ // InitSSOCallback handles the IDP response and ensures the credentials
+ // are valid, then responds with an enrollment profile.
+ // TODO: add support for EULAs too
+ InitiateMDMAppleSSOCallback(ctx context.Context, auth Auth) ([]byte, error)
+
// GetSSOUser handles retrieval of an user that is trying to authenticate
// via SSO
GetSSOUser(ctx context.Context, auth Auth) (*User, error)
diff --git a/server/mdm/apple/apple_mdm.go b/server/mdm/apple/apple_mdm.go
index c37cd5acfe..852331e901 100644
--- a/server/mdm/apple/apple_mdm.go
+++ b/server/mdm/apple/apple_mdm.go
@@ -160,7 +160,7 @@ func (d *DEPService) createProfile(ctx context.Context, depProfile *godep.Profil
return ctxerr.Wrap(ctx, err, "saving enrollment profile in DB")
}
- if err := d.registerProfileWithAppleDEPServer(ctx, depProfile, enrollURL); err != nil {
+ if err := d.RegisterProfileWithAppleDEPServer(ctx, depProfile, enrollURL); err != nil {
return ctxerr.Wrap(ctx, err, "registering profile in Apple servers")
}
@@ -169,15 +169,25 @@ func (d *DEPService) createProfile(ctx context.Context, depProfile *godep.Profil
// registerProfileInDEPServe is in charge of registering the enrollment profile
// in Apple's servers via the DEP API, so it can be used for assignment.
-func (d *DEPService) registerProfileWithAppleDEPServer(ctx context.Context, depProfile *godep.Profile, enrollURL string) error {
- // Override url with Fleet's enroll path (publicly accessible address).
+func (d *DEPService) RegisterProfileWithAppleDEPServer(ctx context.Context, depProfile *godep.Profile, enrollURL string) error {
+ appConfig, err := d.ds.AppConfig(context.Background())
+ if err != nil {
+ return fmt.Errorf("get app config: %w", err)
+ }
+
depProfile.URL = enrollURL
- // If the profile doesn't have a configuration_web_url, use Fleet's
- // enrollURL, otherwise the request for the enrollment profile is
- // submitted as a POST instead of GET.
- if depProfile.ConfigurationWebURL == "" {
+ // If SSO is configured, use the `/mdm/sso` page which starts the SSO
+ // flow, otherwise use Fleet's enroll URL.
+ //
+ // Even though the DEP profile supports an `url` attribute, we should
+ // always still set configuration_web_url, otherwise the request method
+ // coming from Apple changes from GET to POST, and we want to preserve
+ // backwards compatibility.
+ if appConfig.MDM.EndUserAuthentication.SSOProviderSettings.IsEmpty() {
depProfile.ConfigurationWebURL = enrollURL
+ } else {
+ depProfile.ConfigurationWebURL = appConfig.ServerSettings.ServerURL + "/mdm/sso"
}
depClient := NewDEPClient(d.depStorage, d.ds, d.logger)
@@ -195,22 +205,13 @@ func (d *DEPService) registerProfileWithAppleDEPServer(ctx context.Context, depP
// EnrollURL returns an URL that can be used to obtain an MDM enrollment
// profile (xml) from Fleet.
-//
-// TODO: there's a similar function from the PoC that is meant to be removed.
func (d *DEPService) EnrollURL(token string) (string, error) {
appConfig, err := d.ds.AppConfig(context.Background())
if err != nil {
return "", fmt.Errorf("get app config: %w", err)
}
- enrollURL, err := url.Parse(appConfig.ServerSettings.ServerURL)
- if err != nil {
- return "", fmt.Errorf("parse url: %w", err)
- }
- enrollURL.Path = path.Join(enrollURL.Path, EnrollPath)
- q := enrollURL.Query()
- q.Set("token", token)
- enrollURL.RawQuery = q.Encode()
- return enrollURL.String(), nil
+
+ return EnrollURL(token, appConfig)
}
func (d *DEPService) RunAssigner(ctx context.Context) error {
diff --git a/server/mdm/apple/util.go b/server/mdm/apple/util.go
index 87d782a04f..04c8b4d76b 100644
--- a/server/mdm/apple/util.go
+++ b/server/mdm/apple/util.go
@@ -12,6 +12,8 @@ import (
"fmt"
"math"
"math/big"
+ "net/url"
+ "path"
"strings"
"time"
@@ -155,3 +157,15 @@ func FmtErrorChain(chain []mdm.ErrorChain) string {
}
return sb.String()
}
+
+func EnrollURL(token string, appConfig *fleet.AppConfig) (string, error) {
+ enrollURL, err := url.Parse(appConfig.ServerSettings.ServerURL)
+ if err != nil {
+ return "", err
+ }
+ enrollURL.Path = path.Join(enrollURL.Path, EnrollPath)
+ q := enrollURL.Query()
+ q.Set("token", token)
+ enrollURL.RawQuery = q.Encode()
+ return enrollURL.String(), nil
+}
diff --git a/server/mdm/apple/util_test.go b/server/mdm/apple/util_test.go
new file mode 100644
index 0000000000..88c27fe46b
--- /dev/null
+++ b/server/mdm/apple/util_test.go
@@ -0,0 +1,38 @@
+package apple_mdm
+
+import (
+ "testing"
+
+ "github.com/fleetdm/fleet/v4/server/fleet"
+ "github.com/stretchr/testify/require"
+)
+
+func TestMDMAppleEnrollURL(t *testing.T) {
+ cases := []struct {
+ appConfig *fleet.AppConfig
+ expectedURL string
+ }{
+ {
+ appConfig: &fleet.AppConfig{
+ ServerSettings: fleet.ServerSettings{
+ ServerURL: "https://foo.example.com",
+ },
+ },
+ expectedURL: "https://foo.example.com/api/mdm/apple/enroll?token=tok",
+ },
+ {
+ appConfig: &fleet.AppConfig{
+ ServerSettings: fleet.ServerSettings{
+ ServerURL: "https://foo.example.com/",
+ },
+ },
+ expectedURL: "https://foo.example.com/api/mdm/apple/enroll?token=tok",
+ },
+ }
+
+ for _, tt := range cases {
+ enrollURL, err := EnrollURL("tok", tt.appConfig)
+ require.NoError(t, err)
+ require.Equal(t, tt.expectedURL, enrollURL)
+ }
+}
diff --git a/server/service/appconfig.go b/server/service/appconfig.go
index a509360f04..6983887bcf 100644
--- a/server/service/appconfig.go
+++ b/server/service/appconfig.go
@@ -403,6 +403,12 @@ func (svc *Service) ModifyAppConfig(ctx context.Context, p []byte, applyOpts fle
}
}
+ if oldAppConfig.MDM.EndUserAuthentication.SSOProviderSettings != appConfig.MDM.EndUserAuthentication.SSOProviderSettings {
+ if err := svc.EnterpriseOverrides.MDMAppleSyncDEPPRofile(ctx); err != nil {
+ return nil, ctxerr.Wrap(ctx, err, "sync DEP profile")
+ }
+ }
+
if oldAppConfig.MDM.MacOSSetup.BootstrapPackage.Value != appConfig.MDM.MacOSSetup.BootstrapPackage.Value &&
appConfig.MDM.MacOSSetup.BootstrapPackage.Value == "" {
// clear bootstrap package for no team - note that we cannot call
diff --git a/server/service/appconfig_test.go b/server/service/appconfig_test.go
index 43bea1b1bc..ad28940265 100644
--- a/server/service/appconfig_test.go
+++ b/server/service/appconfig_test.go
@@ -17,8 +17,10 @@ import (
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mock"
+ nanodep_mock "github.com/fleetdm/fleet/v4/server/mock/nanodep"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/fleetdm/fleet/v4/server/test"
+ nanodep_client "github.com/micromdm/nanodep/client"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -768,9 +770,21 @@ func TestTransparencyURLDowngradeLicense(t *testing.T) {
func TestMDMAppleConfig(t *testing.T) {
ds := new(mock.Store)
+ depStorage := new(nanodep_mock.Storage)
admin := &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}
+ depSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ switch r.URL.Path {
+ case "/session":
+ _, _ = w.Write([]byte(`{"auth_session_token": "xyz"}`))
+ case "/profile":
+ _, _ = w.Write([]byte(`{"profile_uuid": "xyz"}`))
+ }
+ }))
+ t.Cleanup(depSrv.Close)
+
const licenseErr = "missing or invalid license"
const notFoundErr = "not found"
testCases := []struct {
@@ -906,7 +920,7 @@ func TestMDMAppleConfig(t *testing.T) {
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
- svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: &fleet.LicenseInfo{Tier: tt.licenseTier}})
+ svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: &fleet.LicenseInfo{Tier: tt.licenseTier}, DEPStorage: depStorage})
ctx = viewer.NewContext(ctx, viewer.Viewer{User: admin})
dsAppConfig := &fleet.AppConfig{
@@ -929,6 +943,24 @@ func TestMDMAppleConfig(t *testing.T) {
}
return nil, errors.New(notFoundErr)
}
+ ds.ListMDMAppleEnrollmentProfilesFunc = func(ctx context.Context) ([]*fleet.MDMAppleEnrollmentProfile, error) {
+ return []*fleet.MDMAppleEnrollmentProfile{}, nil
+ }
+ ds.NewMDMAppleEnrollmentProfileFunc = func(ctx context.Context, enrollmentPayload fleet.MDMAppleEnrollmentProfilePayload) (*fleet.MDMAppleEnrollmentProfile, error) {
+ return &fleet.MDMAppleEnrollmentProfile{}, nil
+ }
+
+ depStorage.RetrieveConfigFunc = func(p0 context.Context, p1 string) (*nanodep_client.Config, error) {
+ return &nanodep_client.Config{BaseURL: depSrv.URL}, nil
+ }
+
+ depStorage.RetrieveAuthTokensFunc = func(ctx context.Context, name string) (*nanodep_client.OAuth1Tokens, error) {
+ return &nanodep_client.OAuth1Tokens{}, nil
+ }
+
+ depStorage.StoreAssignerProfileFunc = func(ctx context.Context, name string, profileUUID string) error {
+ return nil
+ }
ac, err := svc.AppConfigObfuscated(ctx)
require.NoError(t, err)
diff --git a/server/service/apple_mdm.go b/server/service/apple_mdm.go
index 337adaac06..d815922de5 100644
--- a/server/service/apple_mdm.go
+++ b/server/service/apple_mdm.go
@@ -10,8 +10,6 @@ import (
"io"
"mime/multipart"
"net/http"
- "net/url"
- "path"
"strconv"
"strings"
"sync"
@@ -28,6 +26,7 @@ import (
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
"github.com/fleetdm/fleet/v4/server/mdm/apple/appmanifest"
"github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig"
+ "github.com/fleetdm/fleet/v4/server/sso"
kitlog "github.com/go-kit/kit/log"
"github.com/go-kit/kit/log/level"
"github.com/go-sql-driver/mysql"
@@ -84,12 +83,19 @@ func (svc *Service) NewMDMAppleEnrollmentProfile(ctx context.Context, enrollment
return nil, ctxerr.Wrap(ctx, err)
}
if profile.DEPProfile != nil {
- if err := svc.setDEPProfile(ctx, profile, appConfig); err != nil {
+ lic, err := svc.License(ctx)
+ if err != nil {
+ return nil, ctxerr.Wrap(ctx, err, "get license")
+ }
+ if !lic.IsPremium() {
+ return nil, fleet.ErrMissingLicense
+ }
+ if err := svc.EnterpriseOverrides.MDMAppleSyncDEPPRofile(ctx); err != nil {
return nil, ctxerr.Wrap(ctx, err)
}
}
- enrollmentURL, err := svc.mdmAppleEnrollURL(profile.Token, appConfig)
+ enrollmentURL, err := apple_mdm.EnrollURL(profile.Token, appConfig)
if err != nil {
return nil, ctxerr.Wrap(ctx, err)
}
@@ -98,52 +104,6 @@ func (svc *Service) NewMDMAppleEnrollmentProfile(ctx context.Context, enrollment
return profile, nil
}
-func (svc *Service) mdmAppleEnrollURL(token string, appConfig *fleet.AppConfig) (string, error) {
- enrollURL, err := url.Parse(appConfig.ServerSettings.ServerURL)
- if err != nil {
- return "", err
- }
- enrollURL.Path = path.Join(enrollURL.Path, apple_mdm.EnrollPath)
- q := enrollURL.Query()
- q.Set("token", token)
- enrollURL.RawQuery = q.Encode()
- return enrollURL.String(), nil
-}
-
-// setDEPProfile define a "DEP profile" on https://mdmenrollment.apple.com and
-// sets the returned Profile UUID as the current DEP profile to apply to newly sync DEP devices.
-func (svc *Service) setDEPProfile(ctx context.Context, enrollmentProfile *fleet.MDMAppleEnrollmentProfile, appConfig *fleet.AppConfig) error {
- var depProfileRequest godep.Profile
- if err := json.Unmarshal(*enrollmentProfile.DEPProfile, &depProfileRequest); err != nil {
- return ctxerr.Wrap(ctx, err, "invalid DEP profile")
- }
-
- enrollURL, err := svc.mdmAppleEnrollURL(enrollmentProfile.Token, appConfig)
- if err != nil {
- return fmt.Errorf("generating enrollment URL: %w", err)
- }
- // Override url with Fleet's enroll path (publicly accessible address).
- depProfileRequest.URL = enrollURL
-
- // If the profile doesn't have a configuration_web_url, use Fleet's
- // enrollURL, otherwise the request for the enrollment profile is
- // submitted as a POST instead of GET.
- if depProfileRequest.ConfigurationWebURL == "" {
- depProfileRequest.ConfigurationWebURL = enrollURL
- }
-
- depClient := apple_mdm.NewDEPClient(svc.depStorage, svc.ds, svc.logger)
- res, err := depClient.DefineProfile(ctx, apple_mdm.DEPName, &depProfileRequest)
- if err != nil {
- return ctxerr.Wrap(ctx, err, "apple POST /profile request failed")
- }
-
- if err := svc.depStorage.StoreAssignerProfile(ctx, apple_mdm.DEPName, res.ProfileUUID); err != nil {
- return ctxerr.Wrap(ctx, err, "set profile UUID")
- }
- return nil
-}
-
type listMDMAppleEnrollmentProfilesRequest struct{}
type listMDMAppleEnrollmentProfilesResponse struct {
@@ -180,7 +140,7 @@ func (svc *Service) ListMDMAppleEnrollmentProfiles(ctx context.Context) ([]*flee
return nil, ctxerr.Wrap(ctx, err)
}
for i := range enrollments {
- enrollURL, err := svc.mdmAppleEnrollURL(enrollments[i].Token, appConfig)
+ enrollURL, err := apple_mdm.EnrollURL(enrollments[i].Token, appConfig)
if err != nil {
return nil, ctxerr.Wrap(ctx, err)
}
@@ -2083,6 +2043,104 @@ func (svc *Service) DeleteMDMAppleSetupAssistant(ctx context.Context, teamID *ui
return fleet.ErrMissingLicense
}
+////////////////////////////////////////////////////////////////////////////////
+// POST /mdm/sso
+////////////////////////////////////////////////////////////////////////////////
+
+type initiateMDMAppleSSORequest struct{}
+
+type initiateMDMAppleSSOResponse struct {
+ URL string `json:"url,omitempty"`
+ Err error `json:"error,omitempty"`
+}
+
+func (r initiateMDMAppleSSOResponse) error() error { return r.Err }
+
+func initiateMDMAppleSSOEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
+ idpProviderURL, err := svc.InitiateMDMAppleSSO(ctx)
+ if err != nil {
+ return initiateMDMAppleSSOResponse{Err: err}, nil
+ }
+
+ return initiateMDMAppleSSOResponse{URL: idpProviderURL}, nil
+}
+
+func (svc *Service) InitiateMDMAppleSSO(ctx context.Context) (string, error) {
+ // skipauth: No authorization check needed due to implementation
+ // returning only license error.
+ svc.authz.SkipAuthorization(ctx)
+
+ return "", fleet.ErrMissingLicense
+}
+
+////////////////////////////////////////////////////////////////////////////////
+// POST /mdm/sso/callback
+////////////////////////////////////////////////////////////////////////////////
+
+type callbackMDMAppleSSORequest struct{}
+
+func (callbackMDMAppleSSORequest) DecodeRequest(ctx context.Context, r *http.Request) (interface{}, error) {
+ err := r.ParseForm()
+ if err != nil {
+ return nil, ctxerr.Wrap(ctx, &fleet.BadRequestError{
+ Message: "failed to parse form",
+ InternalErr: err,
+ }, "decode sso callback")
+ }
+ authResponse, err := sso.DecodeAuthResponse(r.FormValue("SAMLResponse"))
+ if err != nil {
+ return nil, ctxerr.Wrap(ctx, &fleet.BadRequestError{
+ Message: "failed to decode SAMLResponse",
+ InternalErr: err,
+ }, "decoding sso callback")
+ }
+ return authResponse, nil
+}
+
+type callbackMDMAppleSSOResponse struct {
+ Err error `json:"error,omitempty"`
+
+ // used in hijackRender for the response
+ profile []byte
+}
+
+func (r callbackMDMAppleSSOResponse) error() error { return r.Err }
+
+func (r callbackMDMAppleSSOResponse) hijackRender(ctx context.Context, w http.ResponseWriter) {
+ w.Header().Set("Content-Length", strconv.FormatInt(int64(len(r.profile)), 10))
+ w.Header().Set("Content-Type", "application/x-apple-aspen-config")
+ w.Header().Add("Content-Disposition", `attachment; filename="fleet-mdm-enrollment-profile.mobileconfig"`)
+ w.Header().Set("X-Content-Type-Options", "nosniff")
+
+ // OK to just log the error here as writing anything on
+ // `http.ResponseWriter` sets the status code to 200 (and it can't be
+ // changed.) Clients should rely on matching content-length with the
+ // header provided.
+ if n, err := w.Write(r.profile); err != nil {
+ logging.WithExtras(ctx, "err", err, "written", n)
+ }
+}
+
+func callbackMDMAppleSSOEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
+ auth := request.(fleet.Auth)
+
+ // validate that the SSO response is valid
+ profile, err := svc.InitiateMDMAppleSSOCallback(ctx, auth)
+ if err != nil {
+ return callbackMDMAppleSSOResponse{Err: err}, nil
+
+ }
+ return callbackMDMAppleSSOResponse{profile: profile}, nil
+}
+
+func (svc *Service) InitiateMDMAppleSSOCallback(ctx context.Context, auth fleet.Auth) ([]byte, error) {
+ // skipauth: No authorization check needed due to implementation
+ // returning only license error.
+ svc.authz.SkipAuthorization(ctx)
+
+ return nil, fleet.ErrMissingLicense
+}
+
////////////////////////////////////////////////////////////////////////////////
// FileVault-related free version implementation
////////////////////////////////////////////////////////////////////////////////
diff --git a/server/service/apple_mdm_test.go b/server/service/apple_mdm_test.go
index cd9cb0c393..1a082925a6 100644
--- a/server/service/apple_mdm_test.go
+++ b/server/service/apple_mdm_test.go
@@ -422,38 +422,6 @@ func TestAppleMDMAuthorization(t *testing.T) {
})
}
-func TestMDMAppleEnrollURL(t *testing.T) {
- svc := Service{}
-
- cases := []struct {
- appConfig *fleet.AppConfig
- expectedURL string
- }{
- {
- appConfig: &fleet.AppConfig{
- ServerSettings: fleet.ServerSettings{
- ServerURL: "https://foo.example.com",
- },
- },
- expectedURL: "https://foo.example.com/api/mdm/apple/enroll?token=tok",
- },
- {
- appConfig: &fleet.AppConfig{
- ServerSettings: fleet.ServerSettings{
- ServerURL: "https://foo.example.com/",
- },
- },
- expectedURL: "https://foo.example.com/api/mdm/apple/enroll?token=tok",
- },
- }
-
- for _, tt := range cases {
- enrollURL, err := svc.mdmAppleEnrollURL("tok", tt.appConfig)
- require.NoError(t, err)
- require.Equal(t, tt.expectedURL, enrollURL)
- }
-}
-
func TestMDMAppleConfigProfileAuthz(t *testing.T) {
svc, ctx, ds := setupAppleMDMService(t)
diff --git a/server/service/handler.go b/server/service/handler.go
index 449379ebdc..7df6bd1cd9 100644
--- a/server/service/handler.go
+++ b/server/service/handler.go
@@ -619,6 +619,12 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC
ne.WithCustomMiddleware(
errorLimiter.Limit("ping_orbit", desktopQuota),
).HEAD("/api/fleet/orbit/ping", orbitPingEndpoint, orbitPingRequest{})
+
+ neMDM.WithCustomMiddleware(limiter.Limit("login", throttled.RateQuota{MaxRate: loginRateLimit, MaxBurst: 9})).
+ POST("/api/_version_/fleet/mdm/sso", initiateMDMAppleSSOEndpoint, initiateMDMAppleSSORequest{})
+
+ neMDM.WithCustomMiddleware(limiter.Limit("login", throttled.RateQuota{MaxRate: loginRateLimit, MaxBurst: 9})).
+ POST("/api/_version_/fleet/mdm/sso/callback", callbackMDMAppleSSOEndpoint, callbackMDMAppleSSORequest{})
}
func newServer(e endpoint.Endpoint, decodeFn kithttp.DecodeRequestFunc, opts []kithttp.ServerOption) http.Handler {
diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go
index af9efb8f1e..6ccd63f171 100644
--- a/server/service/integration_mdm_test.go
+++ b/server/service/integration_mdm_test.go
@@ -29,6 +29,7 @@ import (
"github.com/fleetdm/fleet/v4/server/config"
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
+ "github.com/fleetdm/fleet/v4/server/datastore/redis/redistest"
"github.com/fleetdm/fleet/v4/server/fleet"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
"github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig"
@@ -109,6 +110,7 @@ func (s *integrationMDMTestSuite) SetupSuite() {
pushFactory,
NewNanoMDMLogger(kitlog.NewJSONLogger(os.Stdout)),
)
+ redisPool := redistest.SetupRedis(s.T(), "zz", false, false, false)
var depSchedule *schedule.Schedule
var profileSchedule *schedule.Schedule
@@ -121,6 +123,7 @@ func (s *integrationMDMTestSuite) SetupSuite() {
DEPStorage: depStorage,
SCEPStorage: scepStorage,
MDMPusher: mdmPushService,
+ Pool: redisPool,
StartCronSchedules: []TestNewScheduleFunc{
func(ctx context.Context, ds fleet.Datastore) fleet.NewCronScheduleFunc {
return func() (fleet.CronSchedule, error) {
@@ -3658,9 +3661,27 @@ func (s *integrationMDMTestSuite) setTokenForTest(t *testing.T, email, password
s.token = s.getCachedUserToken(email, password)
}
-func (s *integrationMDMTestSuite) TestMDMAppleSSO() {
+func (s *integrationMDMTestSuite) TestSSO() {
t := s.T()
+ var lastSubmittedProfile *godep.Profile
+ s.mockDEPResponse(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ switch r.URL.Path {
+ case "/session":
+ _, _ = w.Write([]byte(`{"auth_session_token": "xyz"}`))
+ case "/profile":
+ lastSubmittedProfile = &godep.Profile{}
+ rawProfile, err := io.ReadAll(r.Body)
+ require.NoError(t, err)
+ err = json.Unmarshal(rawProfile, lastSubmittedProfile)
+ require.NoError(t, err)
+ encoder := json.NewEncoder(w)
+ err = encoder.Encode(godep.ProfileResponse{ProfileUUID: "abc"})
+ require.NoError(t, err)
+ }
+ }))
+
// MDM SSO fields are empty by default
acResp := appConfigResponse{}
s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp)
@@ -3691,6 +3712,10 @@ func (s *integrationMDMTestSuite) TestMDMAppleSSO() {
s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp)
assert.Equal(t, wantSettings, acResp.MDM.EndUserAuthentication.SSOProviderSettings)
+ // check that the last submitted DEP profile has been updated accordingly
+ require.Contains(t, lastSubmittedProfile.URL, acResp.ServerSettings.ServerURL+"/api/mdm/apple/enroll?token=")
+ require.Equal(t, acResp.ServerSettings.ServerURL+"/mdm/sso", lastSubmittedProfile.ConfigurationWebURL)
+
// patch without specifying the mdm sso settings fields and an unrelated
// field, should not remove them
acResp = appConfigResponse{}
@@ -3727,4 +3752,42 @@ func (s *integrationMDMTestSuite) TestMDMAppleSSO() {
}
}`), http.StatusOK, &acResp)
assert.Empty(t, acResp.MDM.EndUserAuthentication.SSOProviderSettings)
+ require.Equal(t, lastSubmittedProfile.ConfigurationWebURL, lastSubmittedProfile.URL)
+
+ // set-up valid settings
+ acResp = appConfigResponse{}
+ s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
+ "server_settings": {"server_url": "https://localhost:8080"},
+ "mdm": {
+ "end_user_authentication": {
+ "entity_id": "https://localhost:8080",
+ "issuer_uri": "http://localhost:8080/simplesaml/saml2/idp/SSOService.php",
+ "idp_name": "SimpleSAML",
+ "metadata_url": "http://localhost:9080/simplesaml/saml2/idp/metadata.php"
+ }
+ }
+ }`), http.StatusOK, &acResp)
+ require.Contains(t, lastSubmittedProfile.URL, acResp.ServerSettings.ServerURL+"/api/mdm/apple/enroll?token=")
+ require.Equal(t, acResp.ServerSettings.ServerURL+"/mdm/sso", lastSubmittedProfile.ConfigurationWebURL)
+
+ res := s.LoginMDMSSOUser("sso_user", "user123#")
+
+ body, err := io.ReadAll(res.Body)
+ require.NoError(t, err)
+ defer res.Body.Close()
+ require.Contains(t, res.Header, "Content-Disposition")
+ require.Contains(t, res.Header, "Content-Type")
+ require.Contains(t, res.Header, "X-Content-Type-Options")
+ require.Contains(t, res.Header.Get("Content-Disposition"), "attachment;")
+ require.Contains(t, res.Header.Get("Content-Type"), "application/x-apple-aspen-config")
+ require.Contains(t, res.Header.Get("X-Content-Type-Options"), "nosniff")
+ headerLen, err := strconv.Atoi(res.Header.Get("Content-Length"))
+ require.NoError(t, err)
+ require.Equal(t, len(body), headerLen)
+
+ var profile struct {
+ PayloadIdentifier string `plist:"PayloadIdentifier"`
+ }
+ require.NoError(t, plist.Unmarshal(body, &profile))
+ require.Equal(t, apple_mdm.FleetPayloadIdentifier, profile.PayloadIdentifier)
}
diff --git a/server/service/sessions.go b/server/service/sessions.go
index 7b0a94319c..062adead04 100644
--- a/server/service/sessions.go
+++ b/server/service/sessions.go
@@ -5,9 +5,7 @@ import (
"context"
"crypto/rand"
"encoding/base64"
- "encoding/xml"
"errors"
- "fmt"
"html/template"
"net/http"
"net/url"
@@ -298,10 +296,10 @@ func (svc *Service) InitiateSSO(ctx context.Context, redirectURL string) (string
if !appConfig.SSOSettings.EnableSSO {
err := &fleet.BadRequestError{Message: "organization not configured to use sso"}
- return "", ctxerr.Wrap(ctx, newSSOError(err, ssoOrgDisabled), "callback sso")
+ return "", ctxerr.Wrap(ctx, newSSOError(err, ssoOrgDisabled), "initiate sso")
}
- metadata, err := svc.getMetadata(appConfig)
+ metadata, err := sso.GetMetadata(&appConfig.SSOSettings.SSOProviderSettings)
if err != nil {
return "", ctxerr.Wrap(ctx, err, "InitiateSSO getting metadata")
}
@@ -461,45 +459,30 @@ func (svc *Service) InitSSOCallback(ctx context.Context, auth fleet.Auth) (strin
if appConfig.SSOSettings.EnableSSOIdPLogin && auth.RequestID() == "" {
// Missing request ID indicates this was IdP-initiated. Only allow if
// configured to do so.
- metadata, err = svc.getMetadata(appConfig)
+ metadata, err = sso.GetMetadata(&appConfig.SSOSettings.SSOProviderSettings)
if err != nil {
return "", ctxerr.Wrap(ctx, err, "get sso metadata")
}
redirectURL = "/"
} else {
- session, err := svc.ssoSessionStore.Get(auth.RequestID())
+ var session *sso.Session
+ session, metadata, err = svc.ssoSessionStore.Fullfill(auth.RequestID())
if err != nil {
- return "", ctxerr.Wrap(ctx, err, "sso request invalid")
- }
- // Remove session to so that is can't be reused before it expires.
- err = svc.ssoSessionStore.Expire(auth.RequestID())
- if err != nil {
- return "", ctxerr.Wrap(ctx, err, "remove sso request")
- }
- if err := xml.Unmarshal([]byte(session.Metadata), &metadata); err != nil {
- return "", ctxerr.Wrap(ctx, err, "unmarshal metadata")
+ return "", ctxerr.Wrap(ctx, err, "validate request in session")
}
redirectURL = session.OriginalURL
}
// Validate response
- validator, err := sso.NewValidator(*metadata, sso.WithExpectedAudience(
+ err = sso.ValidateAudiences(
+ *metadata,
+ auth,
appConfig.SSOSettings.EntityID,
appConfig.ServerSettings.ServerURL,
appConfig.ServerSettings.ServerURL+svc.config.Server.URLPrefix+"/api/v1/fleet/sso/callback", // ACS
- ))
+ )
if err != nil {
- return "", ctxerr.Wrap(ctx, err, "create validator from metadata")
- }
- // make sure the response hasn't been tampered with
- auth, err = validator.ValidateSignature(auth)
- if err != nil {
- return "", ctxerr.Wrap(ctx, err, "signature validation failed")
- }
- // make sure the response isn't stale
- err = validator.ValidateResponse(auth)
- if err != nil {
- return "", ctxerr.Wrap(ctx, err, "response validation failed")
+ return "", ctxerr.Wrap(ctx, err, "validating sso response")
}
return redirectURL, nil
@@ -603,26 +586,6 @@ func (svc *Service) makeSession(ctx context.Context, userID uint) (*fleet.Sessio
return session, nil
}
-func (svc *Service) getMetadata(config *fleet.AppConfig) (*sso.Metadata, error) {
- if config.SSOSettings.MetadataURL != "" {
- metadata, err := sso.GetMetadata(config.SSOSettings.MetadataURL)
- if err != nil {
- return nil, err
- }
- return metadata, nil
- }
-
- if config.SSOSettings.Metadata != "" {
- metadata, err := sso.ParseMetadata(config.SSOSettings.Metadata)
- if err != nil {
- return nil, err
- }
- return metadata, nil
- }
-
- return nil, fmt.Errorf("missing metadata for idp %s", config.SSOSettings.IDPName)
-}
-
func (svc *Service) GetSessionByKey(ctx context.Context, key string) (*fleet.Session, error) {
session, err := svc.ds.SessionByKey(ctx, key)
if err != nil {
diff --git a/server/service/testing_client.go b/server/service/testing_client.go
index 6fe40e69d7..efd18ea478 100644
--- a/server/service/testing_client.go
+++ b/server/service/testing_client.go
@@ -280,13 +280,27 @@ func (ts *withServer) getConfig() *appConfigResponse {
func (ts *withServer) LoginSSOUser(username, password string) (fleet.Auth, string) {
t := ts.s.T()
+ auth, res := ts.loginSSOUser(username, password, "/api/v1/fleet/sso")
+ defer res.Body.Close()
+ body, err := io.ReadAll(res.Body)
+ require.NoError(t, err)
+ return auth, string(body)
+}
+
+func (ts *withServer) LoginMDMSSOUser(username, password string) *http.Response {
+ _, res := ts.loginSSOUser(username, password, "/api/v1/fleet/mdm/sso")
+ return res
+}
+
+func (ts *withServer) loginSSOUser(username, password string, basePath string) (fleet.Auth, *http.Response) {
+ t := ts.s.T()
if _, ok := os.LookupEnv("SAML_IDP_TEST"); !ok {
t.Skip("SSO tests are disabled")
}
var resIni initiateSSOResponse
- ts.DoJSON("POST", "/api/v1/fleet/sso", map[string]string{}, http.StatusOK, &resIni)
+ ts.DoJSON("POST", basePath, map[string]string{}, http.StatusOK, &resIni)
jar, err := cookiejar.New(nil)
require.NoError(t, err)
@@ -324,12 +338,9 @@ func (ts *withServer) LoginSSOUser(username, password string) (fleet.Auth, strin
auth, err := sso.DecodeAuthResponse(rawSSOResp)
require.NoError(t, err)
q := url.QueryEscape(rawSSOResp)
- res := ts.DoRawNoAuth("POST", "/api/v1/fleet/sso/callback?SAMLResponse="+q, nil, http.StatusOK)
+ res := ts.DoRawNoAuth("POST", basePath+"/callback?SAMLResponse="+q, nil, http.StatusOK)
- defer res.Body.Close()
- body, err = io.ReadAll(res.Body)
- require.NoError(t, err)
- return auth, string(body)
+ return auth, res
}
// gets the latest activity and checks that it matches any provided properties.
diff --git a/server/service/testing_utils.go b/server/service/testing_utils.go
index 3e255ef517..44ef5afd5d 100644
--- a/server/service/testing_utils.go
+++ b/server/service/testing_utils.go
@@ -21,6 +21,7 @@ import (
"github.com/fleetdm/fleet/v4/server/logging"
"github.com/fleetdm/fleet/v4/server/mail"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
+ nanodep_mock "github.com/fleetdm/fleet/v4/server/mock/nanodep"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/fleetdm/fleet/v4/server/service/async"
"github.com/fleetdm/fleet/v4/server/service/mock"
@@ -59,7 +60,7 @@ func newTestServiceWithConfig(t *testing.T, ds fleet.Datastore, fleetConfig conf
enrollHostLimiter fleet.EnrollHostLimiter = nopEnrollHostLimiter{}
is fleet.InstallerStore
mdmStorage nanomdm_storage.AllStorage
- depStorage nanodep_storage.AllStorage
+ depStorage nanodep_storage.AllStorage = &nanodep_mock.Storage{}
mdmPusher nanomdm_push.Pusher
mailer fleet.MailService = &mockMailService{SendEmailFn: func(e fleet.Email) error { return nil }}
)
@@ -104,8 +105,9 @@ func newTestServiceWithConfig(t *testing.T, ds fleet.Datastore, fleetConfig conf
is = opts[0].Is
// allow to explicitly set MDM storage to nil
mdmStorage = opts[0].MDMStorage
- // allow to explicitly set DEP storage to nil
- depStorage = opts[0].DEPStorage
+ if opts[0].DEPStorage != nil {
+ depStorage = opts[0].DEPStorage
+ }
// allow to explicitly set mdm pusher to nil
mdmPusher = opts[0].MDMPusher
}
@@ -158,6 +160,7 @@ func newTestServiceWithConfig(t *testing.T, ds fleet.Datastore, fleetConfig conf
depStorage,
apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPusher),
"",
+ ssoStore,
)
if err != nil {
panic(err)
diff --git a/server/sso/authorization_request_test.go b/server/sso/authorization_request_test.go
index 3ae76d6187..40e5fc6bca 100644
--- a/server/sso/authorization_request_test.go
+++ b/server/sso/authorization_request_test.go
@@ -86,14 +86,17 @@ func (s *mockStore) create(requestID, originalURL, metadata string, lifetimeSecs
return nil
}
-func (s *mockStore) Get(requestID string) (*Session, error) {
+func (s *mockStore) get(requestID string) (*Session, error) {
if s.session == nil {
return nil, ErrSessionNotFound
}
return s.session, nil
}
-func (s *mockStore) Expire(requestID string) error {
+func (s *mockStore) expire(requestID string) error {
s.session = nil
return nil
}
+func (s *mockStore) Fullfill(requestID string) (*Session, *Metadata, error) {
+ return s.session, &Metadata{}, nil
+}
diff --git a/server/sso/session_store.go b/server/sso/session_store.go
index 72eb344263..437dcb971b 100644
--- a/server/sso/session_store.go
+++ b/server/sso/session_store.go
@@ -3,7 +3,9 @@ package sso
import (
"bytes"
"encoding/json"
+ "encoding/xml"
"errors"
+ "fmt"
"time"
"github.com/fleetdm/fleet/v4/server/datastore/redis"
@@ -30,8 +32,9 @@ type Session struct {
// a reasonable amount of time, it automatically expires and is removed.
type SessionStore interface {
create(requestID, originalURL, metadata string, lifetimeSecs uint) error
- Get(requestID string) (*Session, error)
- Expire(requestID string) error
+ get(requestID string) (*Session, error)
+ expire(requestID string) error
+ Fullfill(requestID string) (*Session, *Metadata, error)
}
// NewSessionStore creates a SessionStore
@@ -59,7 +62,7 @@ func (s *store) create(requestID, originalURL, metadata string, lifetimeSecs uin
return err
}
-func (s *store) Get(requestID string) (*Session, error) {
+func (s *store) get(requestID string) (*Session, error) {
// not reading from a replica here as this gets called in close succession
// in the auth flow, with initiate SSO writing and callback SSO having to
// read that write.
@@ -84,9 +87,29 @@ func (s *store) Get(requestID string) (*Session, error) {
var ErrSessionNotFound = errors.New("session not found")
-func (s *store) Expire(requestID string) error {
+func (s *store) expire(requestID string) error {
conn := redis.ConfigureDoer(s.pool, s.pool.Get())
defer conn.Close()
_, err := conn.Do("DEL", requestID)
return err
}
+
+func (s *store) Fullfill(requestID string) (*Session, *Metadata, error) {
+ session, err := s.get(requestID)
+ if err != nil {
+ return nil, nil, fmt.Errorf("sso request invalid: %w", err)
+ }
+
+ // Remove session so that it can't be reused before it expires.
+ err = s.expire(requestID)
+ if err != nil {
+ return nil, nil, fmt.Errorf("remove sso request: %w", err)
+ }
+
+ var metadata *Metadata
+ if err := xml.Unmarshal([]byte(session.Metadata), &metadata); err != nil {
+ return nil, nil, fmt.Errorf("unmarshal sso request metadata: %w", err)
+ }
+
+ return session, metadata, nil
+}
diff --git a/server/sso/session_store_test.go b/server/sso/session_store_test.go
index 0e7575a832..d72b8f0287 100644
--- a/server/sso/session_store_test.go
+++ b/server/sso/session_store_test.go
@@ -18,7 +18,7 @@ func TestSessionStore(t *testing.T) {
err := store.create("request123", "https://originalurl.com", "some metadata", 1)
require.NoError(t, err)
- sess, err := store.Get("request123")
+ sess, err := store.get("request123")
require.NoError(t, err)
require.NotNil(t, sess)
assert.Equal(t, "https://originalurl.com", sess.OriginalURL)
@@ -26,7 +26,7 @@ func TestSessionStore(t *testing.T) {
// Wait a little bit more than one second, session should no longer be present.
time.Sleep(1100 * time.Millisecond)
- sess, err = store.Get("request123")
+ sess, err = store.get("request123")
assert.Equal(t, ErrSessionNotFound, err)
assert.Nil(t, sess)
@@ -35,16 +35,16 @@ func TestSessionStore(t *testing.T) {
require.NoError(t, err)
// Forcefully expire it
- err = store.Expire("request456")
+ err = store.expire("request456")
require.NoError(t, err)
// It is not present anymore
- sess, err = store.Get("request456")
+ sess, err = store.get("request456")
assert.Equal(t, ErrSessionNotFound, err)
assert.Nil(t, sess)
// Expire a session that does not exist is fine
- err = store.Expire("requestNOSUCH")
+ err = store.expire("requestNOSUCH")
require.NoError(t, err)
}
diff --git a/server/sso/settings.go b/server/sso/settings.go
index 98dcdca29e..6cf0ba373b 100644
--- a/server/sso/settings.go
+++ b/server/sso/settings.go
@@ -8,6 +8,7 @@ import (
"time"
"github.com/fleetdm/fleet/v4/pkg/fleethttp"
+ "github.com/fleetdm/fleet/v4/server/fleet"
dsigtypes "github.com/russellhaering/goxmldsig/types"
)
@@ -56,8 +57,28 @@ type Settings struct {
OriginalURL string
}
-// ParseMetadata writes metadata xml to a struct
-func ParseMetadata(metadata string) (*Metadata, error) {
+func GetMetadata(config *fleet.SSOProviderSettings) (*Metadata, error) {
+ if config.MetadataURL != "" {
+ metadata, err := getMetadata(config.MetadataURL)
+ if err != nil {
+ return nil, err
+ }
+ return metadata, nil
+ }
+
+ if config.Metadata != "" {
+ metadata, err := parseMetadata(config.Metadata)
+ if err != nil {
+ return nil, err
+ }
+ return metadata, nil
+ }
+
+ return nil, fmt.Errorf("missing metadata for idp %s", config.IDPName)
+}
+
+// parseMetadata writes metadata xml to a struct
+func parseMetadata(metadata string) (*Metadata, error) {
var md Metadata
err := xml.Unmarshal([]byte(metadata), &md)
if err != nil {
@@ -66,11 +87,11 @@ func ParseMetadata(metadata string) (*Metadata, error) {
return &md, nil
}
-// GetMetadata retrieves information describing how to interact with a particular
+// getMetadata retrieves information describing how to interact with a particular
// IDP via a remote URL. metadataURL is the location where the metadata is located
// and timeout defines how long to wait to get a response form the metadata
// server.
-func GetMetadata(metadataURL string) (*Metadata, error) {
+func getMetadata(metadataURL string) (*Metadata, error) {
client := fleethttp.NewClient(fleethttp.WithTimeout(5 * time.Second))
request, err := http.NewRequest(http.MethodGet, metadataURL, nil)
if err != nil {
diff --git a/server/sso/settings_test.go b/server/sso/settings_test.go
index 2ba73c2fc1..7bc2fe6b07 100644
--- a/server/sso/settings_test.go
+++ b/server/sso/settings_test.go
@@ -43,7 +43,7 @@ rICQDchR6/cxoQCkoyf+/YTpY492MafV
`
func TestParseMetadata(t *testing.T) {
- settings, err := ParseMetadata(metadata)
+ settings, err := parseMetadata(metadata)
require.Nil(t, err)
assert.Equal(t, "http://www.okta.com/exka4zkf6dxm8pF220h7", settings.EntityID)
@@ -61,7 +61,7 @@ func TestGetMetadata(t *testing.T) {
_, err := w.Write([]byte(metadata))
require.NoError(t, err)
}))
- settings, err := GetMetadata(ts.URL)
+ settings, err := getMetadata(ts.URL)
require.Nil(t, err)
assert.Equal(t, "http://www.okta.com/exka4zkf6dxm8pF220h7", settings.EntityID)
assert.Len(t, settings.IDPSSODescriptor.NameIDFormats, 2)
diff --git a/server/sso/validate.go b/server/sso/validate.go
index ec366c5221..30b2190f90 100644
--- a/server/sso/validate.go
+++ b/server/sso/validate.go
@@ -218,3 +218,23 @@ func generateSAMLValidID() (string, error) {
}
return idPrefix + string(randomBytes), nil
}
+
+func ValidateAudiences(metadata Metadata, auth fleet.Auth, audiences ...string) error {
+ validator, err := NewValidator(metadata, WithExpectedAudience(audiences...))
+
+ if err != nil {
+ return fmt.Errorf("create validator from metadata: %w", err)
+ }
+ // make sure the response hasn't been tampered with
+ auth, err = validator.ValidateSignature(auth)
+ if err != nil {
+ return fmt.Errorf("signature validation failed: %w", err)
+ }
+ // make sure the response isn't stale
+ err = validator.ValidateResponse(auth)
+ if err != nil {
+ return fmt.Errorf("response validation failed: %w", err)
+ }
+
+ return nil
+}
diff --git a/tools/saml/config.php b/tools/saml/config.php
new file mode 100644
index 0000000000..37e63e7431
--- /dev/null
+++ b/tools/saml/config.php
@@ -0,0 +1,11 @@
+ [
+ 'https://localhost:8080/api/v1/fleet/sso/callback',
+ 'https://localhost:8080/api/v1/fleet/mdm/sso/callback',
+ ],
+ 'NameIDFormat' => 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddres',
+ 'simplesaml.nameidattribute' => 'email',
+);
+