mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 13:37:30 +00:00
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:
parent
080723dde7
commit
05ddeade90
19 changed files with 480 additions and 172 deletions
3
.github/workflows/test-go.yaml
vendored
3
.github/workflows/test-go.yaml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
63
ee/server/service/users.go
Normal file
63
ee/server/service/users.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
//
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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", ¶ms, 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", ¶ms, 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>
|
||||
`
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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',
|
||||
),
|
||||
),
|
||||
|
|
|
|||
Loading…
Reference in a new issue