fleet/server/service/conditional_access_idp.go
Victor Lyuboslavsky 7f67ac940f
Okta IdP Apple profile endpoint + fixes (#35526)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #34539

Added endpoint to get a sample Apple config profile that IT admin can
use for Okta conditional access configuration.
`/api/_version_/fleet/conditional_access/idp/apple/profile`

And additional cleanup/improvements:
- logging
- error handling (sending errors to Sentry/OTEL)
- redirect end user to error page if IT admin hasn't set up conditional
access in Fleet

Contributor API changes at:
https://github.com/fleetdm/fleet/pull/35632/files

# Checklist for submitter

- [ ] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
  - Will be added to related PR: #35204

## Testing

- [x] Added/updated automated tests
- [x] QA'd all new/changed functionality manually

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added Apple profile generation for Okta conditional access IdP
integration.
* New endpoint for retrieving conditional access Apple configuration
profiles.

* **Bug Fixes**
* Improved error handling and logging for conditional access operations.
  * Enhanced error responses for missing server URL configuration.

* **Refactoring**
* Centralized error handling for internal server errors with improved
context logging.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-11-14 13:49:08 -06:00

401 lines
15 KiB
Go

package service
import (
"bytes"
"context"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"fmt"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"text/template"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/contexts/logging"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/google/uuid"
)
const conditionalAccessAppleProfileTemplate = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadContent</key>
<array>
<!-- Trusted CA certificate -->
<dict>
<key>PayloadCertificateFileName</key>
<string>conditional_access_ca.der</string>
<key>PayloadContent</key>
<data>{{.CACertBase64}}</data>
<key>PayloadDescription</key>
<string>Fleet conditional access CA certificate</string>
<key>PayloadDisplayName</key>
<string>Fleet conditional access CA</string>
<key>PayloadIdentifier</key>
<string>com.fleetdm.conditional-access-ca</string>
<key>PayloadType</key>
<string>com.apple.security.root</string>
<key>PayloadUUID</key>
<string>{{.CACertUUID}}</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>
<!-- SCEP configuration -->
<dict>
<key>PayloadContent</key>
<dict>
<key>URL</key>
<string>{{.SCEPURL}}</string>
<key>Challenge</key>
<string>{{.Challenge}}</string>
<key>Keysize</key>
<integer>2048</integer>
<key>Key Type</key>
<string>RSA</string>
<key>Key Usage</key>
<integer>5</integer>
<key>ExtendedKeyUsage</key>
<array>
<string>1.3.6.1.5.5.7.3.2</string>
</array>
<key>Subject</key>
<array>
<array>
<array>
<string>CN</string>
<string>{{.CertificateCN}}</string>
</array>
</array>
</array>
<key>SubjectAltName</key>
<dict>
<key>uniformResourceIdentifier</key>
<array>
<string>urn:device:apple:uuid:%HardwareUUID%</string>
</array>
</dict>
<key>Retries</key>
<integer>3</integer>
<key>RetryDelay</key>
<integer>10</integer>
<!-- ACL for browser access -->
<key>AllowAllAppsAccess</key>
<true/>
<key>KeyIsExtractable</key>
<false/>
</dict>
<key>PayloadDescription</key>
<string>Configures SCEP for Fleet conditional access for Okta certificate</string>
<key>PayloadDisplayName</key>
<string>Fleet conditional access SCEP</string>
<key>PayloadIdentifier</key>
<string>com.fleetdm.conditional-access-scep</string>
<key>PayloadType</key>
<string>com.apple.security.scep</string>
<key>PayloadUUID</key>
<string>{{.SCEPPayloadUUID}}</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>
<!-- Identity preference for mTLS endpoint -->
<dict>
<key>Name</key>
<string>{{.MTLSURL}}</string>
<key>PayloadCertificateUUID</key>
<string>{{.SCEPPayloadUUID}}</string>
<key>PayloadDescription</key>
<string>Identity preference for mTLS endpoints</string>
<key>PayloadDisplayName</key>
<string>Fleet mTLS identity preference</string>
<key>PayloadIdentifier</key>
<string>com.fleetdm.conditional-access-preference</string>
<key>PayloadType</key>
<string>com.apple.security.identitypreference</string>
<key>PayloadUUID</key>
<string>{{.IdentityPrefUUID}}</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>
<!-- Chrome web browser configuration -->
<dict>
<key>PayloadType</key>
<string>com.apple.ManagedClient.preferences</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>PayloadIdentifier</key>
<string>com.fleetdm.chrome.certs</string>
<key>PayloadUUID</key>
<string>{{.ChromeConfigUUID}}</string>
<key>PayloadDisplayName</key>
<string>Chrome mTLS auto-select</string>
<key>PayloadContent</key>
<dict>
<key>com.google.Chrome</key>
<dict>
<key>Forced</key>
<array>
<dict>
<key>mcx_preference_settings</key>
<dict>
<key>AllowPolicyInIncognito</key>
<true/>
<key>AutoSelectCertificateForUrls</key>
<array>
<!-- MUST be stringified JSON -->
<string>{"pattern":"{{.MTLSURL}}","filter":{"SUBJECT":{"CN":"{{.CertificateCN}}"}}}</string>
</array>
</dict>
</dict>
</array>
</dict>
</dict>
</dict>
</array>
<key>PayloadDescription</key>
<string>Configures SCEP enrollment for Okta conditional access</string>
<key>PayloadDisplayName</key>
<string>Fleet conditional access for Okta</string>
<key>PayloadIdentifier</key>
<string>com.fleetdm.conditional-access-okta</string>
<key>PayloadOrganization</key>
<string>Fleet Device Management</string>
<key>PayloadRemovalDisallowed</key>
<false/>
<key>PayloadScope</key>
<string>User</string>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadUUID</key>
<string>{{.RootPayloadUUID}}</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>
</plist>
`
var conditionalAccessAppleProfileTemplateParsed = template.Must(template.New("conditionalAccessAppleProfile").Parse(
conditionalAccessAppleProfileTemplate))
// fleetConditionalAccessNamespace is a custom UUID namespace for Fleet Okta conditional access profiles.
// Generated using: uuid.NewSHA1(uuid.NameSpaceURL, []byte("https://fleetdm.com/learn-more-about/okta-conditional-access"))
// This ensures UUIDs are unique to Fleet's Okta conditional access feature and won't collide with other systems.
var fleetConditionalAccessNamespace = uuid.Must(uuid.Parse("fe5c0046-e83e-5a1d-9693-ace1348d34ec"))
// generateDeterministicUUID generates a UUID v5 based on the server URL and a component name.
// This ensures the same server always generates the same UUIDs for profile components.
func generateDeterministicUUID(serverURL, component string) string {
// Use Fleet's conditional access namespace to avoid collisions
// Create a deterministic UUID based on serverURL + component
name := fmt.Sprintf("%s:%s", serverURL, component)
return uuid.NewSHA1(fleetConditionalAccessNamespace, []byte(name)).String()
}
type appleProfileTemplateData struct {
CACertBase64 string
SCEPURL string
Challenge string
CertificateCN string
MTLSURL string
CACertUUID string
SCEPPayloadUUID string
IdentityPrefUUID string
ChromeConfigUUID string
RootPayloadUUID string
}
type conditionalAccessGetIdPSigningCertRequest struct{}
type conditionalAccessGetIdPSigningCertResponse struct {
CertPEM []byte
Err error `json:"error,omitempty"`
}
func (r conditionalAccessGetIdPSigningCertResponse) Error() error { return r.Err }
func (r conditionalAccessGetIdPSigningCertResponse) HijackRender(ctx context.Context, w http.ResponseWriter) {
w.Header().Set("Content-Length", strconv.FormatInt(int64(len(r.CertPEM)), 10))
w.Header().Set("Content-Type", "application/x-pem-file")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Content-Disposition", "attachment; filename=\"fleet-idp-signing-cert.pem\"")
// OK to just log the error here as writing anything on `http.ResponseWriter` sets the status code to 200 (and it can't be
// changed.) Clients should rely on matching content-length with the header provided
n, err := w.Write(r.CertPEM)
if err != nil {
logging.WithExtras(ctx, "err", err, "bytes_written", n)
}
}
func conditionalAccessGetIdPSigningCertEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
certPEM, err := svc.ConditionalAccessGetIdPSigningCert(ctx)
if err != nil {
return conditionalAccessGetIdPSigningCertResponse{Err: err}, nil
}
return conditionalAccessGetIdPSigningCertResponse{
CertPEM: certPEM,
}, nil
}
func (svc *Service) ConditionalAccessGetIdPSigningCert(ctx context.Context) (certPEM []byte, err error) {
// Check user is authorized to read conditional access Okta IdP certificate
if err := svc.authz.Authorize(ctx, &fleet.ConditionalAccessIDPAssets{}, fleet.ActionRead); err != nil {
return nil, ctxerr.Wrap(ctx, err, "failed to authorize")
}
// Check that server private key is configured
if len(svc.config.Server.PrivateKey) == 0 {
return nil, &fleet.BadRequestError{Message: "Fleet server private key is not configured. Learn more: https://fleetdm.com/learn-more-about/fleet-server-private-key"}
}
// Load IdP certificate from mdm_config_assets
assets, err := svc.ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{
fleet.MDMAssetConditionalAccessIDPCert,
}, nil)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "failed to load IdP certificate")
}
certAsset, ok := assets[fleet.MDMAssetConditionalAccessIDPCert]
if !ok {
return nil, ctxerr.New(ctx, "IdP certificate not configured")
}
return certAsset.Value, nil
}
type conditionalAccessGetIdPAppleProfileResponse struct {
ProfileData []byte
Err error `json:"error,omitempty"`
}
func (r conditionalAccessGetIdPAppleProfileResponse) Error() error { return r.Err }
func (r conditionalAccessGetIdPAppleProfileResponse) HijackRender(ctx context.Context, w http.ResponseWriter) {
w.Header().Set("Content-Length", strconv.FormatInt(int64(len(r.ProfileData)), 10))
w.Header().Set("Content-Type", "application/x-apple-aspen-config")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Content-Disposition", "attachment; filename=\"fleet-conditional-access.mobileconfig\"")
// OK to just log the error here as writing anything on `http.ResponseWriter` sets the status code to 200 (and it can't be
// changed.) Clients should rely on matching content-length with the header provided
n, err := w.Write(r.ProfileData)
if err != nil {
logging.WithExtras(ctx, "err", err, "bytes_written", n)
}
}
func conditionalAccessGetIdPAppleProfileEndpoint(ctx context.Context, _ interface{}, svc fleet.Service) (fleet.Errorer, error) {
profileData, err := svc.ConditionalAccessGetIdPAppleProfile(ctx)
if err != nil {
return conditionalAccessGetIdPAppleProfileResponse{Err: err}, nil
}
return conditionalAccessGetIdPAppleProfileResponse{
ProfileData: profileData,
}, nil
}
func (svc *Service) ConditionalAccessGetIdPAppleProfile(ctx context.Context) (profileData []byte, err error) {
// Check user is authorized to read conditional access Apple profile
if err := svc.authz.Authorize(ctx, &fleet.ConditionalAccessIDPAssets{}, fleet.ActionRead); err != nil {
return nil, ctxerr.Wrap(ctx, err, "failed to authorize")
}
// Check that server private key is configured
if len(svc.config.Server.PrivateKey) == 0 {
return nil, &fleet.BadRequestError{Message: "Fleet server private key is not configured. Learn more: https://fleetdm.com/learn-more-about/fleet-server-private-key"}
}
// Load CA certificate for SCEP from mdm_config_assets
assets, err := svc.ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{
fleet.MDMAssetConditionalAccessCACert,
}, nil)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "failed to load conditional access CA certificate")
}
caCertAsset, ok := assets[fleet.MDMAssetConditionalAccessCACert]
if !ok {
return nil, ctxerr.New(ctx, "conditional access CA certificate not configured")
}
// Parse PEM certificate
block, _ := pem.Decode(caCertAsset.Value)
if block == nil {
return nil, ctxerr.New(ctx, "failed to decode CA certificate PEM")
}
// Parse DER certificate
_, err = x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "failed to parse CA certificate")
}
// Base64 encode the DER certificate for the profile
caCertBase64 := base64.StdEncoding.EncodeToString(block.Bytes)
// Get app config for server URL
appConfig, err := svc.ds.AppConfig(ctx)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "failed to load app config")
}
if strings.TrimSpace(appConfig.ServerSettings.ServerURL) == "" {
return nil, &fleet.BadRequestError{Message: "server URL is not configured"}
}
// Construct SCEP URL using net/url package
parsedServerURL, err := url.Parse(appConfig.ServerSettings.ServerURL)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "failed to parse server URL")
}
scepURL := parsedServerURL.JoinPath("/api/fleet/conditional_access/scep").String()
// Get global enroll secrets
secrets, err := svc.ds.GetEnrollSecrets(ctx, nil)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "failed to get enroll secrets")
}
if len(secrets) == 0 {
return nil, ctxerr.Wrap(ctx, &fleet.BadRequestError{Message: "global enroll secret is not configured"})
}
// Use the first global enroll secret as the challenge
challenge := secrets[0].Secret
// Get mTLS URL using ConditionalAccessIdPSSOURL
mtlsURL, err := appConfig.ConditionalAccessIdPSSOURL(os.Getenv)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "failed to get mTLS URL")
}
// Generate deterministic UUIDs based on server URL
serverURL := appConfig.ServerSettings.ServerURL
caCertUUID := generateDeterministicUUID(serverURL, "conditional-access-ca-cert")
scepPayloadUUID := generateDeterministicUUID(serverURL, "conditional-access-scep")
identityPrefUUID := generateDeterministicUUID(serverURL, "conditional-access-identity-pref")
chromeConfigUUID := generateDeterministicUUID(serverURL, "conditional-access-chrome-config")
rootPayloadUUID := generateDeterministicUUID(serverURL, "conditional-access-root-payload")
// Execute template
var buf bytes.Buffer
if err := conditionalAccessAppleProfileTemplateParsed.Execute(&buf, appleProfileTemplateData{
CACertBase64: caCertBase64,
SCEPURL: scepURL,
Challenge: challenge,
CertificateCN: "Fleet conditional access for Okta",
MTLSURL: mtlsURL,
CACertUUID: caCertUUID,
SCEPPayloadUUID: scepPayloadUUID,
IdentityPrefUUID: identityPrefUUID,
ChromeConfigUUID: chromeConfigUUID,
RootPayloadUUID: rootPayloadUUID,
}); err != nil {
return nil, ctxerr.Wrap(ctx, err, "failed to execute profile template")
}
return buf.Bytes(), nil
}