From a23d208b1df33675b561b8cddfec973e0644274d Mon Sep 17 00:00:00 2001 From: Roberto Dip Date: Thu, 27 Apr 2023 09:43:20 -0300 Subject: [PATCH] gate DEP enrollment behind SSO when configured (#11309) #10739 Co-authored-by: Gabriel Hernandez Co-authored-by: gillespi314 <73313222+gillespi314@users.noreply.github.com> --- changes/sso-idp-mdm | 1 + cmd/fleet/serve.go | 1 + docker-compose.yml | 4 +- .../Testing-and-local-development.md | 15 ++ ee/server/service/mdm.go | 126 ++++++++++++++ ee/server/service/service.go | 8 + frontend/components/DataError/DataError.tsx | 12 +- frontend/components/Spinner/_styles.scss | 3 - frontend/interfaces/mdm.ts | 4 + .../pages/MDMAppleSSOPage/MDMAppleSSOPage.tsx | 37 ++++ frontend/pages/MDMAppleSSOPage/_styles.scss | 14 ++ frontend/pages/MDMAppleSSOPage/index.ts | 1 + frontend/router/index.tsx | 2 + frontend/services/entities/mdm.ts | 5 + frontend/utilities/endpoints.ts | 1 + server/datastore/mysql/apple_mdm.go | 1 + server/fleet/app.go | 4 + server/fleet/app_test.go | 5 + server/fleet/service.go | 13 ++ server/mdm/apple/apple_mdm.go | 37 ++-- server/mdm/apple/util.go | 14 ++ server/mdm/apple/util_test.go | 38 +++++ server/service/appconfig.go | 6 + server/service/appconfig_test.go | 34 +++- server/service/apple_mdm.go | 160 ++++++++++++------ server/service/apple_mdm_test.go | 32 ---- server/service/handler.go | 6 + server/service/integration_mdm_test.go | 65 ++++++- server/service/sessions.go | 59 ++----- server/service/testing_client.go | 23 ++- server/service/testing_utils.go | 9 +- server/sso/authorization_request_test.go | 7 +- server/sso/session_store.go | 31 +++- server/sso/session_store_test.go | 10 +- server/sso/settings.go | 29 +++- server/sso/settings_test.go | 4 +- server/sso/validate.go | 20 +++ tools/saml/config.php | 11 ++ 38 files changed, 667 insertions(+), 185 deletions(-) create mode 100644 changes/sso-idp-mdm create mode 100644 frontend/pages/MDMAppleSSOPage/MDMAppleSSOPage.tsx create mode 100644 frontend/pages/MDMAppleSSOPage/_styles.scss create mode 100644 frontend/pages/MDMAppleSSOPage/index.ts create mode 100644 server/mdm/apple/util_test.go create mode 100644 tools/saml/config.php 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', +); +