mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 13:37:30 +00:00
<!-- 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 -->
134 lines
5 KiB
Go
134 lines
5 KiB
Go
// Package sso wraps SSO functionality to be used by Fleet's service layer.
|
|
// It uses https://github.com/crewjam/saml for SAML parsing and validation.
|
|
//
|
|
// Initiate SSO:
|
|
// - Fleet generates a random session ID, and SAML AuthnRequest with a random Request ID.
|
|
// - Fleet stores the session in Redis with the session ID as key and "Request ID" + "Original URL" + configured Metadata as value.
|
|
// - Fleet returns a URL that redirects the user to the IdP with AuthnRequest and the session ID as a HTTP cookie.
|
|
//
|
|
// Callback SSO:
|
|
// - Fleet receives SAMLResponse in the request.
|
|
// - Fleet loads the session ID from a HTTP cookie and loads the session from Redis.
|
|
// - Fleet uses the Request ID + Metadata loaded from Redis to verify the SAMLResponse.
|
|
// - If verification succeeds, Fleet redirects the user to the Original URL loaded from Redis.
|
|
//
|
|
// IdP-initiated Callback SSO (if enabled by the admin):
|
|
// - Fleet receives SAMLResponse without session ID or "Request ID".
|
|
// - Fleet uses the configured metadata to verify the SAMLResponse.
|
|
//
|
|
// PS: We use a HTTP cookie for the session ID to prevent CSRF attacks (outcome of a pentest).
|
|
package sso
|
|
|
|
import (
|
|
"context"
|
|
"net/url"
|
|
|
|
"github.com/crewjam/saml"
|
|
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
|
"github.com/fleetdm/fleet/v4/server/fleet"
|
|
)
|
|
|
|
// SAMLProviderFromConfiguredMetadata creates a SAML provider that can validate SAML responses
|
|
// from the configured SSO metadata.
|
|
func SAMLProviderFromConfiguredMetadata(
|
|
ctx context.Context,
|
|
entityID string,
|
|
acsURL string,
|
|
settings *fleet.SSOProviderSettings,
|
|
) (*saml.ServiceProvider, error) {
|
|
entityDescriptor, err := GetMetadata(settings)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, &fleet.BadRequestError{
|
|
Message: "failed to get and parse IdP metadata",
|
|
InternalErr: err,
|
|
})
|
|
}
|
|
parsedACSURL, err := url.Parse(acsURL)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "failed to parse ACS URL")
|
|
}
|
|
return &saml.ServiceProvider{
|
|
EntityID: entityID,
|
|
AcsURL: *parsedACSURL,
|
|
IDPMetadata: entityDescriptor,
|
|
AuthnNameIDFormat: saml.EmailAddressNameIDFormat,
|
|
}, nil
|
|
}
|
|
|
|
// SAMLProviderFromSession creates a SAML provider that can validate SAML responses
|
|
// from a valid SSO session (stored in sessionStore).
|
|
func SAMLProviderFromSession(
|
|
ctx context.Context,
|
|
sessionID string,
|
|
sessionStore SessionStore,
|
|
acsURL *url.URL,
|
|
entityID string,
|
|
expectedAudiences []string,
|
|
) (samlProvider *saml.ServiceProvider, requestID, originalURL string, ssoRequestData SSORequestData, err error) {
|
|
session, err := sessionStore.Fullfill(sessionID)
|
|
if err != nil {
|
|
return nil, "", "", SSORequestData{}, ctxerr.Wrap(ctx, err, "validate request in session")
|
|
}
|
|
entityDescriptor, err := ParseMetadata([]byte(session.Metadata))
|
|
if err != nil {
|
|
return nil, "", "", SSORequestData{}, ctxerr.Wrap(ctx, err, "failed to parse metadata")
|
|
}
|
|
|
|
return &saml.ServiceProvider{
|
|
EntityID: entityID,
|
|
AcsURL: *acsURL,
|
|
IDPMetadata: entityDescriptor,
|
|
ValidateAudienceRestriction: func(assertion *saml.Assertion) error {
|
|
return validateAudiences(assertion, expectedAudiences)
|
|
},
|
|
}, session.RequestID, session.OriginalURL, session.RequestData, nil
|
|
}
|
|
|
|
// SAMLProviderFromSessionOrConfiguredMetadata creates a SAML provider that can validate SAML responses.
|
|
// It will create the SAML provider from an existing SSO session (using sessionStore),
|
|
// if sessionID was generated by Fleet.
|
|
// or it will create a SAML provider from the configured metadata if IdP-initiated logins are enabled.
|
|
func SAMLProviderFromSessionOrConfiguredMetadata(
|
|
ctx context.Context,
|
|
sessionID string,
|
|
sessionStore SessionStore,
|
|
acsURL *url.URL,
|
|
settings *fleet.SSOSettings,
|
|
expectedAudiences []string,
|
|
) (samlProvider *saml.ServiceProvider, requestID string, redirectURL string, err error) {
|
|
idpInitiated := sessionID == ""
|
|
|
|
var entityDescriptor *saml.EntityDescriptor
|
|
if settings.EnableSSOIdPLogin && idpInitiated {
|
|
// Missing request ID indicates this was IdP-initiated. Only allow if
|
|
// configured to do so.
|
|
var err error
|
|
entityDescriptor, err = GetMetadata(&settings.SSOProviderSettings)
|
|
if err != nil {
|
|
return nil, "", "", ctxerr.Wrap(ctx, err, "failed to parse metadata")
|
|
}
|
|
redirectURL = "/"
|
|
} else {
|
|
session, err := sessionStore.Fullfill(sessionID)
|
|
if err != nil {
|
|
return nil, "", "", 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")
|
|
}
|
|
redirectURL = session.OriginalURL
|
|
requestID = session.RequestID
|
|
}
|
|
|
|
return &saml.ServiceProvider{
|
|
EntityID: settings.EntityID,
|
|
AcsURL: *acsURL,
|
|
DefaultRedirectURI: redirectURL,
|
|
IDPMetadata: entityDescriptor,
|
|
ValidateAudienceRestriction: func(assertion *saml.Assertion) error {
|
|
return validateAudiences(assertion, expectedAudiences)
|
|
},
|
|
AllowIDPInitiated: settings.EnableSSOIdPLogin,
|
|
}, requestID, redirectURL, nil
|
|
}
|