mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 21:47:20 +00:00
<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves #34528 # Details This PR implements the agent changes for allowing Fleet admins to require that users authenticate with an IdP prior to having their devices set up. I'll comment on changes inline but the high-level is: 1. Orbit calls the enroll endpoint as usual. This is triggered lazily by any one of a number of subsystems like device token rotation or requesting Fleet config 2. If the enroll endpoint returns the new `ErrEndUserAuthRequired` response, then it opens a window to the `/mdm/sso` Fleet page and retries the enroll endpoint every 30 seconds indefinitely. 3. Any other non-200 response to the enroll request is treated as before (limited # of retries, with backoff) # Checklist for submitter If some of the following don't apply, delete the relevant line. - [ ] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing- changes.md#changes-files) for more information. Will add changelog when story is one. ## Testing - [X] Added/updated automated tests Added test for new retry logic - [X] QA'd all new/changed functionality manually This is kinda hard to test without the associated backend PR: https://github.com/fleetdm/fleet/pull/34835 ## fleetd/orbit/Fleet Desktop - [X] Verified compatibility with the latest released version of Fleet (see [Must rule](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/workflows/fleetd-development-and-release-strategy.md)) This is compatible with all Fleet versions, since older ones won't send the new error. - [X] If the change applies to only one platform, confirmed that `runtime.GOOS` is used as needed to isolate changes This is compatible with all platforms, although it currently should only ever run on Windows and Linux since macOS devices will have end-user auth taken care of before they even download Orbit. - [ ] Verified that fleetd runs on macOS, Linux and Windows Testing this now. - [ ] Verified auto-update works from the released version of component to the new version (see [tools/tuf/test](../tools/tuf/test/README.md)) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added SSO (Single Sign-On) enrollment support for end-user authentication * Enhanced error messaging for authentication-required scenarios * **Bug Fixes** * Improved error handling and retry logic for enrollment failures <!-- end of auto-generated comment: release notes by coderabbit.ai -->
200 lines
4.9 KiB
Go
200 lines
4.9 KiB
Go
package service
|
|
|
|
import (
|
|
"bytes"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
|
|
"github.com/fleetdm/fleet/v4/server/fleet"
|
|
)
|
|
|
|
var (
|
|
ErrUnauthenticated = errors.New("unauthenticated, or invalid token")
|
|
ErrPasswordResetRequired = errors.New("Password reset required. Please sign into the Fleet UI to update your password, then log in again with: fleetctl login.")
|
|
ErrMissingLicense = errors.New("missing or invalid license")
|
|
// ErrEndUserAuthRequired is returned when an action (such as enrolling a device)
|
|
// requires end user authentication
|
|
ErrEndUserAuthRequired = errors.New("end user authentication required")
|
|
)
|
|
|
|
type SetupAlreadyErr interface {
|
|
SetupAlready() bool
|
|
Error() string
|
|
}
|
|
|
|
type setupAlreadyErr struct{}
|
|
|
|
func (e setupAlreadyErr) Error() string {
|
|
return "Fleet has already been setup"
|
|
}
|
|
|
|
func (e setupAlreadyErr) SetupAlready() bool {
|
|
return true
|
|
}
|
|
|
|
type NotSetupErr interface {
|
|
NotSetup() bool
|
|
Error() string
|
|
}
|
|
|
|
type notSetupErr struct{}
|
|
|
|
func (e notSetupErr) Error() string {
|
|
return "The Fleet instance is not set up yet"
|
|
}
|
|
|
|
func (e notSetupErr) NotSetup() bool {
|
|
return true
|
|
}
|
|
|
|
// TODO: we have a similar but different interface in the fleet package,
|
|
// fleet.NotFoundError - at the very least, the NotFound method should be the
|
|
// same in both (the other is currently IsNotFound), and ideally we'd just have
|
|
// one of those interfaces.
|
|
type NotFoundErr interface {
|
|
NotFound() bool
|
|
Error() string
|
|
}
|
|
|
|
type notFoundErr struct {
|
|
msg string
|
|
|
|
fleet.ErrorWithUUID
|
|
}
|
|
|
|
func (e notFoundErr) Error() string {
|
|
if e.msg != "" {
|
|
return e.msg
|
|
}
|
|
return "The resource was not found"
|
|
}
|
|
|
|
func (e notFoundErr) NotFound() bool {
|
|
return true
|
|
}
|
|
|
|
// Implement Is so that errors.Is(err, sql.ErrNoRows) returns true for an
|
|
// error of type *notFoundError, without having to wrap sql.ErrNoRows
|
|
// explicitly.
|
|
func (e notFoundErr) Is(other error) bool {
|
|
return other == sql.ErrNoRows
|
|
}
|
|
|
|
type ConflictErr interface {
|
|
Conflict() bool
|
|
Error() string
|
|
}
|
|
|
|
type conflictErr struct {
|
|
msg string
|
|
}
|
|
|
|
func (e conflictErr) Error() string {
|
|
return e.msg
|
|
}
|
|
|
|
func (e conflictErr) Conflict() bool {
|
|
return true
|
|
}
|
|
|
|
type serverError struct {
|
|
Message string `json:"message"`
|
|
Errors []struct {
|
|
Name string `json:"name"`
|
|
Reason string `json:"reason"`
|
|
} `json:"errors"`
|
|
}
|
|
|
|
// truncateAndDetectHTML truncates a response body to a reasonable length and
|
|
// detects if it's HTML content. Returns the truncated body and whether it's HTML.
|
|
func truncateAndDetectHTML(body []byte, maxLen int) (truncated []byte, isHTML bool) {
|
|
if len(body) > maxLen {
|
|
// Use append which is more idiomatic and efficient
|
|
truncated = append([]byte(nil), body[:maxLen]...)
|
|
truncated = append(truncated, "..."...)
|
|
} else {
|
|
// For small bodies, we can return the slice directly since it will be
|
|
// converted to string soon anyway and won't hold a large underlying array
|
|
truncated = body
|
|
}
|
|
lowerPrefix := bytes.ToLower(truncated)
|
|
isHTML = bytes.Contains(lowerPrefix, []byte("<html")) || bytes.Contains(lowerPrefix, []byte("<!doctype"))
|
|
|
|
// Return truncated byte slice
|
|
return truncated, isHTML
|
|
}
|
|
|
|
func extractServerErrorText(body io.Reader) string {
|
|
_, reason := extractServerErrorNameReason(body)
|
|
return reason
|
|
}
|
|
|
|
func extractServerErrorNameReason(body io.Reader) (string, string) {
|
|
// Read the body first so we can try to parse it as JSON and fallback to text if needed
|
|
bodyBytes, err := io.ReadAll(body)
|
|
if err != nil {
|
|
return "", "failed to read response body"
|
|
}
|
|
|
|
// Try to parse as JSON first
|
|
var serverErr serverError
|
|
if err := json.Unmarshal(bodyBytes, &serverErr); err != nil {
|
|
// If it's not JSON, it might be HTML or plain text error from a proxy/load balancer
|
|
const maxLen = 200
|
|
truncatedBytes, isHTML := truncateAndDetectHTML(bodyBytes, maxLen)
|
|
|
|
if isHTML {
|
|
// Generic HTML response
|
|
return "", fmt.Sprintf("server returned HTML instead of JSON response, body: %s", truncatedBytes)
|
|
}
|
|
|
|
// Return cleaned up text for non-HTML responses
|
|
truncated := strings.TrimSpace(string(truncatedBytes))
|
|
if truncated == "" {
|
|
return "", "empty response body"
|
|
}
|
|
return "", truncated
|
|
}
|
|
|
|
errName := ""
|
|
errReason := serverErr.Message
|
|
if len(serverErr.Errors) > 0 {
|
|
errReason += ": " + serverErr.Errors[0].Reason
|
|
errName = serverErr.Errors[0].Name
|
|
}
|
|
|
|
return errName, errReason
|
|
}
|
|
|
|
func extractServerErrorNameReasons(body io.Reader) ([]string, []string) {
|
|
var serverErr serverError
|
|
if err := json.NewDecoder(body).Decode(&serverErr); err != nil {
|
|
return []string{""}, []string{"unknown"}
|
|
}
|
|
|
|
var errName []string
|
|
var errReason []string
|
|
for _, err := range serverErr.Errors {
|
|
errName = append(errName, err.Name)
|
|
errReason = append(errReason, err.Reason)
|
|
}
|
|
|
|
return errName, errReason
|
|
}
|
|
|
|
type statusCodeErr struct {
|
|
code int
|
|
body string
|
|
}
|
|
|
|
func (e *statusCodeErr) Error() string {
|
|
return fmt.Sprintf("%d %s", e.code, e.body)
|
|
}
|
|
|
|
func (e *statusCodeErr) StatusCode() int {
|
|
return e.code
|
|
}
|