mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 13:37:30 +00:00
End-user authentication for Window/Linux setup experience: backend (#34835)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves #34529 # Details This PR implements the backend (and some related front-end screens) for allowing Fleet admins to require that users authenticate with an IdP prior to having their devices set up. I'll comment on changes inline but the high-level for the device enrollment flow is: 1. The handler for the `/orbit/enroll` endpoint now checks whether the end-user authentication is required for the team (or globally, if using the global enroll secret). 2. If so, it checks whether a `host_mdm_idp_accounts` row exists with a `host_uuid` matching the identifier sent with the request 3. If a row exists, enroll. If not, return back a new flavor of `OrbitError` with a `401` status code and a message (`END_USER_AUTH_REQUIRED`) that Orbit can interpret and act accordingly. Additionally some changes were made to the MDM SSO flow. Namely, adding more data to the session we store for correlating requests we make to the IdP to initiate SSO to responses aimed at our callback. We now store a `RequestData` struct which contains the UUID of the device making the request, as well as the "initiator" (in this case, "setup_experience"). When our SSO callback detects that the initiator was the setup experience, it attempts to add all of the relevant records to our database to associate the host with an IdP account. This removes the enrollment gate in the `/orbit/enroll` endpoint. # Checklist for submitter If some of the following don't apply, delete the relevant line. - [ ] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files) for more information. Will put the changelog in the last ticket for the story - [X] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements) ## Testing - [X] Added/updated automated tests will see if there's any more to update - [X] QA'd all new/changed functionality manually To test w/ SimpleSAML 1. Log in to your local Fleet dashboard with MDM and IdP set up for SimpleSAML 1. Go to Settings -> Integrations -> Identity provider 2. Use "SimpleSAML" for the provider name 3. Use `mdm.test.com` for the entity ID 4. Use `http://127.0.0.1:9080/simplesaml/saml2/idp/metadata.php` for the metadata URL 1. Set up a team (or "no team") to have End User Authentication required (Controls -> Setup experience) 1. Get the enroll secret of that team 1. In the browser console, do: ``` fetch("https://localhost:8080/api/fleet/orbit/enroll", { "headers": { "accept": "application/json, text/plain, */*", "cache-control": "no-cache", "content-type": "application/json", "pragma": "no-cache", }, "body": "{\"enroll_secret\":\"<enroll secret>", \"hardware_uuid\":\"abc123\" }", "method": "POST", }); ``` replacing `<enroll secret>` with your team's enroll secret. 8. Verify in the network tab that you get a 401 error with message `END_USER_AUTH_REQUIRED` 1. Go to https://localhost:8080/mdm/sso?initiator=setup_experience&host_uuid=abc123 1. Verify that a new screen appears asking you to log in to your IdP 1. Log in to SimpleSAML with `sso_user / user123#` 1. Verify that you're taken to a success screen 1. In your database, verify that records exist in the `mdm_idp_accounts` and `host_mdm_idp_accounts` tables with uuid `abc123` 1. Try the `fetch` command in the browser console again, verify that it succeeds. ## fleetd/orbit/Fleet Desktop - [ ] Verified compatibility with the latest released version of Fleet (see [Must rule](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/workflows/fleetd-development-and-release-strategy.md)) This is _not_ compatible with the current version of fleetd or the soon-to-be-released 1.49.x. Until #34847 changes are released in fleetd, this will need to be put behind a feature flag or withheld from Fleet releases. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit ## Release Notes * **New Features** * Added support for device UUID linkage during MDM enrollment to enable host-initiated enrollment tracking * Introduced setup experience flow for device authentication during enrollment * Added end-user authentication requirement configuration for macOS MDM enrollment * **Improvements** * Enhanced MDM enrollment process to maintain device context through authentication * Updated authentication UI to display completion status for device setup flows * Refined form layout styling for improved visual consistency <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
parent
8ccdbe05f1
commit
d4271986e0
21 changed files with 218 additions and 42 deletions
|
|
@ -712,7 +712,7 @@ func (svc *Service) DeleteMDMAppleSetupAssistant(ctx context.Context, teamID *ui
|
|||
|
||||
const appleMDMAccountDrivenEnrollmentUrl = "/api/mdm/apple/account_driven_enroll"
|
||||
|
||||
func (svc *Service) InitiateMDMSSO(ctx context.Context, initiator, customOriginalURL string) (sessionID string, sessionDurationSeconds int, idpURL string, err error) {
|
||||
func (svc *Service) InitiateMDMSSO(ctx context.Context, initiator, customOriginalURL string, hostUUID string) (sessionID string, sessionDurationSeconds int, idpURL string, err error) {
|
||||
// skipauth: User context does not yet exist. Unauthenticated users may
|
||||
// initiate SSO.
|
||||
svc.authz.SkipAuthorization(ctx)
|
||||
|
|
@ -771,6 +771,10 @@ func (svc *Service) InitiateMDMSSO(ctx context.Context, initiator, customOrigina
|
|||
sessionID, idpURL, err = sso.CreateAuthorizationRequest(ctx,
|
||||
samlProvider, svc.ssoSessionStore, originalURL,
|
||||
uint(sessionDurationSeconds), //nolint:gosec // dismiss G115
|
||||
sso.SSORequestData{
|
||||
HostUUID: hostUUID,
|
||||
Initiator: initiator,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return "", 0, "", ctxerr.Wrap(ctx, err, "InitiateMDMSSO creating authorization")
|
||||
|
|
@ -786,13 +790,13 @@ func (svc *Service) MDMSSOCallback(ctx context.Context, sessionID string, samlRe
|
|||
|
||||
logging.WithLevel(logging.WithNoUser(ctx), level.Info)
|
||||
|
||||
profileToken, enrollmentRef, eulaToken, originalURL, err := svc.mdmSSOHandleCallbackAuth(ctx, sessionID, samlResponse)
|
||||
profileToken, enrollmentRef, eulaToken, originalURL, ssoRequestData, err := svc.mdmSSOHandleCallbackAuth(ctx, sessionID, samlResponse)
|
||||
if err != nil {
|
||||
logging.WithErr(ctx, err)
|
||||
return apple_mdm.FleetUISSOCallbackPath + "?error=true", ""
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(originalURL, "/enroll?") {
|
||||
if !strings.HasPrefix(originalURL, "/enroll?") && ssoRequestData.Initiator != "setup_experience" {
|
||||
// for flows other than the /enroll BYOD, we have to ensure that Apple MDM
|
||||
// is enabled (this was previously done in a middleware on the route, but
|
||||
// we do it here now so the middleware is disabled for the BYOD flow, which
|
||||
|
|
@ -812,6 +816,8 @@ func (svc *Service) MDMSSOCallback(ctx context.Context, sessionID string, samlRe
|
|||
q.Add("eula_token", eulaToken)
|
||||
}
|
||||
|
||||
q.Add("initiator", ssoRequestData.Initiator)
|
||||
|
||||
switch {
|
||||
case originalURL == appleMDMAccountDrivenEnrollmentUrl:
|
||||
// For account driven enrollment we have to use this special protocol URL scheme to pass the
|
||||
|
|
@ -845,17 +851,17 @@ func (svc *Service) mdmSSOHandleCallbackAuth(
|
|||
sessionID string,
|
||||
samlResponse []byte,
|
||||
) (profileToken string, enrollmentReference string,
|
||||
eulaToken string, originalURL string, err error,
|
||||
eulaToken string, originalURL string, ssoRequestData sso.SSORequestData, err error,
|
||||
) {
|
||||
appConfig, err := svc.ds.AppConfig(ctx)
|
||||
if err != nil {
|
||||
return "", "", "", "", ctxerr.Wrap(ctx, err, "get config for sso")
|
||||
return "", "", "", "", sso.SSORequestData{}, ctxerr.Wrap(ctx, err, "get config for sso")
|
||||
}
|
||||
|
||||
serverURL := appConfig.MDMUrl()
|
||||
acsURL, err := url.Parse(serverURL + svc.config.Server.URLPrefix + "/api/v1/fleet/mdm/sso/callback")
|
||||
if err != nil {
|
||||
return "", "", "", "", ctxerr.Wrap(ctx, err, "failed to parse ACS URL")
|
||||
return "", "", "", "", sso.SSORequestData{}, ctxerr.Wrap(ctx, err, "failed to parse ACS URL")
|
||||
}
|
||||
|
||||
mdmSSOSettings := appConfig.MDM.EndUserAuthentication.SSOProviderSettings
|
||||
|
|
@ -866,7 +872,7 @@ func (svc *Service) mdmSSOHandleCallbackAuth(
|
|||
// this means some teams may not use SSO even if it is configured.
|
||||
if mdmSSOSettings.IsEmpty() {
|
||||
err := &fleet.BadRequestError{Message: "organization not configured to use sso"}
|
||||
return "", "", "", "", ctxerr.Wrap(ctx, err, "get config for mdm sso callback")
|
||||
return "", "", "", "", sso.SSORequestData{}, ctxerr.Wrap(ctx, err, "get config for mdm sso callback")
|
||||
}
|
||||
|
||||
expectedAudiences := []string{
|
||||
|
|
@ -874,11 +880,11 @@ func (svc *Service) mdmSSOHandleCallbackAuth(
|
|||
appConfig.MDMUrl(),
|
||||
appConfig.MDMUrl() + svc.config.Server.URLPrefix + "/api/v1/fleet/mdm/sso/callback",
|
||||
}
|
||||
samlProvider, requestID, originalURL, err := sso.SAMLProviderFromSession(
|
||||
samlProvider, requestID, originalURL, ssoRequestData, err := sso.SAMLProviderFromSession(
|
||||
ctx, sessionID, svc.ssoSessionStore, acsURL, mdmSSOSettings.EntityID, expectedAudiences,
|
||||
)
|
||||
if err != nil {
|
||||
return "", "", "", "", ctxerr.Wrap(ctx, err, "failed to create provider from metadata")
|
||||
return "", "", "", "", sso.SSORequestData{}, ctxerr.Wrap(ctx, err, "failed to create provider from metadata")
|
||||
}
|
||||
|
||||
// Parse and verify SAMLResponse (verifies fields, expected IDs and signature).
|
||||
|
|
@ -886,7 +892,7 @@ func (svc *Service) mdmSSOHandleCallbackAuth(
|
|||
if err != nil {
|
||||
// We actually don't return 401 to clients and instead return an HTML page with /login?status=error,
|
||||
// but to be consistent we will return fleet.AuthFailedError which is used for unauthorized access.
|
||||
return "", "", "", "", ctxerr.Wrap(ctx, fleet.NewAuthFailedError(err.Error()))
|
||||
return "", "", "", "", sso.SSORequestData{}, ctxerr.Wrap(ctx, fleet.NewAuthFailedError(err.Error()))
|
||||
}
|
||||
|
||||
// Store information for automatic account population/creation
|
||||
|
|
@ -902,12 +908,13 @@ func (svc *Service) mdmSSOHandleCallbackAuth(
|
|||
}
|
||||
|
||||
err = svc.ds.InsertMDMIdPAccount(ctx, &fleet.MDMIdPAccount{
|
||||
UUID: ssoRequestData.HostUUID,
|
||||
Username: username,
|
||||
Fullname: auth.UserDisplayName(),
|
||||
Email: auth.UserID(),
|
||||
})
|
||||
if err != nil {
|
||||
return "", "", "", "", ctxerr.Wrap(ctx, err, "saving account data from IdP")
|
||||
return "", "", "", "", sso.SSORequestData{}, ctxerr.Wrap(ctx, err, "saving account data from IdP")
|
||||
}
|
||||
|
||||
idpAcc, err := svc.ds.GetMDMIdPAccountByEmail(
|
||||
|
|
@ -917,12 +924,21 @@ func (svc *Service) mdmSSOHandleCallbackAuth(
|
|||
auth.UserID(),
|
||||
)
|
||||
if err != nil {
|
||||
return "", "", "", "", ctxerr.Wrap(ctx, err, "retrieving new account data from IdP")
|
||||
return "", "", "", "", sso.SSORequestData{}, ctxerr.Wrap(ctx, err, "retrieving new account data from IdP")
|
||||
}
|
||||
|
||||
// If the initiator is "setup_experience", we can insert the host idp account record
|
||||
// right away, as the host uuid is provided in the SSO request data.
|
||||
if ssoRequestData.Initiator == "setup_experience" && ssoRequestData.HostUUID != "" {
|
||||
err = svc.ds.AssociateHostMDMIdPAccountDB(ctx, ssoRequestData.HostUUID, idpAcc.UUID)
|
||||
if err != nil {
|
||||
return "", "", "", "", sso.SSORequestData{}, ctxerr.Wrap(ctx, err, "saving host-account link from IdP")
|
||||
}
|
||||
}
|
||||
|
||||
eula, err := svc.ds.MDMGetEULAMetadata(ctx)
|
||||
if err != nil && !fleet.IsNotFound(err) {
|
||||
return "", "", "", "", ctxerr.Wrap(ctx, err, "getting EULA metadata")
|
||||
return "", "", "", "", sso.SSORequestData{}, ctxerr.Wrap(ctx, err, "getting EULA metadata")
|
||||
}
|
||||
|
||||
if eula != nil {
|
||||
|
|
@ -931,22 +947,22 @@ func (svc *Service) mdmSSOHandleCallbackAuth(
|
|||
|
||||
// If this is account driven enrollment there is no need to fetch the profile
|
||||
if originalURL == appleMDMAccountDrivenEnrollmentUrl {
|
||||
return "", idpAcc.UUID, eulaToken, originalURL, nil
|
||||
return "", idpAcc.UUID, eulaToken, originalURL, ssoRequestData, nil
|
||||
}
|
||||
|
||||
// get the automatic profile to access the authentication token.
|
||||
depProf, err := svc.getAutomaticEnrollmentProfile(ctx)
|
||||
if err != nil {
|
||||
return "", "", "", "", ctxerr.Wrap(ctx, err, "listing profiles")
|
||||
return "", "", "", "", sso.SSORequestData{}, ctxerr.Wrap(ctx, err, "listing profiles")
|
||||
}
|
||||
|
||||
if depProf == nil {
|
||||
return "", "", "", "", ctxerr.Wrap(ctx, err, "missing profile")
|
||||
return "", "", "", "", sso.SSORequestData{}, ctxerr.Wrap(ctx, err, "missing profile")
|
||||
}
|
||||
|
||||
// using the idp token as a reference just because that's the
|
||||
// only thing we're referencing later on during enrollment.
|
||||
return depProf.Token, idpAcc.UUID, eulaToken, originalURL, nil
|
||||
return depProf.Token, idpAcc.UUID, eulaToken, originalURL, ssoRequestData, nil
|
||||
}
|
||||
|
||||
func (svc *Service) mdmAppleSyncDEPProfiles(ctx context.Context) error {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import Spinner from "components/Spinner/Spinner";
|
|||
import SSOError from "components/MDM/SSOError";
|
||||
import Button from "components/buttons/Button";
|
||||
|
||||
import AuthenticationFormWrapper from "components/AuthenticationFormWrapper";
|
||||
|
||||
const baseClass = "mdm-apple-sso-callback-page";
|
||||
|
||||
const RedirectTo = ({ url }: { url: string }) => {
|
||||
|
|
@ -18,6 +20,7 @@ interface IEnrollmentGateProps {
|
|||
profileToken?: string;
|
||||
eulaToken?: string;
|
||||
enrollmentReference?: string;
|
||||
initiator?: string;
|
||||
error?: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -25,6 +28,7 @@ const EnrollmentGate = ({
|
|||
profileToken,
|
||||
eulaToken,
|
||||
enrollmentReference,
|
||||
initiator,
|
||||
error,
|
||||
}: IEnrollmentGateProps) => {
|
||||
const [showEULA, setShowEULA] = useState(Boolean(eulaToken));
|
||||
|
|
@ -35,6 +39,16 @@ const EnrollmentGate = ({
|
|||
return <SSOError />;
|
||||
}
|
||||
|
||||
if (initiator === "setup_experience") {
|
||||
return (
|
||||
<AuthenticationFormWrapper header="Authentication complete">
|
||||
<div className={`${baseClass} form`}>
|
||||
<p>You’re done! You may now close this window.</p>
|
||||
</div>
|
||||
</AuthenticationFormWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
if (showEULA && eulaToken) {
|
||||
return (
|
||||
<div className={`${baseClass}__eula-wrapper`}>
|
||||
|
|
@ -70,6 +84,7 @@ interface IMDMSSOCallbackQuery {
|
|||
eula_token?: string;
|
||||
profile_token?: string;
|
||||
enrollment_reference?: string;
|
||||
initiator?: string;
|
||||
error?: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -80,6 +95,7 @@ const MDMAppleSSOCallbackPage = (
|
|||
eula_token,
|
||||
profile_token,
|
||||
enrollment_reference,
|
||||
initiator,
|
||||
error,
|
||||
} = props.location.query;
|
||||
return (
|
||||
|
|
@ -88,6 +104,7 @@ const MDMAppleSSOCallbackPage = (
|
|||
eulaToken={eula_token}
|
||||
profileToken={profile_token}
|
||||
enrollmentReference={enrollment_reference}
|
||||
initiator={initiator}
|
||||
error={error}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -19,4 +19,8 @@
|
|||
&__agree-btn {
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
&.form {
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import { useQuery } from "react-query";
|
||||
import { AxiosError } from "axios";
|
||||
import { WithRouterProps } from "react-router";
|
||||
|
|
@ -7,22 +7,29 @@ import mdmAPI, { IMDMSSOParams } from "services/entities/mdm";
|
|||
|
||||
import SSOError from "components/MDM/SSOError";
|
||||
import Spinner from "components/Spinner/Spinner";
|
||||
import Button from "components/buttons/Button";
|
||||
import CustomLink from "components/CustomLink";
|
||||
import { IMdmSSOReponse } from "interfaces/mdm";
|
||||
import AuthenticationFormWrapper from "components/AuthenticationFormWrapper";
|
||||
|
||||
const baseClass = "mdm-apple-sso-page";
|
||||
|
||||
const DEPSSOLoginPage = ({
|
||||
location: { pathname, query },
|
||||
}: WithRouterProps<object, IMDMSSOParams>) => {
|
||||
const [clickedLogin, setClickedLogin] = useState(false);
|
||||
localStorage.setItem("deviceinfo", query.deviceinfo || "");
|
||||
query.initiator = "mdm_sso";
|
||||
if (pathname === "/mdm/apple/account_driven_enroll/sso") {
|
||||
query.initiator = "account_driven_enroll";
|
||||
if (!query.initiator) {
|
||||
query.initiator =
|
||||
pathname === "/mdm/apple/account_driven_enroll/sso"
|
||||
? "account_driven_enroll"
|
||||
: "mdm_sso";
|
||||
}
|
||||
const { error } = useQuery<IMdmSSOReponse, AxiosError>(
|
||||
["dep_sso"],
|
||||
() => mdmAPI.initiateMDMAppleSSO(query),
|
||||
{
|
||||
enabled: clickedLogin || query.initiator !== "setup_experience",
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
onSuccess: ({ url }) => {
|
||||
|
|
@ -31,6 +38,35 @@ const DEPSSOLoginPage = ({
|
|||
}
|
||||
);
|
||||
|
||||
if (query.initiator === "setup_experience") {
|
||||
return (
|
||||
<AuthenticationFormWrapper header="Authentication required">
|
||||
<div className={`${baseClass} form`}>
|
||||
<p>
|
||||
Your organization requires you to authenticate before setting up
|
||||
your device. Please sign in to continue.
|
||||
</p>
|
||||
<Button
|
||||
className={`${baseClass}__sso-btn`}
|
||||
type="button"
|
||||
title="Single sign-on"
|
||||
onClick={() => setClickedLogin(true)}
|
||||
isLoading={clickedLogin}
|
||||
>
|
||||
<div>Sign in</div>
|
||||
</Button>
|
||||
<p className={`${baseClass}__transparency-link`}>
|
||||
<CustomLink
|
||||
text="Why am I seeing this?"
|
||||
url="https://fleetdm.com/better"
|
||||
newTab
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</AuthenticationFormWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className={baseClass}>{error ? <SSOError /> : <Spinner />}</div>;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -6,4 +6,23 @@
|
|||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&.form {
|
||||
height: auto;
|
||||
|
||||
.mdm-apple-sso-page__sso-btn {
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
&__transparency-link {
|
||||
text-align: center;
|
||||
|
||||
.custom-link {
|
||||
font-size: $xxx-small;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,6 +76,9 @@ export interface IAppleSetupEnrollmentProfileResponse {
|
|||
export interface IMDMSSOParams {
|
||||
deviceinfo: string;
|
||||
initiator: string;
|
||||
// optional host_uuid to link SSO to a specific host; used in Orbit-initiated
|
||||
// enrollments with end-user authentication.
|
||||
host_uuid?: string;
|
||||
}
|
||||
|
||||
export interface IMDMAppleEnrollmentProfileParams {
|
||||
|
|
|
|||
|
|
@ -3756,12 +3756,12 @@ func (ds *Datastore) InsertMDMIdPAccount(ctx context.Context, account *fleet.MDM
|
|||
INSERT INTO mdm_idp_accounts
|
||||
(uuid, username, fullname, email)
|
||||
VALUES
|
||||
(UUID(), ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
(COALESCE(NULLIF(TRIM(?), ''), UUID()), ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
username = VALUES(username),
|
||||
fullname = VALUES(fullname)`
|
||||
|
||||
_, err := ds.writer(ctx).ExecContext(ctx, stmt, account.Username, account.Fullname, account.Email)
|
||||
_, err := ds.writer(ctx).ExecContext(ctx, stmt, account.UUID, account.Username, account.Fullname, account.Email)
|
||||
return ctxerr.Wrap(ctx, err, "creating new MDM IdP account")
|
||||
}
|
||||
|
||||
|
|
@ -6986,7 +6986,11 @@ func (ds *Datastore) ReconcileMDMAppleEnrollRef(ctx context.Context, enrollRef s
|
|||
return result, err
|
||||
}
|
||||
|
||||
func associateHostMDMIdPAccountDB(ctx context.Context, tx sqlx.ExtContext, hostUUID, acctUUID string) error {
|
||||
func (ds *Datastore) AssociateHostMDMIdPAccountDB(ctx context.Context, hostUUID string, acctUUID string) error {
|
||||
return associateHostMDMIdPAccountDB(ctx, ds.writer(ctx), hostUUID, acctUUID)
|
||||
}
|
||||
|
||||
func associateHostMDMIdPAccountDB(ctx context.Context, tx sqlx.ExtContext, hostUUID string, acctUUID string) error {
|
||||
const stmt = `
|
||||
INSERT INTO host_mdm_idp_accounts (host_uuid, account_uuid)
|
||||
VALUES (?, ?)
|
||||
|
|
|
|||
|
|
@ -1395,6 +1395,9 @@ type Datastore interface {
|
|||
// InsertMDMIdPAccount inserts a new MDM IdP account
|
||||
InsertMDMIdPAccount(ctx context.Context, account *MDMIdPAccount) error
|
||||
|
||||
// AssociateHostMDMIdPAccountDB associates a host with an MDM IdP account
|
||||
AssociateHostMDMIdPAccountDB(ctx context.Context, hostUUID string, acctUUID string) error
|
||||
|
||||
// GetMDMIdPAccountByUUID returns MDM IdP account that matches the given token.
|
||||
GetMDMIdPAccountByUUID(ctx context.Context, uuid string) (*MDMIdPAccount, error)
|
||||
|
||||
|
|
|
|||
|
|
@ -633,6 +633,7 @@ func (fe FleetdError) ToMap() map[string]any {
|
|||
// with a failed request's response.
|
||||
type OrbitError struct {
|
||||
Message string
|
||||
code int
|
||||
}
|
||||
|
||||
// Error implements the error interface for the OrbitError.
|
||||
|
|
@ -640,6 +641,21 @@ func (e OrbitError) Error() string {
|
|||
return e.Message
|
||||
}
|
||||
|
||||
// StatusCode implements the ErrWithStatusCode interface for the OrbitError.
|
||||
func (e OrbitError) StatusCode() int {
|
||||
if e.code == 0 {
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
return e.code
|
||||
}
|
||||
|
||||
func NewOrbitIDPAuthRequiredError() *OrbitError {
|
||||
return &OrbitError{
|
||||
Message: "END_USER_AUTH_REQUIRED",
|
||||
code: http.StatusUnauthorized,
|
||||
}
|
||||
}
|
||||
|
||||
// Message that may surfaced by the server or the fleetctl client.
|
||||
const (
|
||||
// Hosts, general
|
||||
|
|
|
|||
|
|
@ -181,7 +181,9 @@ type Service interface {
|
|||
// 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)
|
||||
InitiateMDMSSO(ctx context.Context, initiator, customOriginalURL string) (sessionID string, sessionDurationSeconds int, idpURL string, err error)
|
||||
// When initiated from Orbit, the hostUUID is used to link the SSO
|
||||
// session to a specific host.
|
||||
InitiateMDMSSO(ctx context.Context, initiator, customOriginalURL string, hostUUID string) (sessionID string, sessionDurationSeconds int, idpURL string, err error)
|
||||
|
||||
// InitSSOCallback handles the IdP SAMLResponse and ensures the credentials are valid.
|
||||
// The sessionID is used to identify the SSO session and samlResponse is the raw SAMLResponse.
|
||||
|
|
|
|||
|
|
@ -951,6 +951,8 @@ type GetMDMAppleProfilesSummaryFunc func(ctx context.Context, teamID *uint) (*fl
|
|||
|
||||
type InsertMDMIdPAccountFunc func(ctx context.Context, account *fleet.MDMIdPAccount) error
|
||||
|
||||
type AssociateHostMDMIdPAccountDBFunc func(ctx context.Context, hostUUID string, acctUUID string) error
|
||||
|
||||
type GetMDMIdPAccountByUUIDFunc func(ctx context.Context, uuid string) (*fleet.MDMIdPAccount, error)
|
||||
|
||||
type GetMDMIdPAccountByEmailFunc func(ctx context.Context, email string) (*fleet.MDMIdPAccount, error)
|
||||
|
|
@ -2982,6 +2984,9 @@ type DataStore struct {
|
|||
InsertMDMIdPAccountFunc InsertMDMIdPAccountFunc
|
||||
InsertMDMIdPAccountFuncInvoked bool
|
||||
|
||||
AssociateHostMDMIdPAccountDBFunc AssociateHostMDMIdPAccountDBFunc
|
||||
AssociateHostMDMIdPAccountDBFuncInvoked bool
|
||||
|
||||
GetMDMIdPAccountByUUIDFunc GetMDMIdPAccountByUUIDFunc
|
||||
GetMDMIdPAccountByUUIDFuncInvoked bool
|
||||
|
||||
|
|
@ -7190,6 +7195,13 @@ func (s *DataStore) InsertMDMIdPAccount(ctx context.Context, account *fleet.MDMI
|
|||
return s.InsertMDMIdPAccountFunc(ctx, account)
|
||||
}
|
||||
|
||||
func (s *DataStore) AssociateHostMDMIdPAccountDB(ctx context.Context, hostUUID string, acctUUID string) error {
|
||||
s.mu.Lock()
|
||||
s.AssociateHostMDMIdPAccountDBFuncInvoked = true
|
||||
s.mu.Unlock()
|
||||
return s.AssociateHostMDMIdPAccountDBFunc(ctx, hostUUID, acctUUID)
|
||||
}
|
||||
|
||||
func (s *DataStore) GetMDMIdPAccountByUUID(ctx context.Context, uuid string) (*fleet.MDMIdPAccount, error) {
|
||||
s.mu.Lock()
|
||||
s.GetMDMIdPAccountByUUIDFuncInvoked = true
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ type GetUserSettingsFunc func(ctx context.Context, id uint) (settings *fleet.Use
|
|||
|
||||
type InitiateSSOFunc func(ctx context.Context, redirectURL string) (sessionID string, sessionDurationSeconds int, idpURL string, err error)
|
||||
|
||||
type InitiateMDMSSOFunc func(ctx context.Context, initiator string, customOriginalURL string) (sessionID string, sessionDurationSeconds int, idpURL string, err error)
|
||||
type InitiateMDMSSOFunc func(ctx context.Context, initiator string, customOriginalURL string, hostUUID string) (sessionID string, sessionDurationSeconds int, idpURL string, err error)
|
||||
|
||||
type InitSSOCallbackFunc func(ctx context.Context, sessionID string, samlResponse []byte) (auth fleet.Auth, redirectURL string, err error)
|
||||
|
||||
|
|
@ -2314,11 +2314,11 @@ func (s *Service) InitiateSSO(ctx context.Context, redirectURL string) (sessionI
|
|||
return s.InitiateSSOFunc(ctx, redirectURL)
|
||||
}
|
||||
|
||||
func (s *Service) InitiateMDMSSO(ctx context.Context, initiator string, customOriginalURL string) (sessionID string, sessionDurationSeconds int, idpURL string, err error) {
|
||||
func (s *Service) InitiateMDMSSO(ctx context.Context, initiator string, customOriginalURL string, hostUUID string) (sessionID string, sessionDurationSeconds int, idpURL string, err error) {
|
||||
s.mu.Lock()
|
||||
s.InitiateMDMSSOFuncInvoked = true
|
||||
s.mu.Unlock()
|
||||
return s.InitiateMDMSSOFunc(ctx, initiator, customOriginalURL)
|
||||
return s.InitiateMDMSSOFunc(ctx, initiator, customOriginalURL, hostUUID)
|
||||
}
|
||||
|
||||
func (s *Service) InitSSOCallback(ctx context.Context, sessionID string, samlResponse []byte) (auth fleet.Auth, redirectURL string, err error) {
|
||||
|
|
|
|||
|
|
@ -3341,8 +3341,9 @@ func (svc *Service) UpdateMDMAppleSetup(ctx context.Context, payload fleet.MDMAp
|
|||
////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
type initiateMDMSSORequest struct {
|
||||
Initiator string `json:"initiator,omitempty"` // optional, passed by the UI during account-driven enrollment
|
||||
Initiator string `json:"initiator,omitempty"` // optional, passed by the UI during account-driven enrollment, or by Orbit for non-Apple IdP auth.
|
||||
UserIdentifier string `json:"user_identifier,omitempty"` // optional, passed by Apple for account-driven enrollment
|
||||
HostUUID string `json:"host_uuid,omitempty"` // optional, passed by Orbit for non-Apple IdP auth
|
||||
}
|
||||
|
||||
type initiateMDMSSOResponse struct {
|
||||
|
|
@ -3361,7 +3362,7 @@ func (r initiateMDMSSOResponse) SetCookies(_ context.Context, w http.ResponseWri
|
|||
|
||||
func initiateMDMSSOEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
||||
req := request.(*initiateMDMSSORequest)
|
||||
sessionID, sessionDurationSeconds, idpProviderURL, err := svc.InitiateMDMSSO(ctx, req.Initiator, "")
|
||||
sessionID, sessionDurationSeconds, idpProviderURL, err := svc.InitiateMDMSSO(ctx, req.Initiator, "", req.HostUUID)
|
||||
if err != nil {
|
||||
return initiateMDMSSOResponse{Err: err}, nil
|
||||
}
|
||||
|
|
@ -3374,7 +3375,7 @@ func initiateMDMSSOEndpoint(ctx context.Context, request interface{}, svc fleet.
|
|||
}, nil
|
||||
}
|
||||
|
||||
func (svc *Service) InitiateMDMSSO(ctx context.Context, initiator, customOriginalURL string) (sessionID string, sessionDurationSeconds int, idpURL string, err error) {
|
||||
func (svc *Service) InitiateMDMSSO(ctx context.Context, initiator, customOriginalURL string, hostUUID string) (sessionID string, sessionDurationSeconds int, idpURL string, err error) {
|
||||
// skipauth: No authorization check needed due to implementation
|
||||
// returning only license error.
|
||||
svc.authz.SkipAuthorization(ctx)
|
||||
|
|
|
|||
|
|
@ -224,7 +224,7 @@ func renderEnrollPage(w io.Writer, appCfg *fleet.AppConfig, urlPrefix, enrollSec
|
|||
}
|
||||
|
||||
func initiateOTAEnrollSSO(svc fleet.Service, w http.ResponseWriter, r *http.Request, enrollSecret string) error {
|
||||
ssnID, ssnDurationSecs, idpURL, err := svc.InitiateMDMSSO(r.Context(), "ota_enroll", "/enroll?enroll_secret="+url.QueryEscape(enrollSecret))
|
||||
ssnID, ssnDurationSecs, idpURL, err := svc.InitiateMDMSSO(r.Context(), "ota_enroll", "/enroll?enroll_secret="+url.QueryEscape(enrollSecret), "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -177,6 +177,29 @@ func (svc *Service) EnrollOrbit(ctx context.Context, hostInfo fleet.OrbitHostInf
|
|||
if err != nil {
|
||||
return "", fleet.OrbitError{Message: "app config load failed: " + err.Error()}
|
||||
}
|
||||
isEndUserAuthRequired := appConfig.MDM.MacOSSetup.EnableEndUserAuthentication
|
||||
// If the secret is for a team, get the team config as well.
|
||||
if secret.TeamID != nil {
|
||||
team, err := svc.ds.Team(ctx, *secret.TeamID)
|
||||
if err != nil {
|
||||
return "", fleet.OrbitError{Message: "failed to get team config: " + err.Error()}
|
||||
}
|
||||
isEndUserAuthRequired = team.Config.MDM.MacOSSetup.EnableEndUserAuthentication
|
||||
}
|
||||
|
||||
if isEndUserAuthRequired {
|
||||
if hostInfo.HardwareUUID == "" {
|
||||
return "", fleet.OrbitError{Message: "failed to get IdP account: hardware uuid is empty"}
|
||||
}
|
||||
// Try to find an IdP account for this host.
|
||||
idpAccount, err := svc.ds.GetMDMIdPAccountByHostUUID(ctx, hostInfo.HardwareUUID)
|
||||
if err != nil {
|
||||
return "", fleet.OrbitError{Message: "failed to get IdP account: " + err.Error()}
|
||||
}
|
||||
if idpAccount == nil {
|
||||
return "", fleet.NewOrbitIDPAuthRequiredError()
|
||||
}
|
||||
}
|
||||
|
||||
var stickyEnrollment *string
|
||||
if svc.keyValueStore != nil {
|
||||
|
|
|
|||
|
|
@ -493,6 +493,7 @@ func (svc *Service) InitiateSSO(ctx context.Context, redirectURL string) (sessio
|
|||
sessionID, idpURL, err = sso.CreateAuthorizationRequest(
|
||||
ctx, samlProvider, svc.ssoSessionStore, redirectURL,
|
||||
uint(sessionDurationSeconds), //nolint:gosec // dismiss G115
|
||||
sso.SSORequestData{},
|
||||
)
|
||||
if err != nil {
|
||||
return "", 0, "", ctxerr.Wrap(ctx, err, "InitiateSSO creating authorization")
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ func CreateAuthorizationRequest(
|
|||
sessionStore SessionStore,
|
||||
originalURL string,
|
||||
sessionTTLSeconds uint,
|
||||
requestData SSORequestData,
|
||||
) (sessionID string, idpURL string, err error) {
|
||||
idpURL, err = getDestinationURL(samlProvider.IDPMetadata)
|
||||
if err != nil {
|
||||
|
|
@ -76,6 +77,7 @@ func CreateAuthorizationRequest(
|
|||
originalURL,
|
||||
metadataWriter.String(),
|
||||
sessionLifetimeSeconds,
|
||||
requestData,
|
||||
)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("caching SSO session while creating auth request: %w", err)
|
||||
|
|
|
|||
|
|
@ -47,6 +47,10 @@ func TestCreateAuthorizationRequest(t *testing.T) {
|
|||
store,
|
||||
"/redir",
|
||||
0,
|
||||
SSORequestData{
|
||||
HostUUID: "host-uuid-123",
|
||||
Initiator: "test_initiator",
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 300*time.Second, store.sessionLifetime) // check default is used
|
||||
|
|
@ -67,6 +71,8 @@ func TestCreateAuthorizationRequest(t *testing.T) {
|
|||
ssn := store.session
|
||||
require.NotNil(t, ssn)
|
||||
assert.Equal(t, "/redir", ssn.OriginalURL)
|
||||
assert.Equal(t, "host-uuid-123", ssn.RequestData.HostUUID)
|
||||
assert.Equal(t, "test_initiator", ssn.RequestData.Initiator)
|
||||
assert.Equal(t, 5*time.Minute, store.sessionLifetime)
|
||||
|
||||
var meta saml.EntityDescriptor
|
||||
|
|
@ -79,6 +85,7 @@ func TestCreateAuthorizationRequest(t *testing.T) {
|
|||
store,
|
||||
"/redir",
|
||||
sessionTTL,
|
||||
SSORequestData{},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1*time.Hour, store.sessionLifetime)
|
||||
|
|
@ -105,11 +112,12 @@ type mockStore struct {
|
|||
sessionLifetime time.Duration
|
||||
}
|
||||
|
||||
func (s *mockStore) create(sessionID, requestID, originalURL, metadata string, lifetimeSecs uint) error {
|
||||
func (s *mockStore) create(sessionID, requestID, originalURL, metadata string, lifetimeSecs uint, requestData SSORequestData) error {
|
||||
s.session = &Session{
|
||||
RequestID: requestID,
|
||||
OriginalURL: originalURL,
|
||||
Metadata: metadata,
|
||||
RequestData: requestData,
|
||||
}
|
||||
s.sessionLifetime = time.Duration(lifetimeSecs) * time.Second // nolint:gosec // dismiss G115
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -64,14 +64,14 @@ func SAMLProviderFromSession(
|
|||
acsURL *url.URL,
|
||||
entityID string,
|
||||
expectedAudiences []string,
|
||||
) (samlProvider *saml.ServiceProvider, requestID, originalURL string, err error) {
|
||||
) (samlProvider *saml.ServiceProvider, requestID, originalURL string, ssoRequestData SSORequestData, err error) {
|
||||
session, err := sessionStore.Fullfill(sessionID)
|
||||
if err != nil {
|
||||
return nil, "", "", ctxerr.Wrap(ctx, err, "validate request in session")
|
||||
return nil, "", "", SSORequestData{}, ctxerr.Wrap(ctx, err, "validate request in session")
|
||||
}
|
||||
entityDescriptor, err := ParseMetadata([]byte(session.Metadata))
|
||||
if err != nil {
|
||||
return nil, "", "", ctxerr.Wrap(ctx, err, "failed to parse metadata")
|
||||
return nil, "", "", SSORequestData{}, ctxerr.Wrap(ctx, err, "failed to parse metadata")
|
||||
}
|
||||
|
||||
return &saml.ServiceProvider{
|
||||
|
|
@ -81,7 +81,7 @@ func SAMLProviderFromSession(
|
|||
ValidateAudienceRestriction: func(assertion *saml.Assertion) error {
|
||||
return validateAudiences(assertion, expectedAudiences)
|
||||
},
|
||||
}, session.RequestID, session.OriginalURL, nil
|
||||
}, session.RequestID, session.OriginalURL, session.RequestData, nil
|
||||
}
|
||||
|
||||
// SAMLProviderFromSessionOrConfiguredMetadata creates a SAML provider that can validate SAML responses.
|
||||
|
|
|
|||
|
|
@ -11,6 +11,11 @@ import (
|
|||
redigo "github.com/gomodule/redigo/redis"
|
||||
)
|
||||
|
||||
type SSORequestData struct {
|
||||
HostUUID string `json:"host_uuid,omitempty"`
|
||||
Initiator string `json:"initiator,omitempty"`
|
||||
}
|
||||
|
||||
// Session stores state for the lifetime of a single sign on session.
|
||||
type Session struct {
|
||||
// RequestID is the SAMLRequest ID that must match "InResponseTo" in the SAMLResponse.
|
||||
|
|
@ -19,6 +24,8 @@ type Session struct {
|
|||
Metadata string `json:"metadata"`
|
||||
// OriginalURL is the resource being accessed when login request was triggered
|
||||
OriginalURL string `json:"original_url"`
|
||||
// Additional request data that may be needed to complete the SSO process.
|
||||
RequestData SSORequestData `json:"request_data,omitempty"`
|
||||
}
|
||||
|
||||
// SessionStore persists state of a sso session across process boundries and
|
||||
|
|
@ -27,7 +34,7 @@ type Session struct {
|
|||
// is constrained in the backing store (Redis) so if the sso process is not completed in
|
||||
// a reasonable amount of time, it automatically expires and is removed.
|
||||
type SessionStore interface {
|
||||
create(sessionID, requestID, originalURL, metadata string, lifetimeSecs uint) error
|
||||
create(sessionID, requestID, originalURL, metadata string, lifetimeSecs uint, requestData SSORequestData) error
|
||||
get(sessionID string) (*Session, error)
|
||||
expire(sessionID string) error
|
||||
// Fullfill loads a session with the given session ID, deletes it and returns it.
|
||||
|
|
@ -43,7 +50,7 @@ type store struct {
|
|||
pool fleet.RedisPool
|
||||
}
|
||||
|
||||
func (s *store) create(sessionID, requestID, originalURL, metadata string, lifetimeSecs uint) error {
|
||||
func (s *store) create(sessionID, requestID, originalURL, metadata string, lifetimeSecs uint, requestData SSORequestData) error {
|
||||
if len(sessionID) < 8 {
|
||||
return errors.New("request id must be 8 or more characters in length")
|
||||
}
|
||||
|
|
@ -54,6 +61,7 @@ func (s *store) create(sessionID, requestID, originalURL, metadata string, lifet
|
|||
RequestID: requestID,
|
||||
Metadata: metadata,
|
||||
OriginalURL: originalURL,
|
||||
RequestData: requestData,
|
||||
}
|
||||
var writer bytes.Buffer
|
||||
err := json.NewEncoder(&writer).Encode(session)
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ func TestSessionStore(t *testing.T) {
|
|||
store := NewSessionStore(pool)
|
||||
|
||||
// Create session that lives for 1 second.
|
||||
err := store.create("sessionID123", "requestID123", "https://originalurl.com", "some metadata", 1)
|
||||
err := store.create("sessionID123", "requestID123", "https://originalurl.com", "some metadata", 1, SSORequestData{HostUUID: "host-uuid-123"})
|
||||
require.NoError(t, err)
|
||||
|
||||
sess, err := store.get("sessionID123")
|
||||
|
|
@ -24,6 +24,7 @@ func TestSessionStore(t *testing.T) {
|
|||
assert.Equal(t, "requestID123", sess.RequestID)
|
||||
assert.Equal(t, "https://originalurl.com", sess.OriginalURL)
|
||||
assert.Equal(t, "some metadata", sess.Metadata)
|
||||
assert.Equal(t, "host-uuid-123", sess.RequestData.HostUUID)
|
||||
|
||||
// Wait a little bit more than one second, session should no longer be present.
|
||||
time.Sleep(1100 * time.Millisecond)
|
||||
|
|
@ -33,7 +34,7 @@ func TestSessionStore(t *testing.T) {
|
|||
assert.Nil(t, sess)
|
||||
|
||||
// Create another session for 1 second
|
||||
err = store.create("sessionID456", "requestID456", "https://originalurl.com", "some metadata", 1)
|
||||
err = store.create("sessionID456", "requestID456", "https://originalurl.com", "some metadata", 1, SSORequestData{})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Forcefully expire it
|
||||
|
|
|
|||
Loading…
Reference in a new issue