Files
Juan Fernandez e91a0b2987 Normalize login responses for MFA-enabled accounts
Make failed logins for MFA-enabled accounts return a consistent response
and timing regardless of the cause, in line with authentication best
practices. Guidance for CLI users whose client can't complete email
verification is now surfaced by fleetctl on any login failure.

Added a `user_mfa_requested` activity, recorded when valid credentials
are submitted for an MFA-enabled account and a verification email is
sent.
2026-07-23 06:41:27 -04:00

773 lines
22 KiB
Go

package service
import (
"context"
"encoding/base64"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
activity_api "github.com/fleetdm/fleet/v4/server/activity/api"
"github.com/fleetdm/fleet/v4/server/config"
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
"github.com/fleetdm/fleet/v4/server/datastore/mysql/mysqltest"
"github.com/fleetdm/fleet/v4/server/datastore/redis/redistest"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mock"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/fleetdm/fleet/v4/server/test"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// testSSOMetadata returns a valid SAML metadata XML for testing
func testSSOMetadata() string {
return `<?xml version="1.0"?>
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="test-idp">
<md:IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<md:KeyDescriptor use="signing">
<ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:X509Data>
<ds:X509Certificate>MIIDXTCCAkWgAwIBAgIJALmVVuDWu4NYMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwHhcNMTYxMjMxMTQzNDQ3WhcNNDgwNjI1MTQzNDQ3WjBFMQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzUCFozgNb1h1M0jzNRSCjhOBnR+uVbVpaWfXYIR+AhWDdEe5ryY+CgavOg8bfLybyzFdehlYdDRgkedEB/GjG8aJw06l0qF4jDOAw0kEygWCu2mcH7XOxRt+YAH3TVHa/Hu1W3WjzkobqqqLQ8gkKWWM27fOgAZ6GieaJBN6VBSMMcPey3HWLBmc+TYJmv1dbaO2jHhKh8pfKw0W12VM8P1PIO8gv4Phu/uuJYieBWKixBEyy0lHjyixYFCR12xdh4CA47q958ZRGnnDUGFVE1QhgRacJCOZ9bd5t9mr8KLaVBYTCJo5ERE8jymab5dPqe5qKfJsCZiqWglbjUo9twIDAQABo1AwTjAdBgNVHQ4EFgQUxpuwcs/CYQOyui+r1G+3KxBNhxkwHwYDVR0jBBgwFoAUxpuwcs/CYQOyui+r1G+3KxBNhxkwDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAAiWUKs/2x/viNCKi3Y6blEuCtAGhzOOZ9EjrvJ8+COH3Rag3tVBWrcBZ3/uhhPq5gy9lqw4OkvEws99/5jFsX1FJ6MKBgqfuy7yh5s1YfM0ANHYczMmYpZeAcQf2CGAaVfwTTfSlzNLsF2lW/ly7yapFzlYSJLGoVE+OHEu8g5SlNACUEfkXw+5Eghh+KzlIN7R6Q7r2ixWNFBC/jWf7NKUfJyX8qIG5md1YUeT6GBW9Bm2/1/RiO24JTaYlfLdKK9TYb8sG5B+OLab2DImG99CJ25RkAcSobWNF5zD0O6lgOo3cEdB/ksCq3hmtlC/DlLZ/D8CJ+7VuZnS1rR2naQ==</ds:X509Certificate>
</ds:X509Data>
</ds:KeyInfo>
</md:KeyDescriptor>
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://idp.example.com/sso"/>
</md:IDPSSODescriptor>
</md:EntityDescriptor>`
}
func TestSessionAuth(t *testing.T) {
ds := new(mock.Store)
svc, ctx := newTestService(t, ds, nil, nil)
ds.ListSessionsForUserFunc = func(ctx context.Context, id uint) ([]*fleet.Session, error) {
if id == 999 {
return []*fleet.Session{
{ID: 1, UserID: id, AccessedAt: time.Now()},
}, nil
}
return nil, nil
}
ds.SessionByIDFunc = func(ctx context.Context, id uint) (*fleet.Session, error) {
return &fleet.Session{ID: id, UserID: 999, AccessedAt: time.Now()}, nil
}
ds.DestroySessionFunc = func(ctx context.Context, ssn *fleet.Session) error {
return nil
}
ds.MarkSessionAccessedFunc = func(ctx context.Context, ssn *fleet.Session) error {
return nil
}
testCases := []struct {
name string
user *fleet.User
shouldFailWrite bool
shouldFailRead bool
}{
{
"global admin",
&fleet.User{ID: 111, GlobalRole: ptr.String(fleet.RoleAdmin)},
false,
false,
},
{
"global maintainer",
&fleet.User{ID: 111, GlobalRole: ptr.String(fleet.RoleMaintainer)},
true,
true,
},
{
"global observer",
&fleet.User{ID: 111, GlobalRole: ptr.String(fleet.RoleObserver)},
true,
true,
},
{
"owner user",
&fleet.User{ID: 999},
false,
false,
},
{
"non-owner user",
&fleet.User{ID: 888},
true,
true,
},
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
ctx := viewer.NewContext(ctx, viewer.Viewer{User: tt.user})
_, err := svc.GetInfoAboutSessionsForUser(ctx, 999)
checkAuthErr(t, tt.shouldFailRead, err)
_, err = svc.GetInfoAboutSession(ctx, 1)
checkAuthErr(t, tt.shouldFailRead, err)
err = svc.DeleteSession(ctx, 1)
checkAuthErr(t, tt.shouldFailWrite, err)
})
}
}
func TestAuthenticate(t *testing.T) {
ds := mysqltest.CreateMySQLDS(t)
defer ds.Close()
svc, ctx := newTestService(t, ds, nil, nil)
createTestUsers(t, ds)
loginTests := []struct {
name string
email string
password string
wantErr error
}{
{
name: "admin1",
email: testUsers["admin1"].Email,
password: testUsers["admin1"].PlaintextPassword,
},
{
name: "user1",
email: testUsers["user1"].Email,
password: testUsers["user1"].PlaintextPassword,
},
}
for _, tt := range loginTests {
t.Run(tt.email, func(st *testing.T) {
loggedIn, token, err := svc.Login(test.UserContext(ctx, test.UserAdmin), tt.email, tt.password, false)
require.Nil(st, err, "login unsuccessful")
assert.Equal(st, tt.email, loggedIn.Email)
assert.NotEmpty(st, token)
sessions, err := svc.GetInfoAboutSessionsForUser(test.UserContext(ctx, test.UserAdmin), loggedIn.ID)
require.Nil(st, err)
require.Len(st, sessions, 1, "user should have one session")
session := sessions[0]
assert.NotZero(st, session.UserID)
assert.WithinDuration(st, time.Now(), session.AccessedAt, 3*time.Second,
"access time should be set with current time at session creation")
})
}
}
func TestMFA(t *testing.T) {
ds := new(mock.Store)
opts := &TestServerOpts{}
svc, ctx := newTestService(t, ds, nil, nil, opts)
user := &fleet.User{MFAEnabled: true, Name: "Bob Smith", Email: "foo@example.com"}
require.NoError(t, user.SetPassword(test.GoodPassword, 10, 10))
ds.UserByEmailFunc = func(ctx context.Context, email string) (*fleet.User, error) {
return user, nil
}
var failedLoginActivity bool
opts.ActivityMock.NewActivityFunc = func(_ context.Context, _ *activity_api.User, activity activity_api.ActivityDetails) error {
if activity.ActivityName() == (fleet.ActivityTypeUserFailedLogin{}).ActivityName() {
failedLoginActivity = true
}
return nil
}
_, _, err := svc.Login(ctx, "foo@example.com", test.GoodPassword, false)
var authErr *fleet.AuthFailedError
require.ErrorAs(t, err, &authErr)
require.Equal(t, "Authentication failed", err.Error())
require.True(t, failedLoginActivity)
var sentMail fleet.Email
mailer := &mockMailService{SendEmailFn: func(e fleet.Email) error {
sentMail = e
return nil
}}
mfaToken := "foovalidate"
ds.NewMFATokenFunc = func(ctx context.Context, userID uint) (string, error) {
return mfaToken, nil
}
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{}, nil
}
innerSvc := &Service{
ds: ds,
config: config.TestConfig(),
mailService: mailer,
}
var mfaRequestedActivity bool
innerSvc.SetActivityService(&mock.MockActivityService{
NewActivityFunc: func(_ context.Context, _ *activity_api.User, activity activity_api.ActivityDetails) error {
if activity.ActivityName() == (fleet.ActivityTypeUserMFARequested{}).ActivityName() {
mfaRequestedActivity = true
}
return nil
},
})
svcForMailing := validationMiddleware{innerSvc, ds, nil}
_, _, err = svcForMailing.Login(ctx, "foo@example.com", test.GoodPassword, true)
require.Equal(t, err, sendingMFAEmail)
require.Equal(t, "foo@example.com", sentMail.To[0])
require.Equal(t, "Log in to Fleet", sentMail.Subject)
require.True(t, mfaRequestedActivity)
var session *fleet.Session
var mfaUser *fleet.User
ds.SessionByMFATokenFunc = func(ctx context.Context, token string, sessionKeySize int) (*fleet.Session, *fleet.User, error) {
if token == mfaToken {
return session, mfaUser, nil
}
return nil, nil, &notFoundErr{}
}
resp, err := sessionCreateEndpoint(ctx, &sessionCreateRequest{Token: "foo"}, svc)
require.NoError(t, err)
require.NotNil(t, resp.Error())
session = &fleet.Session{}
mfaUser = user
opts.ActivityMock.NewActivityFunc = func(_ context.Context, user *activity_api.User, activity activity_api.ActivityDetails) error {
require.Equal(t, mfaUser.Email, user.Email)
require.Equal(t, fleet.ActivityTypeUserLoggedIn{}.ActivityName(), activity.ActivityName())
return nil
}
resp, err = sessionCreateEndpoint(ctx, &sessionCreateRequest{Token: mfaToken}, svc)
require.NoError(t, err)
require.Nil(t, resp.Error())
require.True(t, opts.ActivityMock.NewActivityFuncInvoked)
}
func TestGetSessionByKey(t *testing.T) {
ds := new(mock.Store)
svc, ctx := newTestService(t, ds, nil, nil)
cfg := config.TestConfig()
theSession := &fleet.Session{UserID: 123, Key: "abc"}
ds.SessionByKeyFunc = func(ctx context.Context, key string) (*fleet.Session, error) {
return theSession, nil
}
ds.DestroySessionFunc = func(ctx context.Context, ssn *fleet.Session) error {
return nil
}
ds.MarkSessionAccessedFunc = func(ctx context.Context, ssn *fleet.Session) error {
return nil
}
cases := []struct {
desc string
accessed time.Duration
apiOnly bool
fail bool
}{
{"real user, accessed recently", -1 * time.Hour, false, false},
{"real user, accessed too long ago", -(cfg.Session.Duration + time.Hour), false, true},
{"api-only, accessed recently", -1 * time.Hour, true, false},
{"api-only, accessed long ago", -(cfg.Session.Duration + time.Hour), true, false},
}
for _, tc := range cases {
t.Run(tc.desc, func(t *testing.T) {
var authErr *fleet.AuthRequiredError
ds.SessionByKeyFuncInvoked, ds.DestroySessionFuncInvoked, ds.MarkSessionAccessedFuncInvoked = false, false, false
theSession.AccessedAt = time.Now().Add(tc.accessed)
theSession.APIOnly = ptr.Bool(tc.apiOnly)
_, err := svc.GetSessionByKey(ctx, theSession.Key)
if tc.fail {
require.Error(t, err)
require.ErrorAs(t, err, &authErr)
require.True(t, ds.SessionByKeyFuncInvoked)
require.True(t, ds.DestroySessionFuncInvoked)
require.False(t, ds.MarkSessionAccessedFuncInvoked)
} else {
require.NoError(t, err)
require.True(t, ds.SessionByKeyFuncInvoked)
require.False(t, ds.DestroySessionFuncInvoked)
require.True(t, ds.MarkSessionAccessedFuncInvoked)
}
})
}
}
type testAuth struct {
userID string
userDisplayName string
requestID string
assertionAttributes []fleet.SAMLAttribute
}
var _ fleet.Auth = (*testAuth)(nil)
func (a *testAuth) UserID() string {
return a.userID
}
func (a *testAuth) UserDisplayName() string {
return a.userDisplayName
}
func (a *testAuth) RequestID() string {
return a.requestID
}
func (a *testAuth) AssertionAttributes() []fleet.SAMLAttribute {
return a.assertionAttributes
}
func (a *testAuth) RawResponse() []byte {
return nil
}
func TestGetSSOUser(t *testing.T) {
ds := new(mock.Store)
svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{
License: &fleet.LicenseInfo{
Tier: fleet.TierPremium,
},
})
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{
SSOSettings: &fleet.SSOSettings{
EnableSSO: true,
EnableSSOIdPLogin: true,
EnableJITProvisioning: true,
},
}, nil
}
ds.UserByEmailFunc = func(ctx context.Context, email string) (*fleet.User, error) {
return nil, newNotFoundError()
}
var newUser *fleet.User
ds.NewUserFunc = func(ctx context.Context, user *fleet.User) (*fleet.User, error) {
newUser = user
return user, nil
}
auth := &testAuth{
userID: "foo@example.com",
userDisplayName: "foo@example.com",
requestID: "foobar",
assertionAttributes: []fleet.SAMLAttribute{
{
Name: "FLEET_JIT_USER_ROLE_GLOBAL",
Values: []fleet.SAMLAttributeValue{
{Value: "admin"},
},
},
},
}
// Test SSO login with a non-existent user.
_, err := svc.GetSSOUser(ctx, auth)
require.NoError(t, err)
require.NotNil(t, newUser)
require.NotNil(t, newUser.GlobalRole)
require.Equal(t, "admin", *newUser.GlobalRole)
require.Empty(t, newUser.Teams)
// Test SSO login with the same (now existing) user (should update roles).
// (1) Check that when a user's role attributes are unchanged then SavedUser is not called.
ds.SaveUserFunc = func(ctx context.Context, user *fleet.User) error {
return nil
}
ds.UserByEmailFunc = func(ctx context.Context, email string) (*fleet.User, error) {
return newUser, nil
}
_, err = svc.GetSSOUser(ctx, auth)
require.NoError(t, err)
require.False(t, ds.SaveUserFuncInvoked)
// (2) Test SSO login with the same user with roles updated in its attributes.
var savedUser *fleet.User
ds.SaveUserFunc = func(ctx context.Context, user *fleet.User) error {
savedUser = user
return nil
}
ds.TeamWithExtrasFunc = func(ctx context.Context, tid uint) (*fleet.Team, error) {
return &fleet.Team{ID: tid}, nil
}
auth.assertionAttributes = []fleet.SAMLAttribute{
{
Name: "FLEET_JIT_USER_ROLE_TEAM_2",
Values: []fleet.SAMLAttributeValue{
{Value: "maintainer"},
},
},
}
_, err = svc.GetSSOUser(ctx, auth)
require.NoError(t, err)
require.NotNil(t, savedUser)
require.Nil(t, savedUser.GlobalRole)
require.Len(t, savedUser.Teams, 1)
require.Equal(t, uint(2), savedUser.Teams[0].ID)
require.Equal(t, "maintainer", savedUser.Teams[0].Role)
require.True(t, ds.SaveUserFuncInvoked)
// (3) Test existing user's role is not changed after a new login if EnableJITProvisioning is false.
ds.SaveUserFuncInvoked = false
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{
SSOSettings: &fleet.SSOSettings{
EnableSSO: true,
EnableSSOIdPLogin: true,
EnableJITProvisioning: false,
},
}, nil
}
auth.assertionAttributes = []fleet.SAMLAttribute{
{
Name: "FLEET_JIT_USER_ROLE_TEAM_2",
Values: []fleet.SAMLAttributeValue{
{Value: "admin"},
},
},
}
_, err = svc.GetSSOUser(ctx, auth)
require.NoError(t, err)
require.False(t, ds.SaveUserFuncInvoked)
// (4) Test with invalid team ID in the attributes
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{
SSOSettings: &fleet.SSOSettings{
EnableSSO: true,
EnableSSOIdPLogin: true,
EnableJITProvisioning: true,
},
}, nil
}
ds.TeamWithExtrasFunc = func(ctx context.Context, tid uint) (*fleet.Team, error) {
return nil, newNotFoundError()
}
auth.assertionAttributes = []fleet.SAMLAttribute{
{
Name: "FLEET_JIT_USER_ROLE_TEAM_3",
Values: []fleet.SAMLAttributeValue{
{Value: "maintainer"},
},
},
}
_, err = svc.GetSSOUser(ctx, auth)
require.Error(t, err)
// (5) Test JIT provisioning with global technician role.
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{
SSOSettings: &fleet.SSOSettings{
EnableSSO: true,
EnableSSOIdPLogin: true,
EnableJITProvisioning: true,
},
}, nil
}
newUser = nil
ds.UserByEmailFunc = func(ctx context.Context, email string) (*fleet.User, error) {
return nil, newNotFoundError()
}
ds.NewUserFuncInvoked = false
auth.assertionAttributes = []fleet.SAMLAttribute{
{
Name: "FLEET_JIT_USER_ROLE_GLOBAL",
Values: []fleet.SAMLAttributeValue{
{Value: "technician"},
},
},
}
_, err = svc.GetSSOUser(ctx, auth)
require.NoError(t, err)
require.NotNil(t, newUser)
require.NotNil(t, newUser.GlobalRole)
require.Equal(t, fleet.RoleTechnician, *newUser.GlobalRole)
require.Empty(t, newUser.Teams)
// (6) Test JIT provisioning with team technician role.
newUser = nil
ds.UserByEmailFunc = func(ctx context.Context, email string) (*fleet.User, error) {
return nil, newNotFoundError()
}
ds.NewUserFuncInvoked = false
ds.TeamWithExtrasFunc = func(ctx context.Context, tid uint) (*fleet.Team, error) {
return &fleet.Team{ID: tid}, nil
}
auth.assertionAttributes = []fleet.SAMLAttribute{
{
Name: "FLEET_JIT_USER_ROLE_TEAM_1",
Values: []fleet.SAMLAttributeValue{
{Value: "technician"},
},
},
}
_, err = svc.GetSSOUser(ctx, auth)
require.NoError(t, err)
require.NotNil(t, newUser)
require.Nil(t, newUser.GlobalRole)
require.Len(t, newUser.Teams, 1)
require.Equal(t, uint(1), newUser.Teams[0].ID)
require.Equal(t, fleet.RoleTechnician, newUser.Teams[0].Role)
}
func TestInitiateSSOWithSSOServerURL(t *testing.T) {
ds := new(mock.Store)
pool := redistest.NopRedis()
svc, ctx := newTestServiceWithConfig(t, ds, config.TestConfig(), nil, nil, &TestServerOpts{
Pool: pool,
})
// Mock app config with SSO server URL
appConfig := &fleet.AppConfig{
ServerSettings: fleet.ServerSettings{
ServerURL: "https://fleet.example.com",
},
SSOSettings: &fleet.SSOSettings{
EnableSSO: true,
SSOServerURL: "https://admin.fleet.example.com",
SSOProviderSettings: fleet.SSOProviderSettings{
EntityID: "fleet",
IDPName: "TestIDP",
Metadata: testSSOMetadata(),
},
},
}
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return appConfig, nil
}
// Test that ACS URL uses SSO URL
sessionID, _, idpURL, err := svc.InitiateSSO(ctx, "/dashboard")
require.NoError(t, err)
require.NotEmpty(t, sessionID)
require.NotEmpty(t, idpURL)
// The ACS URL should use the SSO server URL
// We can't directly test the ACS URL in the SAML request here since it's embedded in the XML,
// but the integration test verifies this works correctly
}
func TestInitiateSSOACSURLWithURLPrefix(t *testing.T) {
// With url_prefix set, the ACS callback URL must carry the subpath exactly
// once, regardless of whether server_url was configured with or without the
// subpath. The latter is the configuration older deployments may have used.
testCases := []struct {
name string
serverURL string
}{
{
name: "server_url includes the subpath",
serverURL: "https://fleet.example.com/apps/fleet",
},
{
name: "server_url omits the subpath",
serverURL: "https://fleet.example.com",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
ds := new(mock.Store)
pool := redistest.NopRedis()
cfg := config.TestConfig()
cfg.Server.URLPrefix = "/apps/fleet"
svc, ctx := newTestServiceWithConfig(t, ds, cfg, nil, nil, &TestServerOpts{
Pool: pool,
})
appConfig := &fleet.AppConfig{
ServerSettings: fleet.ServerSettings{
ServerURL: tc.serverURL,
},
SSOSettings: &fleet.SSOSettings{
EnableSSO: true,
SSOProviderSettings: fleet.SSOProviderSettings{
EntityID: "fleet",
IDPName: "TestIDP",
Metadata: testSSOMetadata(),
},
},
}
ds.AppConfigFunc = func(_ context.Context) (*fleet.AppConfig, error) {
return appConfig, nil
}
_, _, idpURL, err := svc.InitiateSSO(ctx, "/dashboard")
require.NoError(t, err)
require.NotEmpty(t, idpURL)
parsed, err := url.Parse(idpURL)
require.NoError(t, err)
encoded := parsed.Query().Get("SAMLRequest")
require.NotEmpty(t, encoded)
authReq := inflate(t, encoded)
require.NotNil(t, authReq.AssertionConsumerServiceURL)
require.Equal(t,
"https://fleet.example.com/apps/fleet/api/v1/fleet/sso/callback",
authReq.AssertionConsumerServiceURL,
)
})
}
}
func TestInitiateSSOWithTrailingSlash(t *testing.T) {
ds := new(mock.Store)
pool := redistest.NopRedis()
svc, ctx := newTestServiceWithConfig(t, ds, config.TestConfig(), nil, nil, &TestServerOpts{
Pool: pool,
})
testCases := []struct {
name string
serverURL string
ssoServerURL string
}{
{
name: "server URL with trailing slash",
serverURL: "https://fleet.example.com/",
ssoServerURL: "",
},
{
name: "SSO server URL with trailing slash",
serverURL: "https://fleet.example.com",
ssoServerURL: "https://admin.fleet.example.com/",
},
{
name: "both URLs with trailing slash",
serverURL: "https://fleet.example.com/",
ssoServerURL: "https://admin.fleet.example.com/",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Mock app config
appConfig := &fleet.AppConfig{
ServerSettings: fleet.ServerSettings{
ServerURL: tc.serverURL,
},
SSOSettings: &fleet.SSOSettings{
EnableSSO: true,
SSOServerURL: tc.ssoServerURL,
SSOProviderSettings: fleet.SSOProviderSettings{
EntityID: "fleet",
IDPName: "TestIDP",
Metadata: testSSOMetadata(),
},
},
}
ds.AppConfigFunc = func(_ context.Context) (*fleet.AppConfig, error) {
return appConfig, nil
}
// Test that InitiateSSO works
sessionID, _, idpURL, err := svc.InitiateSSO(ctx, "/dashboard")
require.NoError(t, err)
require.NotEmpty(t, sessionID)
require.NotEmpty(t, idpURL)
})
}
}
func TestInitiateSSOWithInvalidURL(t *testing.T) {
ds := new(mock.Store)
pool := redistest.NopRedis()
svc, ctx := newTestServiceWithConfig(t, ds, config.TestConfig(), nil, nil, &TestServerOpts{
Pool: pool,
})
// Mock app config with invalid URL
appConfig := &fleet.AppConfig{
ServerSettings: fleet.ServerSettings{
ServerURL: "not-a-valid-url://%%%",
},
SSOSettings: &fleet.SSOSettings{
EnableSSO: true,
SSOProviderSettings: fleet.SSOProviderSettings{
EntityID: "fleet",
IDPName: "TestIDP",
Metadata: testSSOMetadata(),
},
},
}
ds.AppConfigFunc = func(_ context.Context) (*fleet.AppConfig, error) {
return appConfig, nil
}
// Test that invalid URL returns bad request error
_, _, _, err := svc.InitiateSSO(ctx, "/dashboard")
require.Error(t, err)
// Verify it's a bad request error
var badReqErr *fleet.BadRequestError
require.ErrorAs(t, err, &badReqErr)
require.Contains(t, badReqErr.Message, "invalid SSO URL")
}
func TestDecodeCallbackRequestSAMLResponseSizeCap(t *testing.T) {
// The SSO callbacks read SAMLResponse from FormValue, which covers both the
// POST body and the URL query string. WithRequestBodySizeLimit only bounds
// the body, so the value-level cap must reject an oversized query argument.
t.Run("oversized SAMLResponse in query string is rejected", func(t *testing.T) {
oversized := strings.Repeat("A", int(fleet.MaxSSOCallbackSize)+1)
r := httptest.NewRequest("POST", "/api/v1/fleet/sso/callback?SAMLResponse="+oversized, nil)
_, _, err := decodeCallbackRequest(t.Context(), r)
require.Error(t, err)
var bre *fleet.BadRequestError
require.ErrorAs(t, err, &bre)
require.Contains(t, bre.Message, "too large")
})
t.Run("normally-sized SAMLResponse passes the size check", func(t *testing.T) {
small := base64.StdEncoding.EncodeToString([]byte("<x/>"))
form := url.Values{"SAMLResponse": {small}}
r := httptest.NewRequest("POST", "/api/v1/fleet/sso/callback", strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
_, decoded, err := decodeCallbackRequest(t.Context(), r)
require.NoError(t, err)
require.Equal(t, "<x/>", string(decoded))
})
}