Files
fleet/server/service/apple_psso_test.go
a33481653d macos password sync feature branch (#47422)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #45524

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [x] 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.

- [x] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements), JS
inline code is prevented especially for url redirects, and untrusted
data interpolated into shell scripts/commands is validated against shell
metacharacters.
- [x] Timeouts are implemented and retries are limited to avoid infinite
loops
- [x] If paths of existing endpoints are modified without backwards
compatibility, checked the frontend/CLI for any necessary changes

## Testing

- [x] Added/updated automated tests
- [x] Where appropriate, [automated tests simulate multiple hosts and
test for host
isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing)
(updates to one hosts's records do not affect another)

- [x] QA'd all new/changed functionality manually

For unreleased bug fixes in a release candidate, one of:

- [ ] Confirmed that the fix is not expected to adversely impact load
test results
- [ ] Alerted the release DRI if additional load testing is needed

## Database migrations

- [x] Checked schema for all modified table for columns that will
auto-update timestamps during migration.
- [x] Confirmed that updating the timestamps is acceptable, and will not
cause unwanted side effects.
- [x] Ensured the correct collation is explicitly set for character
columns (`COLLATE utf8mb4_unicode_ci`).

## New Fleet configuration settings

- [ ] Setting(s) is/are explicitly excluded from GitOps

If you didn't check the box above, follow this checklist for
GitOps-enabled settings:

- [x] Verified that the setting is exported via `fleetctl
generate-gitops`
- [x] Verified the setting is documented in a separate PR to [the GitOps
documentation](https://github.com/fleetdm/fleet/blob/main/docs/Configuration/yaml-files.md#L485)
- [x] Verified that the setting is cleared on the server if it is not
supplied in a YAML file (or that it is documented as being optional)
- [x] Verified that any relevant UI is disabled when GitOps mode is
enabled


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

* **New Features**
* Added Apple Platform SSO (PSSO) for macOS with device registration,
sign-in, and public discovery (JWKS + Apple app-site association)
protected by single-use nonces.
* Added Apple account provisioning (Platform SSO password sync)
configuration with masked client-secret handling and GitOps support.
* Added a host-scoped PSSO device registration token variable for Apple
MDM profile generation.
* **Bug Fixes**
* Fixed macOS packaging to correctly build, embed, and sign the Platform
SSO extension.
* Resetting device Apple MDM data now also clears stored PSSO enrollment
records.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Magnus Jensen <magnus@fleetdm.com>
2026-07-09 14:57:48 -04:00

227 lines
8.6 KiB
Go

package service
import (
"context"
"crypto/ecdsa"
"crypto/x509"
"encoding/pem"
"errors"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mock"
"github.com/jmoiron/sqlx"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestPSSONonceEndpointAcceptsFormBody guards against the nonce request struct
// losing its DecodeBody: Apple's AppSSOAgent POSTs a urlencoded
// grant_type=srv_challenge form, and without a body-decoder the framework
// falls through to JSON decoding and rejects it with a 400.
func TestPSSONonceEndpointAcceptsFormBody(t *testing.T) {
decode := makeDecoder(pssoNonceRequest{}, 1<<20)
for _, body := range []string{"grant_type=srv_challenge", ""} {
r := httptest.NewRequest("POST", "/api/mdm/apple/psso/nonce", strings.NewReader(body))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
_, err := decode(t.Context(), r)
require.NoError(t, err, "body %q", body)
}
}
func TestPSSORegistrationRequestDecodeBody(t *testing.T) {
pubPEM := "-----BEGIN PUBLIC KEY-----\nMFkw+abc/def=\n-----END PUBLIC KEY-----"
form := url.Values{}
form.Set("device_uuid", "A72B07D0-2E08-45CE-9423-1FCAFFAEC390")
form.Set("device_signing_key", pubPEM)
form.Set("device_encryption_key", pubPEM)
form.Set("signing_key_id", "sign-kid")
form.Set("encryption_key_id", "enc-kid")
var req pssoRegistrationRequest
err := req.DecodeBody(t.Context(), strings.NewReader(form.Encode()), nil, nil)
require.NoError(t, err)
require.Equal(t, "A72B07D0-2E08-45CE-9423-1FCAFFAEC390", req.DeviceUUID)
// PEM survives urlencoding round trip: '+', '/', '=' and newlines intact.
require.Equal(t, pubPEM, req.DeviceSigningKey)
require.Equal(t, pubPEM, req.DeviceEncryptionKey)
require.Equal(t, "sign-kid", req.SigningKeyID)
require.Equal(t, "enc-kid", req.EncryptionKeyID)
}
func TestPSSOTokenRequestDecodeBody(t *testing.T) {
t.Run("extracts assertion", func(t *testing.T) {
form := url.Values{}
form.Set("assertion", "eyJhbGciOiJFUzI1NiJ9.payload.sig")
var req pssoTokenRequest
err := req.DecodeBody(t.Context(), strings.NewReader(form.Encode()), nil, nil)
require.NoError(t, err)
require.Equal(t, "eyJhbGciOiJFUzI1NiJ9.payload.sig", req.Assertion)
})
t.Run("missing assertion rejected", func(t *testing.T) {
var req pssoTokenRequest
err := req.DecodeBody(t.Context(), strings.NewReader("other=value"), nil, nil)
require.Error(t, err)
require.Contains(t, err.Error(), "missing assertion")
})
t.Run("empty body rejected", func(t *testing.T) {
var req pssoTokenRequest
err := req.DecodeBody(t.Context(), strings.NewReader(""), nil, nil)
require.Error(t, err)
})
t.Run("malformed form rejected", func(t *testing.T) {
var req pssoTokenRequest
err := req.DecodeBody(t.Context(), strings.NewReader("a=%zz"), nil, nil)
require.Error(t, err)
})
}
type pssoTestNotFoundError struct{}
func (pssoTestNotFoundError) Error() string { return "not found" }
func (pssoTestNotFoundError) IsNotFound() bool { return true }
// pssoBootstrapMock wires a mock datastore over an in-memory asset map so the
// bootstrap can be exercised without MySQL. GetAll returns a not-found error
// when nothing matches (mirroring the real datastore) and Insert appends.
func pssoBootstrapMock(store map[fleet.MDMAssetName]fleet.MDMConfigAsset) *mock.DataStore {
ds := new(mock.DataStore)
ds.GetAllMDMConfigAssetsByNameFunc = func(_ context.Context, names []fleet.MDMAssetName, _ sqlx.QueryerContext) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) {
out := map[fleet.MDMAssetName]fleet.MDMConfigAsset{}
for _, n := range names {
if a, ok := store[n]; ok {
out[n] = a
}
}
if len(out) == 0 {
return nil, pssoTestNotFoundError{}
}
if len(out) < len(names) {
return out, errors.New("partial result")
}
return out, nil
}
ds.InsertMDMConfigAssetsFunc = func(_ context.Context, assets []fleet.MDMConfigAsset, _ sqlx.ExtContext) error {
for _, a := range assets {
store[a.Name] = a
}
return nil
}
return ds
}
func parsePEMSigningKey(t *testing.T, value []byte) *ecdsa.PrivateKey {
t.Helper()
block, _ := pem.Decode(value)
require.NotNil(t, block)
key, err := x509.ParseECPrivateKey(block.Bytes)
require.NoError(t, err)
return key
}
func parsePEMCert(t *testing.T, value []byte) *x509.Certificate {
t.Helper()
block, _ := pem.Decode(value)
require.NotNil(t, block)
cert, err := x509.ParseCertificate(block.Bytes)
require.NoError(t, err)
return cert
}
func TestBootstrapPSSOAssets(t *testing.T) {
ctx := context.Background()
t.Run("creates signing key, CA, and encryption key when all absent", func(t *testing.T) {
store := map[fleet.MDMAssetName]fleet.MDMConfigAsset{}
ds := pssoBootstrapMock(store)
require.NoError(t, bootstrapPSSOAssets(ctx, ds))
require.True(t, ds.InsertMDMConfigAssetsFuncInvoked)
require.Contains(t, store, fleet.MDMAssetPSSOSigningKey)
require.Contains(t, store, fleet.MDMAssetPSSOCACert)
require.Contains(t, store, fleet.MDMAssetPSSOEncryptionKey)
signingKey := parsePEMSigningKey(t, store[fleet.MDMAssetPSSOSigningKey].Value)
caCert := parsePEMCert(t, store[fleet.MDMAssetPSSOCACert].Value)
encKey := parsePEMSigningKey(t, store[fleet.MDMAssetPSSOEncryptionKey].Value)
assert.True(t, caCert.IsCA)
// The CA is self-signed by the signing key, so its public key is the
// signing key's public key.
caPub, ok := caCert.PublicKey.(*ecdsa.PublicKey)
require.True(t, ok)
assert.True(t, caPub.Equal(&signingKey.PublicKey))
require.NoError(t, caCert.CheckSignatureFrom(caCert))
assert.WithinDuration(t, time.Now().AddDate(pssoCAValidYears, 0, 0), caCert.NotAfter, 24*time.Hour)
// The encryption key is distinct from the signing key: NIST SP 800-57
// forbids using one key for both signing and encryption.
assert.False(t, encKey.PublicKey.Equal(&signingKey.PublicKey))
})
t.Run("no-op when all already exist", func(t *testing.T) {
store := map[fleet.MDMAssetName]fleet.MDMConfigAsset{}
require.NoError(t, bootstrapPSSOAssets(ctx, pssoBootstrapMock(store)))
seededKey := store[fleet.MDMAssetPSSOSigningKey].Value
seededCA := store[fleet.MDMAssetPSSOCACert].Value
seededEnc := store[fleet.MDMAssetPSSOEncryptionKey].Value
ds := pssoBootstrapMock(store)
require.NoError(t, bootstrapPSSOAssets(ctx, ds))
// Nothing re-inserted, and the existing assets are untouched.
assert.False(t, ds.InsertMDMConfigAssetsFuncInvoked)
assert.Equal(t, seededKey, store[fleet.MDMAssetPSSOSigningKey].Value)
assert.Equal(t, seededCA, store[fleet.MDMAssetPSSOCACert].Value)
assert.Equal(t, seededEnc, store[fleet.MDMAssetPSSOEncryptionKey].Value)
})
t.Run("creates only the encryption key when signing and CA already exist", func(t *testing.T) {
store := map[fleet.MDMAssetName]fleet.MDMConfigAsset{}
require.NoError(t, bootstrapPSSOAssets(ctx, pssoBootstrapMock(store)))
existingKeyPEM := store[fleet.MDMAssetPSSOSigningKey].Value
existingCAPEM := store[fleet.MDMAssetPSSOCACert].Value
// Simulate a deployment configured before the encryption key existed.
delete(store, fleet.MDMAssetPSSOEncryptionKey)
ds := pssoBootstrapMock(store)
require.NoError(t, bootstrapPSSOAssets(ctx, ds))
require.True(t, ds.InsertMDMConfigAssetsFuncInvoked)
// The signing key and CA are preserved; only the encryption key is minted.
assert.Equal(t, existingKeyPEM, store[fleet.MDMAssetPSSOSigningKey].Value)
assert.Equal(t, existingCAPEM, store[fleet.MDMAssetPSSOCACert].Value)
require.Contains(t, store, fleet.MDMAssetPSSOEncryptionKey)
encKey := parsePEMSigningKey(t, store[fleet.MDMAssetPSSOEncryptionKey].Value)
signingKey := parsePEMSigningKey(t, existingKeyPEM)
assert.False(t, encKey.PublicKey.Equal(&signingKey.PublicKey))
})
t.Run("creates only the CA over the existing key when CA is missing", func(t *testing.T) {
store := map[fleet.MDMAssetName]fleet.MDMConfigAsset{}
// Seed a signing key only (e.g. a POC instance pre-dating the CA asset).
require.NoError(t, bootstrapPSSOAssets(ctx, pssoBootstrapMock(store)))
existingKeyPEM := store[fleet.MDMAssetPSSOSigningKey].Value
delete(store, fleet.MDMAssetPSSOCACert)
ds := pssoBootstrapMock(store)
require.NoError(t, bootstrapPSSOAssets(ctx, ds))
// The signing key is preserved (not regenerated) and the new CA is signed by it.
assert.Equal(t, existingKeyPEM, store[fleet.MDMAssetPSSOSigningKey].Value)
signingKey := parsePEMSigningKey(t, existingKeyPEM)
caCert := parsePEMCert(t, store[fleet.MDMAssetPSSOCACert].Value)
caPub, ok := caCert.PublicKey.(*ecdsa.PublicKey)
require.True(t, ok)
assert.True(t, caPub.Equal(&signingKey.PublicKey))
})
}