diff --git a/changes/11477-eula-support b/changes/11477-eula-support new file mode 100644 index 0000000000..79bb5ea7f1 --- /dev/null +++ b/changes/11477-eula-support @@ -0,0 +1 @@ +* Added support to add an EULA as part of the AEP/DEP unboxing flow. diff --git a/docs/Contributing/API-for-contributors.md b/docs/Contributing/API-for-contributors.md index d4256bcaa8..e28fe423ac 100644 --- a/docs/Contributing/API-for-contributors.md +++ b/docs/Contributing/API-for-contributors.md @@ -527,7 +527,8 @@ The MDM endpoints exist to support the related command-line interface sub-comman - [Generate Apple DEP Key Pair](#generate-apple-dep-key-pair) - [Request Certificate Signing Request (CSR)](#request-certificate-signing-request-csr) - [Batch-apply Apple MDM custom settings](#batch-apply-apple-mdm-custom-settings) -- [Download an enrollment profile using IdP authentication](#download-an-enrollment-profile-using-idp-authentication) +- [Initiate SSO during DEP enrollment](#initiate-sso-during-dep-enrollment) +- [Complete SSO during DEP enrollment](#complete-sso-during-dep-enrollment) ### Generate Apple DEP Key Pair @@ -602,6 +603,61 @@ If no team (id or name) is provided, the profiles are applied for all hosts (for `204` +### Initiate SSO during DEP enrollment + +This endpoint initiates the SSO flow, the response contains an URL that the client can use to redirect the user to initiate the SSO flow in the configured IdP. + +`POST /api/v1/fleet/mdm/sso` + +#### Parameters + +None. + +#### Example + +`POST /api/v1/fleet/mdm/sso` + +##### Default response + +``` +{ + "url": "https://idp-provider.com/saml?SAMLRequest=...", +} +``` + +### Complete SSO during DEP enrollment + +This is the callback endpoint that the identity provider will use to send security assertions to Fleet. This is where Fleet receives and processes the response from the identify provider. + +`POST /api/v1/fleet/mdm/sso/callback` + +#### Parameters + +| Name | Type | In | Description | +| ------------ | ------ | ---- | ----------------------------------------------------------- | +| SAMLResponse | string | body | **Required**. The SAML response from the identity provider. | + +#### Example + +`POST /api/v1/fleet/mdm/sso/callback` + +##### Request body + +```json +{ + "SAMLResponse": "" +} +``` + +##### Default response + +`Status: 302` + +If the credentials are valid, the server redirects the client to the Fleet UI. The URL contains the following query parameters that can be used to complete the DEP enrollment flow: + +- `profile_token` is a token that can be used to download an enrollment profile (.mobileconfig). +- `eula_token` (optional) if an EULA was uploaded, this contains a token that can be used to view the EULA document. + ## Get or apply configuration files These API routes are used by the `fleetctl` CLI tool. Users can manage Fleet with `fleetctl` and [configuration files in YAML syntax](https://fleetdm.com/docs/using-fleet/configuration-files/). diff --git a/ee/server/service/mdm.go b/ee/server/service/mdm.go index c9bbff9625..65ebb2c27f 100644 --- a/ee/server/service/mdm.go +++ b/ee/server/service/mdm.go @@ -8,6 +8,7 @@ import ( "encoding/json" "errors" "io" + "net/url" "github.com/fleetdm/fleet/v4/pkg/file" "github.com/fleetdm/fleet/v4/server/authz" @@ -508,7 +509,7 @@ func (svc *Service) InitiateMDMAppleSSO(ctx context.Context) (string, error) { } -func (svc *Service) InitiateMDMAppleSSOCallback(ctx context.Context, auth fleet.Auth) ([]byte, error) { +func (svc *Service) InitiateMDMAppleSSOCallback(ctx context.Context, auth fleet.Auth) (string, error) { // skipauth: User context does not yet exist. Unauthenticated users may // hit the SSO callback. svc.authz.SkipAuthorization(ctx) @@ -517,12 +518,12 @@ func (svc *Service) InitiateMDMAppleSSOCallback(ctx context.Context, auth fleet. appConfig, err := svc.ds.AppConfig(ctx) if err != nil { - return nil, ctxerr.Wrap(ctx, err, "get config for sso") + return "", 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") + return "", ctxerr.Wrap(ctx, err, "validate request in session") } err = sso.ValidateAudiences( @@ -534,35 +535,35 @@ func (svc *Service) InitiateMDMAppleSSOCallback(ctx context.Context, auth fleet. ) if err != nil { - return nil, ctxerr.Wrap(ctx, err, "validating sso response") + return "", ctxerr.Wrap(ctx, err, "validating sso response") } - return apple_mdm.GenerateEnrollmentProfileMobileconfig( - appConfig.OrgInfo.OrgName, - appConfig.ServerSettings.ServerURL, - svc.config.MDM.AppleSCEPChallenge, - svc.mdmPushCertTopic, - ) + eula, err := svc.ds.MDMAppleGetEULAMetadata(ctx) + if err != nil && !fleet.IsNotFound(err) { + return "", ctxerr.Wrap(ctx, err, "getting EULA metadata") + } + + depProf, err := svc.getAutomaticEnrollmentProfile(ctx) + if err != nil { + return "", ctxerr.Wrap(ctx, err, "listing profiles") + } + + if depProf == nil { + return "", ctxerr.Wrap(ctx, err, "missing profile") + } + + q := url.Values{"profile_token": {depProf.Token}} + if eula != nil { + q.Add("eula_token", eula.Token) + } + + return appConfig.ServerSettings.ServerURL + "/mdm/sso/callback?" + q.Encode(), nil } func (svc *Service) mdmAppleSyncDEPProfile(ctx context.Context) error { - profiles, err := svc.ds.ListMDMAppleEnrollmentProfiles(ctx) + depProf, err := svc.getAutomaticEnrollmentProfile(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 - } + return ctxerr.Wrap(ctx, err, "fetching enrollment profile") } if depProf == nil { @@ -586,3 +587,26 @@ func (svc *Service) mdmAppleSyncDEPProfile(ctx context.Context) error { return svc.depService.RegisterProfileWithAppleDEPServer(ctx, jsonProf, enrollURL) } + +func (svc *Service) getAutomaticEnrollmentProfile(ctx context.Context) (*fleet.MDMAppleEnrollmentProfile, error) { + profiles, err := svc.ds.ListMDMAppleEnrollmentProfiles(ctx) + if err != nil { + return nil, 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 + } + } + + return depProf, nil +} diff --git a/frontend/components/MDM/SSOError/SSOError.tsx b/frontend/components/MDM/SSOError/SSOError.tsx new file mode 100644 index 0000000000..f0fece9ef6 --- /dev/null +++ b/frontend/components/MDM/SSOError/SSOError.tsx @@ -0,0 +1,22 @@ +import React from "react"; +import classnames from "classnames"; + +import DataError from "components/DataError"; + +const baseClass = "mdm-sso-error"; + +interface ISSOErrorProps { + className?: string; +} + +const SSOError = ({ className }: ISSOErrorProps) => { + const classNames = classnames(baseClass, className); + + return ( + +

Please contact your IT admin at +1-(415)-651-2575.

+
+ ); +}; + +export default SSOError; diff --git a/frontend/components/MDM/SSOError/_styles.scss b/frontend/components/MDM/SSOError/_styles.scss new file mode 100644 index 0000000000..ca0b069ec9 --- /dev/null +++ b/frontend/components/MDM/SSOError/_styles.scss @@ -0,0 +1,6 @@ +.mdm-sso-error { + p { + font-size: $x-small; + margin: 12px 0 0; + } +} diff --git a/frontend/components/MDM/SSOError/index.ts b/frontend/components/MDM/SSOError/index.ts new file mode 100644 index 0000000000..cb02c2a0a6 --- /dev/null +++ b/frontend/components/MDM/SSOError/index.ts @@ -0,0 +1 @@ +export { default } from "./SSOError"; diff --git a/frontend/components/buttons/Button/Button.stories.tsx b/frontend/components/buttons/Button/Button.stories.tsx index 22ef4ac153..9d096a6d99 100644 --- a/frontend/components/buttons/Button/Button.stories.tsx +++ b/frontend/components/buttons/Button/Button.stories.tsx @@ -30,6 +30,7 @@ export default { "unstyled-modal-query", "contextual-nav-item", "small-text-icon", + "oversized", ], control: "select", }, diff --git a/frontend/components/buttons/Button/Button.tsx b/frontend/components/buttons/Button/Button.tsx index df868219e6..c0ebb0c852 100644 --- a/frontend/components/buttons/Button/Button.tsx +++ b/frontend/components/buttons/Button/Button.tsx @@ -21,7 +21,8 @@ export type ButtonVariant = | "unstyled" | "unstyled-modal-query" | "contextual-nav-item" - | "small-text-icon"; + | "small-text-icon" + | "oversized"; export interface IButtonProps { autofocus?: boolean; diff --git a/frontend/components/buttons/Button/_styles.scss b/frontend/components/buttons/Button/_styles.scss index f188e2a581..afe35fcaf4 100644 --- a/frontend/components/buttons/Button/_styles.scss +++ b/frontend/components/buttons/Button/_styles.scss @@ -377,4 +377,11 @@ $base-class: "button"; display: flex; justify-content: space-between; } + + &--oversized { + background-color: $core-fleet-black; + padding: $pad-large $pad-small; + font-size: $medium; + width: 100%; + } } diff --git a/frontend/pages/MDMAppleSSOCallbackPage/MDMAppleSSOCallbackPage.tsx b/frontend/pages/MDMAppleSSOCallbackPage/MDMAppleSSOCallbackPage.tsx new file mode 100644 index 0000000000..496c5f2b82 --- /dev/null +++ b/frontend/pages/MDMAppleSSOCallbackPage/MDMAppleSSOCallbackPage.tsx @@ -0,0 +1,70 @@ +import React, { useState } from "react"; +import { WithRouterProps } from "react-router"; + +import endpoints from "utilities/endpoints"; + +import Spinner from "components/Spinner/Spinner"; +import SSOError from "components/MDM/SSOError"; +import Button from "components/buttons/Button"; + +const baseClass = "mdm-apple-sso-callback-page"; + +const RedirectTo = ({ url }: { url: string }) => { + window.location.href = url; + return ; +}; + +interface IEnrollmentGateProps { + profileToken?: string; + eulaToken?: string; +} + +const EnrollmentGate = ({ profileToken, eulaToken }: IEnrollmentGateProps) => { + const [showEULA, setShowEULA] = useState(Boolean(eulaToken)); + + if (!profileToken) { + return ; + } + + if (showEULA && eulaToken) { + return ( +
+

Terms and conditions

+