add back-end implementation for SSO JIT provisioning (#7182)

Related to #7053, this uses the SSO config added in #7140 to enable JIT provisioning for premium instances.
This commit is contained in:
Roberto Dip 2022-08-15 14:42:33 -03:00 committed by GitHub
parent 080723dde7
commit 05ddeade90
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 480 additions and 172 deletions

View file

@ -49,7 +49,7 @@ jobs:
# Pre-starting dependencies here means they are ready to go when we need them.
- name: Start Infra Dependencies
# Use & to background this
run: FLEET_MYSQL_IMAGE=${{ matrix.mysql }} docker-compose up -d mysql_test redis redis-cluster-1 redis-cluster-2 redis-cluster-3 redis-cluster-4 redis-cluster-5 redis-cluster-6 redis-cluster-setup minio &
run: FLEET_MYSQL_IMAGE=${{ matrix.mysql }} docker-compose up -d mysql_test redis redis-cluster-1 redis-cluster-2 redis-cluster-3 redis-cluster-4 redis-cluster-5 redis-cluster-6 redis-cluster-setup minio saml_idp &
# It seems faster not to cache Go dependencies
- name: Install Go Dependencies
@ -74,6 +74,7 @@ jobs:
REDIS_TEST=1 \
MYSQL_TEST=1 \
MINIO_STORAGE_TEST=1 \
SAML_IDP_TEST=1 \
make test-go 2>&1 | tee /tmp/gotest.log | gotestfmt
- name: Upload to Codecov

View file

@ -51,7 +51,7 @@ REDIS_TEST=1 MYSQL_TEST=1 make test
To run all Go unit tests, run the following:
```
REDIS_TEST=1 MYSQL_TEST=1 MINIO_STORAGE_TEST=1 make test-go
REDIS_TEST=1 MYSQL_TEST=1 MINIO_STORAGE_TEST=1 SAML_IDP_TEST=1 make test-go
```
### Go linters

View file

@ -0,0 +1,63 @@
package service
import (
"context"
"errors"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/ptr"
)
// GetSSOUser is the premium implementation of svc.GetSSOUser, it allows to
// create users during the SSO flow the first time they log in if
// config.SSOSettings.EnableJITProvisioning is `true`
func (svc *Service) GetSSOUser(ctx context.Context, auth fleet.Auth) (*fleet.User, error) {
config, err := svc.ds.AppConfig(ctx)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "getting app config")
}
if err := fleet.ValidateEmail(auth.UserID()); err != nil {
return nil, ctxerr.New(ctx, "validating SSO response")
}
user, err := svc.Service.GetSSOUser(ctx, auth)
var nfe fleet.NotFoundError
switch {
case err == nil:
return user, nil
case errors.As(err, &nfe):
if !config.SSOSettings.EnableJITProvisioning {
return nil, err
}
default:
return nil, err
}
displayName := auth.UserDisplayName()
if displayName == "" {
displayName = auth.UserID()
}
user, err = svc.Service.NewUser(ctx, fleet.UserPayload{
Name: &displayName,
Email: ptr.String(auth.UserID()),
SSOEnabled: ptr.Bool(true),
GlobalRole: ptr.String(fleet.RoleObserver),
})
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "creating new SSO user")
}
err = svc.ds.NewActivity(
ctx,
user,
fleet.ActivityTypeUserAddedBySSO,
&map[string]interface{}{},
)
if err != nil {
return nil, err
}
return user, nil
}

View file

