fleet/server/sso/saml_provider.go

134 lines
4.9 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, err error) {
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")
}
return &saml.ServiceProvider{
EntityID: entityID,
AcsURL: *acsURL,
IDPMetadata: entityDescriptor,
ValidateAudienceRestriction: func(assertion *saml.Assertion) error {
return validateAudiences(assertion, expectedAudiences)
},
}, session.RequestID, session.OriginalURL, 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
}