gate DEP enrollment behind SSO when configured (#11309)
#10739 Co-authored-by: Gabriel Hernandez <ghernandez345@gmail.com> Co-authored-by: gillespi314 <73313222+gillespi314@users.noreply.github.com>
This commit is contained in:
co-authored by
Gabriel Hernandez
gillespi314
parent
7dadec3ecf
commit
a23d208b1d
@@ -0,0 +1 @@
|
||||
* Added functionality to gate Apple MDM login behind SAML authentication.
|
||||
@@ -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")
|
||||
|
||||
+1
-3
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<div className={`${baseClass}`}>
|
||||
<div className={classes}>
|
||||
<div className={`${baseClass}__${card ? "card" : "inner"}`}>
|
||||
<div className="info">
|
||||
<span className="info__header">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -110,3 +110,7 @@ export interface IMdmScript {
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface IMdmSSOReponse {
|
||||
url: string;
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<DataError className={`${baseClass}__sso-error`}>
|
||||
<p>Please contact your IT admin at +1-(415)-651-2575.</p>
|
||||
</DataError>
|
||||
);
|
||||
};
|
||||
|
||||
const DEPSSOLoginPage = () => {
|
||||
const { error } = useQuery<void, AxiosError, IMdmSSOReponse>(
|
||||
["dep_sso"],
|
||||
() => mdmAPI.initiateMDMAppleSSO(),
|
||||
{
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
onSuccess: ({ url }) => {
|
||||
window.location.href = url;
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return <div className={baseClass}>{error ? <SSOError /> : <Spinner />}</div>;
|
||||
};
|
||||
|
||||
export default DEPSSOLoginPage;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "./MDMAppleSSOPage";
|
||||
@@ -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 = (
|
||||
/>
|
||||
<Route path="login/forgot" component={ForgotPasswordPage} />
|
||||
<Route path="login/reset" component={ResetPasswordPage} />
|
||||
<Route path="mdm/sso" component={MDMAppleSSOPage} />
|
||||
</Route>
|
||||
</Route>
|
||||
<Route component={AuthenticatedRoutes as RouteComponent}>
|
||||
|
||||
@@ -98,4 +98,9 @@ export default {
|
||||
team_id: teamId,
|
||||
});
|
||||
},
|
||||
|
||||
initiateMDMAppleSSO: () => {
|
||||
const { MDM_APPLE_SSO } = endpoints;
|
||||
return sendRequest("POST", MDM_APPLE_SSO, {});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
+109
-51
@@ -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
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+11
-48
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+25
-4
@@ -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 {
|
||||
|
||||
@@ -43,7 +43,7 @@ rICQDchR6/cxoQCkoyf+/YTpY492MafV</ds:X509Certificate>
|
||||
`
|
||||
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
$metadata['https://localhost:8080'] = array(
|
||||
'AssertionConsumerService' => [
|
||||
'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',
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user