@ -9,9 +9,10 @@ import (
)
type clientOpts struct {
timeout time.Duration
tlsConf *tls.Config
noFollow bool
timeout time.Duration
tlsConf *tls.Config
noFollow bool
cookieJar http.CookieJar
}
// ClientOpt is the type for the client-specific options.
@ -40,6 +41,14 @@ func WithFollowRedir(follow bool) ClientOpt {
}
}
// WithCookieJar configures the HTTP client to use the provided
// cookie jar to manage cookies between requests.
func WithCookieJar(jar http.CookieJar) ClientOpt {
return func(o *clientOpts) {
o.cookieJar = jar
}
}
// NewClient returns an HTTP client configured according to the provided
// options.
func NewClient(opts ...ClientOpt) *http.Client {
@ -58,6 +67,9 @@ func NewClient(opts ...ClientOpt) *http.Client {
if co.tlsConf != nil {
cli.Transport = NewTransport(WithTLSConfig(co.tlsConf))
}
if co.cookieJar != nil {
cli.Jar = co.cookieJar
}
return cli
}

View file

@ -37,6 +37,9 @@ const (
ActivityTypeDeletedTeam = "deleted_team"
// ActivityTypeLiveQuery is the activity type for live queries
ActivityTypeLiveQuery = "live_query"
// ActivityTypeUserAddedBySSO is the activity type for new users added
// via SSO JIT provisioning
ActivityTypeUserAddedBySSO = "user_added_by_sso"
)
type Activity struct {

View file

@ -58,6 +58,9 @@ type Service interface {
// User returns a valid User given a User ID.
User(ctx context.Context, id uint) (user *User, err error)
// NewUser creates a new user with the given payload
NewUser(ctx context.Context, p UserPayload) (*User, error)
// UserUnauthorized returns a valid User given a User ID, *skipping authorization checks*
// This method should only be used in middleware where there is not yet a viewer context and we need to load up a
// user to create that context.
@ -107,10 +110,14 @@ type Service interface {
// prompted to log in.
InitiateSSO(ctx context.Context, redirectURL string) (string, error)
// CallbackSSO handles the IDP response. The original URL the viewer attempted to access is returned from this
// function, so we can redirect back to the front end and load the page the viewer originally attempted to access
// when prompted for login.
CallbackSSO(ctx context.Context, auth Auth) (*SSOSession, error)
// InitSSOCallback handles the IDP response and ensures the credentials
// are valid
InitSSOCallback(ctx context.Context, auth Auth) (string, error)
// GetSSOUser handles retrieval of an user that is trying to authenticate
// via SSO
GetSSOUser(ctx context.Context, auth Auth) (*User, error)
// LoginSSOUser logs-in the given SSO user
LoginSSOUser(ctx context.Context, user *User, redirectURL string) (*SSOSession, error)
// SSOSettings returns non-sensitive single sign on information used before authentication
SSOSettings(ctx context.Context) (*SessionSSOSettings, error)

View file

@ -4,8 +4,20 @@ import (
"time"
)
// Auth contains methods to fetch information from a valid SSO auth response
type Auth interface {
// UserID returns the Subject Name Identifier associated with the request,
// this can be an email address, an entity identifier, or any other valid
// Name Identifier as described in the spec:
// http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf
//
// Fleet requires users to configure this value to be the email of the Subject
UserID() string
// UserDisplayName finds a display name in the SSO response Attributes, there
// isn't a defined spec for this, so the return value is in a best-effort
// basis
UserDisplayName() string
// RequestID returns the request id associated with this SSO session
RequestID() string
}

View file

@ -4,6 +4,7 @@ import (
"encoding/json"
"errors"
"fmt"
"net/mail"
"time"
"unicode"
@ -334,6 +335,28 @@ func ValidatePasswordRequirements(password string) error {
return errors.New("Password does not meet required criteria")
}
// ValidateEmail checks that the provided email address is valid, this function
// uses the stdlib func `mail.ParseAddress` underneath, which parses email
// adddresses using RFC5322, so it properly parses strings like "User
// <example.com>" thus we check if:
//
// 1. We're able to parse the address
// 2. The parsed address is equal to the provided address
//
// TODO: see issue #7199, we should use this logic for all user-creation flows
func ValidateEmail(email string) error {
addr, err := mail.ParseAddress(email)
if err != nil {
return fmt.Errorf("Invalid email address: %e", err)
}
if addr.Address != email {
return errors.New("Invalid email address")
}
return nil
}
// SetFakePassword sets a stand-in password consisting of random text generated by filling in keySize bytes with
// random data and then base64 encoding those bytes.
//

View file

@ -200,6 +200,31 @@ func TestInviteCreateValidate(t *testing.T) {
}
}
func TestValidateEmailError(t *testing.T) {
errCases := []string{
"invalid",
"Name Surname <test@example.com>",
"test.com",
"",
}
for _, c := range errCases {
require.Error(t, ValidateEmail(c))
}
}
func TestValidateEmail(t *testing.T) {
cases := []string{
"user@example.com",
"user@example.localhost",
"user+1@example.com",
}
for _, c := range cases {
require.NoError(t, ValidateEmail(c))
}
}
func assertContainsErrorName(t *testing.T, invalid InvalidArgumentError, name string) {
for _, argErr := range invalid {
if argErr.name == name {

View file

@ -11,6 +11,7 @@ import (
"time"
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
"github.com/fleetdm/fleet/v4/server/datastore/redis/redistest"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/fleetdm/fleet/v4/server/test"
@ -30,13 +31,20 @@ func TestIntegrationsEnterprise(t *testing.T) {
type integrationEnterpriseTestSuite struct {
withServer
suite.Suite
redisPool fleet.RedisPool
}
func (s *integrationEnterpriseTestSuite) SetupSuite() {
s.withDS.SetupSuite("integrationEnterpriseTestSuite")
users, server := RunServerForTestsWithDS(
s.T(), s.ds, &TestServerOpts{License: &fleet.LicenseInfo{Tier: fleet.TierPremium}})
s.redisPool = redistest.SetupRedis(s.T(), "integration_enterprise", false, false, false)
config := TestServerOpts{
License: &fleet.LicenseInfo{
Tier: fleet.TierPremium,
},
Pool: s.redisPool,
}
users, server := RunServerForTestsWithDS(s.T(), s.ds, &config)
s.server = server
s.users = users
s.token = s.getTestAdminToken()
@ -1171,3 +1179,65 @@ func (s *integrationEnterpriseTestSuite) TestCustomTransparencyURL() {
require.NoError(t, deviceResp.Err)
require.Equal(t, fleet.DefaultTransparencyURL, rawResp.Header.Get("Location"))
}
func (s *integrationEnterpriseTestSuite) TestSSOJITProvisioning() {
t := s.T()
acResp := appConfigResponse{}
s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp)
require.NotNil(t, acResp)
require.False(t, acResp.SSOSettings.EnableJITProvisioning)
config := fleet.AppConfig{
SSOSettings: fleet.SSOSettings{
EnableSSO: true,
EntityID: "https://localhost:8080",
IssuerURI: "http://localhost:8080/simplesaml/saml2/idp/SSOService.php",
IDPName: "SimpleSAML",
MetadataURL: "http://localhost:9080/simplesaml/saml2/idp/metadata.php",
EnableJITProvisioning: false,
},
}
acResp = appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", config, http.StatusOK, &acResp)
require.NotNil(t, acResp)
require.False(t, acResp.SSOSettings.EnableJITProvisioning)
// users can't be created if SSO is disabled
auth, body := s.LoginSSOUser("sso_user", "user123#")
require.Contains(t, body, "/login?status=account_invalid")
// ensure theresn't a user in the DB
_, err := s.ds.UserByEmail(context.Background(), auth.UserID())
var nfe fleet.NotFoundError
require.ErrorAs(t, err, &nfe)
// enable JIT provisioning
config.SSOSettings.EnableJITProvisioning = true
acResp = appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", config, http.StatusOK, &acResp)
require.NotNil(t, acResp)
require.True(t, acResp.SSOSettings.EnableJITProvisioning)
// a new user is created and redirected accordingly
auth, body = s.LoginSSOUser("sso_user", "user123#")
// a successful redirect has this content
require.Contains(t, body, "Redirecting to Fleet at ...")
user, err := s.ds.UserByEmail(context.Background(), auth.UserID())
require.NoError(t, err)
require.Equal(t, auth.UserID(), user.Email)
// a new activity item is created
activitiesResp := listActivitiesResponse{}
s.DoJSON("GET", "/api/latest/fleet/activities", nil, http.StatusOK, &activitiesResp)
require.NoError(t, activitiesResp.Err)
require.NotEmpty(t, activitiesResp.Activities)
require.Condition(t, func() bool {
for _, a := range activitiesResp.Activities {
if *a.ActorEmail == auth.UserID() && a.Type == fleet.ActivityTypeUserAddedBySSO {
return true
}
}
return false
})
}

View file

@ -3,16 +3,17 @@ package service
import (
"bytes"
"compress/flate"
"context"
"encoding/base64"
"encoding/xml"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/fleetdm/fleet/v4/server/datastore/redis/redistest"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/fleetdm/fleet/v4/server/sso"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@ -43,21 +44,20 @@ func TestIntegrationsSSO(t *testing.T) {
func (s *integrationSSOTestSuite) TestGetSSOSettings() {
t := s.T()
// start a test server to serve as the SAML identity provider
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, samltestIdPMetadata)
}))
defer srv.Close()
config := fleet.AppConfig{
SSOSettings: fleet.SSOSettings{
EnableSSO: true,
EntityID: "https://localhost:8080",
IssuerURI: "http://localhost:8080/simplesaml/saml2/idp/SSOService.php",
IDPName: "SimpleSAML",
MetadataURL: "http://localhost:9080/simplesaml/saml2/idp/metadata.php",
EnableJITProvisioning: false,
},
}
// enable SSO
spec := []byte(fmt.Sprintf(`
sso_settings:
enable_sso: true
metadata_url: %s
entity_id: https://samltest.id/saml/idp
idp_name: SAMLtestIdP
`, srv.URL))
s.applyConfig(spec)
acResp := appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", config, http.StatusOK, &acResp)
require.NotNil(t, acResp)
// double-check the settings
var resGet ssoSettingsResponse
@ -75,11 +75,67 @@ func (s *integrationSSOTestSuite) TestGetSSOSettings() {
encoded := q.Get("SAMLRequest")
assert.NotEmpty(t, encoded)
authReq := inflate(t, encoded)
assert.Equal(t, "https://samltest.id/saml/idp", authReq.Issuer.Url)
assert.Equal(t, "https://localhost:8080", authReq.Issuer.Url)
assert.Equal(t, "Fleet", authReq.ProviderName)
assert.True(t, strings.HasPrefix(authReq.ID, "id"), authReq.ID)
}
func (s *integrationSSOTestSuite) TestSSOLogin() {
t := s.T()
config := fleet.AppConfig{
SSOSettings: fleet.SSOSettings{
EnableSSO: true,
EntityID: "https://localhost:8080",
IssuerURI: "http://localhost:8080/simplesaml/saml2/idp/SSOService.php",
IDPName: "SimpleSAML",
MetadataURL: "http://localhost:9080/simplesaml/saml2/idp/metadata.php",
},
}
acResp := appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", config, http.StatusOK, &acResp)
require.NotNil(t, acResp)
// users can't login if they don't have an account on free plans
_, body := s.LoginSSOUser("sso_user", "user123#")
require.Contains(t, body, "/login?status=account_invalid")
// users can't login if they don't have an account on free plans
// even if JIT provisioning is enabled
ac, err := s.ds.AppConfig(context.Background())
ac.SSOSettings.EnableJITProvisioning = true
require.NoError(t, err)
err = s.ds.SaveAppConfig(context.Background(), ac)
require.NoError(t, err)
_, body = s.LoginSSOUser("sso_user", "user123#")
require.Contains(t, body, "/login?status=account_invalid")
// an user created by an admin without SSOEnabled can't log-in
params := fleet.UserPayload{
Name: ptr.String("SSO User 1"),
Email: ptr.String("sso_user@example.com"),
GlobalRole: ptr.String(fleet.RoleObserver),
SSOEnabled: ptr.Bool(false),
}
s.Do("POST", "/api/latest/fleet/users/admin", &params, http.StatusUnprocessableEntity)
_, body = s.LoginSSOUser("sso_user", "user123#")
require.Contains(t, body, "/login?status=account_invalid")
// an user created by an admin with SSOEnabled is able to log-in
params = fleet.UserPayload{
Name: ptr.String("SSO User 2"),
Email: ptr.String("sso_user2@example.com"),
GlobalRole: ptr.String(fleet.RoleObserver),
SSOEnabled: ptr.Bool(true),
}
s.Do("POST", "/api/latest/fleet/users/admin", &params, http.StatusOK)
auth, body := s.LoginSSOUser("sso_user2", "user123#")
assert.Equal(t, "sso_user2@example.com", auth.UserID())
assert.Equal(t, "SSO User 2", auth.UserDisplayName())
require.Contains(t, body, "Redirecting to Fleet at ...")
}
func inflate(t *testing.T, s string) *sso.AuthnRequest {
t.Helper()
@ -93,130 +149,3 @@ func inflate(t *testing.T, s string) *sso.AuthnRequest {
require.NoError(t, xml.NewDecoder(r).Decode(&req))
return &req
}
const (
samltestIdPMetadata = `
<!-- The entity describing the SAMLtest IdP, named by the entityID below -->
<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" ID="SAMLtestIdP" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" xmlns:shibmd="urn:mace:shibboleth:metadata:1.0" xmlns:xml="http://www.w3.org/XML/1998/namespace" xmlns:mdui="urn:oasis:names:tc:SAML:metadata:ui" validUntil="2100-01-01T00:00:42Z" entityID="https://samltest.id/saml/idp">
<IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol urn:oasis:names:tc:SAML:1.1:protocol urn:mace:shibboleth:1.0">
<Extensions>
<!-- An enumeration of the domains this IdP is able to assert scoped attributes, which are
typically those with a @ delimiter, like mail. Most IdP's serve only a single domain. It's crucial
for the SP to check received attribute values match permitted domains to prevent a recognized IdP from
sending attribute values for which a different recognized IdP is authoritative. -->
<shibmd:Scope regexp="false">samltest.id</shibmd:Scope>
<!-- Display information about this IdP that can be used by SP's and discovery
services to identify the IdP meaningfully for end users -->
<mdui:UIInfo>
<mdui:DisplayName xml:lang="en">SAMLtest IdP</mdui:DisplayName>
<mdui:Description xml:lang="en">A free and basic IdP for testing SAML deployments</mdui:Description>
<mdui:Logo height="90" width="225">https://samltest.id/saml/logo.png</mdui:Logo>
</mdui:UIInfo>
</Extensions>
<KeyDescriptor use="signing">
<ds:KeyInfo>
<ds:X509Data>
<ds:X509Certificate>
MIIDETCCAfmgAwIBAgIUZRpDhkNKl5eWtJqk0Bu1BgTTargwDQYJKoZIhvcNAQEL
BQAwFjEUMBIGA1UEAwwLc2FtbHRlc3QuaWQwHhcNMTgwODI0MjExNDEwWhcNMzgw
ODI0MjExNDEwWjAWMRQwEgYDVQQDDAtzYW1sdGVzdC5pZDCCASIwDQYJKoZIhvcN
AQEBBQADggEPADCCAQoCggEBAJrh9/PcDsiv3UeL8Iv9rf4WfLPxuOm9W6aCntEA
8l6c1LQ1Zyrz+Xa/40ZgP29ENf3oKKbPCzDcc6zooHMji2fBmgXp6Li3fQUzu7yd
+nIC2teejijVtrNLjn1WUTwmqjLtuzrKC/ePoZyIRjpoUxyEMJopAd4dJmAcCq/K
k2eYX9GYRlqvIjLFoGNgy2R4dWwAKwljyh6pdnPUgyO/WjRDrqUBRFrLQJorR2kD
c4seZUbmpZZfp4MjmWMDgyGM1ZnR0XvNLtYeWAyt0KkSvFoOMjZUeVK/4xR74F8e
8ToPqLmZEg9ZUx+4z2KjVK00LpdRkH9Uxhh03RQ0FabHW6UCAwEAAaNXMFUwHQYD
VR0OBBYEFJDbe6uSmYQScxpVJhmt7PsCG4IeMDQGA1UdEQQtMCuCC3NhbWx0ZXN0
LmlkhhxodHRwczovL3NhbWx0ZXN0LmlkL3NhbWwvaWRwMA0GCSqGSIb3DQEBCwUA
A4IBAQBNcF3zkw/g51q26uxgyuy4gQwnSr01Mhvix3Dj/Gak4tc4XwvxUdLQq+jC
cxr2Pie96klWhY/v/JiHDU2FJo9/VWxmc/YOk83whvNd7mWaNMUsX3xGv6AlZtCO
L3JhCpHjiN+kBcMgS5jrtGgV1Lz3/1zpGxykdvS0B4sPnFOcaCwHe2B9SOCWbDAN
JXpTjz1DmJO4ImyWPJpN1xsYKtm67Pefxmn0ax0uE2uuzq25h0xbTkqIQgJzyoE/
DPkBFK1vDkMfAW11dQ0BXatEnW7Gtkc0lh2/PIbHWj4AzxYMyBf5Gy6HSVOftwjC
voQR2qr2xJBixsg+MIORKtmKHLfU
</ds:X509Certificate>
</ds:X509Data>
</ds:KeyInfo>
</KeyDescriptor>
<KeyDescriptor use="signing">
<ds:KeyInfo>
<ds:X509Data>
<ds:X509Certificate>
MIIDEjCCAfqgAwIBAgIVAMECQ1tjghafm5OxWDh9hwZfxthWMA0GCSqGSIb3DQEB
CwUAMBYxFDASBgNVBAMMC3NhbWx0ZXN0LmlkMB4XDTE4MDgyNDIxMTQwOVoXDTM4
MDgyNDIxMTQwOVowFjEUMBIGA1UEAwwLc2FtbHRlc3QuaWQwggEiMA0GCSqGSIb3
DQEBAQUAA4IBDwAwggEKAoIBAQC0Z4QX1NFKs71ufbQwoQoW7qkNAJRIANGA4iM0
ThYghul3pC+FwrGv37aTxWXfA1UG9njKbbDreiDAZKngCgyjxj0uJ4lArgkr4AOE
jj5zXA81uGHARfUBctvQcsZpBIxDOvUUImAl+3NqLgMGF2fktxMG7kX3GEVNc1kl
bN3dfYsaw5dUrw25DheL9np7G/+28GwHPvLb4aptOiONbCaVvh9UMHEA9F7c0zfF
/cL5fOpdVa54wTI0u12CsFKt78h6lEGG5jUs/qX9clZncJM7EFkN3imPPy+0HC8n
spXiH/MZW8o2cqWRkrw3MzBZW3Ojk5nQj40V6NUbjb7kfejzAgMBAAGjVzBVMB0G
A1UdDgQWBBQT6Y9J3Tw/hOGc8PNV7JEE4k2ZNTA0BgNVHREELTArggtzYW1sdGVz
dC5pZIYcaHR0cHM6Ly9zYW1sdGVzdC5pZC9zYW1sL2lkcDANBgkqhkiG9w0BAQsF
AAOCAQEASk3guKfTkVhEaIVvxEPNR2w3vWt3fwmwJCccW98XXLWgNbu3YaMb2RSn
7Th4p3h+mfyk2don6au7Uyzc1Jd39RNv80TG5iQoxfCgphy1FYmmdaSfO8wvDtHT
TNiLArAxOYtzfYbzb5QrNNH/gQEN8RJaEf/g/1GTw9x/103dSMK0RXtl+fRs2nbl
D1JJKSQ3AdhxK/weP3aUPtLxVVJ9wMOQOfcy02l+hHMb6uAjsPOpOVKqi3M8XmcU
ZOpx4swtgGdeoSpeRyrtMvRwdcciNBp9UZome44qZAYH1iqrpmmjsfI9pJItsgWu
3kXPjhSfj1AJGR1l9JGvJrHki1iHTA==
</ds:X509Certificate>
</ds:X509Data>
</ds:KeyInfo>
</KeyDescriptor>
<KeyDescriptor use="encryption">
<ds:KeyInfo>
<ds:X509Data>
<ds:X509Certificate>
MIIDEjCCAfqgAwIBAgIVAPVbodo8Su7/BaHXUHykx0Pi5CFaMA0GCSqGSIb3DQEB
CwUAMBYxFDASBgNVBAMMC3NhbWx0ZXN0LmlkMB4XDTE4MDgyNDIxMTQwOVoXDTM4
MDgyNDIxMTQwOVowFjEUMBIGA1UEAwwLc2FtbHRlc3QuaWQwggEiMA0GCSqGSIb3
DQEBAQUAA4IBDwAwggEKAoIBAQCQb+1a7uDdTTBBFfwOUun3IQ9nEuKM98SmJDWa
MwM877elswKUTIBVh5gB2RIXAPZt7J/KGqypmgw9UNXFnoslpeZbA9fcAqqu28Z4
sSb2YSajV1ZgEYPUKvXwQEmLWN6aDhkn8HnEZNrmeXihTFdyr7wjsLj0JpQ+VUlc
4/J+hNuU7rGYZ1rKY8AA34qDVd4DiJ+DXW2PESfOu8lJSOteEaNtbmnvH8KlwkDs
1NvPTsI0W/m4SK0UdXo6LLaV8saIpJfnkVC/FwpBolBrRC/Em64UlBsRZm2T89ca
uzDee2yPUvbBd5kLErw+sC7i4xXa2rGmsQLYcBPhsRwnmBmlAgMBAAGjVzBVMB0G
A1UdDgQWBBRZ3exEu6rCwRe5C7f5QrPcAKRPUjA0BgNVHREELTArggtzYW1sdGVz
dC5pZIYcaHR0cHM6Ly9zYW1sdGVzdC5pZC9zYW1sL2lkcDANBgkqhkiG9w0BAQsF
AAOCAQEABZDFRNtcbvIRmblnZItoWCFhVUlq81ceSQddLYs8DqK340//hWNAbYdj
WcP85HhIZnrw6NGCO4bUipxZXhiqTA/A9d1BUll0vYB8qckYDEdPDduYCOYemKkD
dmnHMQWs9Y6zWiYuNKEJ9mf3+1N8knN/PK0TYVjVjXAf2CnOETDbLtlj6Nqb8La3
sQkYmU+aUdopbjd5JFFwbZRaj6KiHXHtnIRgu8sUXNPrgipUgZUOVhP0C0N5OfE4
JW8ZBrKgQC/6vJ2rSa9TlzI6JAa5Ww7gMXMP9M+cJUNQklcq+SBnTK8G+uBHgPKR
zBDsMIEzRtQZm4GIoHJae4zmnCekkQ==
</ds:X509Certificate>
</ds:X509Data>
</ds:KeyInfo>
</KeyDescriptor>
<!-- An endpoint for artifact resolution. Please see Wikipedia for more details about SAML
artifacts and when you may find them useful. -->
<ArtifactResolutionService Binding="urn:oasis:names:tc:SAML:2.0:bindings:SOAP" Location="https://samltest.id/idp/profile/SAML2/SOAP/ArtifactResolution" index="1" />
<!-- A set of endpoints where the IdP can receive logout messages. These must match the public
facing addresses if this IdP is hosted behind a reverse proxy. -->
<SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://samltest.id/idp/profile/SAML2/Redirect/SLO"/>
<SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://samltest.id/idp/profile/SAML2/POST/SLO"/>
<SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST-SimpleSign" Location="https://samltest.id/idp/profile/SAML2/POST-SimpleSign/SLO"/>
<!-- A set of endpoints the SP can send AuthnRequests to in order to trigger user authentication. -->
<SingleSignOnService Binding="urn:mace:shibboleth:1.0:profiles:AuthnRequest" Location="https://samltest.id/idp/profile/Shibboleth/SSO"/>
<SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://samltest.id/idp/profile/SAML2/POST/SSO"/>
<SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST-SimpleSign" Location="https://samltest.id/idp/profile/SAML2/POST-SimpleSign/SSO"/>
<SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://samltest.id/idp/profile/SAML2/Redirect/SSO"/>
<SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:SOAP" Location="https://samltest.id/idp/profile/SAML2/SOAP/ECP"/>
</IDPSSODescriptor>
</EntityDescriptor>
`
)

View file

@ -34,7 +34,7 @@ func (mw metricsMiddleware) CallbackSSO(ctx context.Context, auth fleet.Auth) (s
mw.requestCount.With(lvs...).Add(1)
mw.requestLatency.With(lvs...).Observe(time.Since(begin).Seconds())
}(time.Now())
sess, err = mw.Service.CallbackSSO(ctx, auth)
sess, err = getSSOSession(ctx, mw.Service, auth)
return
}

View file

@ -25,10 +25,10 @@ func (svc *Service) CreateInitialUser(ctx context.Context, p fleet.UserPayload)
p.GlobalRole = ptr.String(fleet.RoleAdmin)
p.Teams = nil
return svc.newUser(ctx, p)
return svc.NewUser(ctx, p)
}
func (svc *Service) newUser(ctx context.Context, p fleet.UserPayload) (*fleet.User, error) {
func (svc *Service) NewUser(ctx context.Context, p fleet.UserPayload) (*fleet.User, error) {
user, err := p.User(svc.config.Auth.SaltKeySize, svc.config.Auth.BcryptCost)
if err != nil {
return nil, err

View file

@ -348,7 +348,7 @@ func (r callbackSSOResponse) html() string { return r.content }
func makeCallbackSSOEndpoint(urlPrefix string) handlerFunc {
return func(ctx context.Context, request interface{}, svc fleet.Service) (interface{}, error) {
authResponse := request.(fleet.Auth)
session, err := svc.CallbackSSO(ctx, authResponse)
session, err := getSSOSession(ctx, svc, authResponse)
var resp callbackSSOResponse
if err != nil {
var ssoErr ssoError
@ -390,7 +390,21 @@ func makeCallbackSSOEndpoint(urlPrefix string) handlerFunc {
}
}
func (svc *Service) CallbackSSO(ctx context.Context, auth fleet.Auth) (*fleet.SSOSession, error) {
func getSSOSession(ctx context.Context, svc fleet.Service, auth fleet.Auth) (*fleet.SSOSession, error) {
redirectURL, err := svc.InitSSOCallback(ctx, auth)
if err != nil {
return nil, err
}
user, err := svc.GetSSOUser(ctx, auth)
if err != nil {
return nil, err
}
return svc.LoginSSOUser(ctx, user, redirectURL)
}
func (svc *Service) InitSSOCallback(ctx context.Context, auth fleet.Auth) (string, error) {
// skipauth: User context does not yet exist. Unauthenticated users may
// hit the SSO callback.
svc.authz.SkipAuthorization(ctx)
@ -399,12 +413,12 @@ func (svc *Service) CallbackSSO(ctx context.Context, auth fleet.Auth) (*fleet.SS
appConfig, err := svc.ds.AppConfig(ctx)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "get config for sso")
return "", ctxerr.Wrap(ctx, err, "get config for sso")
}
if !appConfig.SSOSettings.EnableSSO {
err := ctxerr.New(ctx, "organization not configured to use sso")
return nil, ctxerr.Wrap(ctx, ssoError{err: err, code: ssoOrgDisabled}, "callback sso")
return "", ctxerr.Wrap(ctx, ssoError{err: err, code: ssoOrgDisabled}, "callback sso")
}
// Load the request metadata if available
@ -418,21 +432,21 @@ func (svc *Service) CallbackSSO(ctx context.Context, auth fleet.Auth) (*fleet.SS
// configured to do so.
metadata, err = svc.getMetadata(appConfig)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "get sso metadata")
return "", ctxerr.Wrap(ctx, err, "get sso metadata")
}
redirectURL = "/"
} else {
session, err := svc.ssoSessionStore.Get(auth.RequestID())
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "sso request invalid")
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 nil, ctxerr.Wrap(ctx, err, "remove sso request")
return "", ctxerr.Wrap(ctx, err, "remove sso request")
}
if err := xml.Unmarshal([]byte(session.Metadata), &metadata); err != nil {
return nil, ctxerr.Wrap(ctx, err, "unmarshal metadata")
return "", ctxerr.Wrap(ctx, err, "unmarshal metadata")
}
redirectURL = session.OriginalURL
}
@ -444,20 +458,23 @@ func (svc *Service) CallbackSSO(ctx context.Context, auth fleet.Auth) (*fleet.SS
appConfig.ServerSettings.ServerURL+svc.config.Server.URLPrefix+"/api/v1/fleet/sso/callback", // ACS
))
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "create validator from metadata")
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 nil, ctxerr.Wrap(ctx, err, "signature validation failed")
return "", ctxerr.Wrap(ctx, err, "signature validation failed")
}
// make sure the response isn't stale
err = validator.ValidateResponse(auth)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "response validation failed")
return "", ctxerr.Wrap(ctx, err, "response validation failed")
}
// Get and log in user
return redirectURL, nil
}
func (svc *Service) GetSSOUser(ctx context.Context, auth fleet.Auth) (*fleet.User, error) {
user, err := svc.ds.UserByEmail(ctx, auth.UserID())
if err != nil {
var nfe notFoundErrorInterface
@ -466,6 +483,10 @@ func (svc *Service) CallbackSSO(ctx context.Context, auth fleet.Auth) (*fleet.SS
}
return nil, ctxerr.Wrap(ctx, err, "find user in sso callback")
}
return user, nil
}
func (svc *Service) LoginSSOUser(ctx context.Context, user *fleet.User, redirectURL string) (*fleet.SSOSession, error) {
// if the user is not sso enabled they are not authorized
if !user.SSOEnabled {
err := ctxerr.New(ctx, "user not configured to use sso")

View file

@ -6,12 +6,17 @@ import (
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/url"
"os"
"regexp"
"github.com/fleetdm/fleet/v4/pkg/fleethttp"
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/pubsub"
"github.com/fleetdm/fleet/v4/server/sso"
"github.com/fleetdm/fleet/v4/server/test"
"github.com/ghodss/yaml"
"github.com/stretchr/testify/assert"
@ -172,3 +177,57 @@ func (ts *withServer) getConfig() *appConfigResponse {
ts.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &responseBody)
return responseBody
}
func (ts *withServer) LoginSSOUser(username, password string) (fleet.Auth, string) {
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)
jar, err := cookiejar.New(nil)
require.NoError(t, err)
client := fleethttp.NewClient(
fleethttp.WithFollowRedir(false),
fleethttp.WithCookieJar(jar),
)
resp, err := client.Get(resIni.URL)
require.NoError(t, err)
// From the redirect Location header we can get the AuthState and the URL to
// which we submit the credentials
parsed, err := url.Parse(resp.Header.Get("Location"))
require.NoError(t, err)
data := url.Values{
"username": {username},
"password": {password},
"AuthState": {parsed.Query().Get("AuthState")},
}
resp, err = client.PostForm(parsed.Scheme+"://"+parsed.Host+parsed.Path, data)
require.NoError(t, err)
// The response is an HTML form, we can extract the base64-encoded response
// to submit to the Fleet server from here
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
re := regexp.MustCompile(`value="(.*)"`)
matches := re.FindSubmatch(body)
require.NotEmptyf(t, matches, "callback HTML doesn't contain a SAMLResponse value, got body: %s", body)
rawSSOResp := string(matches[1])
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)
defer res.Body.Close()
body, err = io.ReadAll(res.Body)
require.NoError(t, err)
return auth, string(body)
}

View file

@ -65,7 +65,7 @@ func (svc *Service) CreateUser(ctx context.Context, p fleet.UserPayload) (*fleet
p.AdminForcedPasswordReset = ptr.Bool(true)
}
return svc.newUser(ctx, p)
return svc.NewUser(ctx, p)
}
////////////////////////////////////////////////////////////////////////////////
@ -99,7 +99,7 @@ func (svc *Service) CreateUserFromInvite(ctx context.Context, p fleet.UserPayloa
p.GlobalRole = invite.GlobalRole.Ptr()
p.Teams = &invite.Teams
user, err := svc.newUser(ctx, p)
user, err := svc.NewUser(ctx, p)
if err != nil {
return nil, err
}

View file

@ -63,6 +63,21 @@ var statusMap = map[string]int{
"urn:oasis:names:tc:SAML:2.0:status:UnsupportedBinding": UnsupportedBinding,
}
// Since there's not a standard for display names, I have collected the most
// commonly used attribute names for it.
//
// Most of the items here come from:
//
// - https://docs.ldap.com/specs/rfc2798.txt
// - https://docs.microsoft.com/en-us/windows-server/identity/ad-fs/technical-reference/the-role-of-claims
var validDisplayNameAttrs = map[string]struct{}{
"name": {},
"displayname": {},
"cn": {},
"urn:oid:2.5.4.3": {},
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name": {},
}
type resp struct {
response *Response
rawResp string
@ -86,6 +101,22 @@ func (r resp) UserID() string {
return ""
}
func (r resp) UserDisplayName() string {
if r.response != nil {
for _, attr := range r.response.Assertion.AttributeStatement.Attributes {
if _, ok := validDisplayNameAttrs[attr.Name]; ok {
for _, v := range attr.AttributeValues {
if v.Value != "" {
return v.Value
}
}
}
}
}
return ""
}
func (r resp) status() (int, error) {
if r.response != nil {
statusURI := r.response.Status.StatusCode.Value

File diff suppressed because one or more lines are too long

View file

@ -13,11 +13,13 @@ $config = array(
'sso_user:user123#' => array(
'uid' => array('1'),
'eduPersonAffiliation' => array('group1'),
'displayname' => array('SSO User 1'),
'email' => 'sso_user@example.com',
),
'sso_user2:user123#' => array(
'uid' => array('2'),
'eduPersonAffiliation' => array('group1'),
'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name' => array('SSO User 2'),
'email' => 'sso_user2@example.com',
),
),