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:
Roberto Dip
2023-04-27 09:43:20 -03:00
committed by GitHub
co-authored by Gabriel Hernandez gillespi314
parent 7dadec3ecf
commit a23d208b1d
38 changed files with 667 additions and 185 deletions
+1
View File
@@ -0,0 +1 @@
* Added functionality to gate Apple MDM login behind SAML authentication.
+1
View File
@@ -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
View File
@@ -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:
+126
View File
@@ -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)
}
+8
View File
@@ -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,
})
+10 -2
View File
@@ -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">
-3
View File
@@ -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;
}
+4
View File
@@ -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;
}
}
}
+1
View File
@@ -0,0 +1 @@
export { default } from "./MDMAppleSSOPage";
+2
View File
@@ -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}>
+5
View File
@@ -98,4 +98,9 @@ export default {
team_id: teamId,
});
},
initiateMDMAppleSSO: () => {
const { MDM_APPLE_SSO } = endpoints;
return sendRequest("POST", MDM_APPLE_SSO, {});
},
};
+1
View File
@@ -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) =>
+1
View File
@@ -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")
+4
View File
@@ -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
+5
View File
@@ -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())
}
+13
View File
@@ -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)
+19 -18
View File
@@ -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 {
+14
View File
@@ -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
}
+38
View File
@@ -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)
}
}
+6
View File
@@ -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
+33 -1
View File
@@ -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
View File
@@ -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
////////////////////////////////////////////////////////////////////////////////
-32
View File
@@ -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)
+6
View File
@@ -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 {
+64 -1
View File
@@ -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
View File
@@ -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 {
+17 -6
View File
@@ -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.
+6 -3
View File
@@ -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)
+5 -2
View File
@@ -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
}
+27 -4
View File
@@ -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
}
+5 -5
View File
@@ -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
View File
@@ -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 {
+2 -2
View File
@@ -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)
+20
View File
@@ -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
}
+11
View File
@@ -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',
);