From 1c5766131ceafea139b5d05e0a9a4b216b4a2f8b Mon Sep 17 00:00:00 2001 From: Jordan Montgomery Date: Thu, 11 Jun 2026 11:42:42 -0400 Subject: [PATCH 01/28] PSSO migrations for final schema shape --- ee/server/service/apple_psso.go | 136 ++++++---------- ee/server/service/apple_psso_crypto.go | 134 ++++++++++------ ee/server/service/apple_psso_crypto_test.go | 120 +++++++++++++- ee/server/service/service.go | 2 +- server/datastore/mysql/apple_mdm.go | 6 + server/datastore/mysql/apple_mdm_test.go | 19 ++- server/datastore/mysql/apple_psso.go | 147 ++++++++---------- server/datastore/mysql/apple_psso_test.go | 147 ++++++++++++++++++ server/datastore/mysql/hosts.go | 5 + .../20260611140639_CreateApplePSSOTables.go | 38 ++--- ...260611140639_CreateApplePSSOTables_test.go | 143 ++++++----------- server/datastore/mysql/schema.sql | 19 +-- server/fleet/apple_psso.go | 29 ++-- server/fleet/datastore.go | 28 ++-- server/fleet/service.go | 4 +- server/mock/datastore_mock.go | 54 +++++-- 16 files changed, 634 insertions(+), 397 deletions(-) create mode 100644 server/datastore/mysql/apple_psso_test.go diff --git a/ee/server/service/apple_psso.go b/ee/server/service/apple_psso.go index bac596c49a8..08085525cff 100644 --- a/ee/server/service/apple_psso.go +++ b/ee/server/service/apple_psso.go @@ -141,8 +141,9 @@ func parsePSSOSigningKeyPEM(pemBytes []byte) (*ecdsa.PrivateKey, string, error) } // computeKID returns base64url-nopad SHA-256 of the SubjectPublicKeyInfo DER -// encoding of pub. This matches the kid format the extension sends with its -// JWTs (SHA-256 of the public key bytes, base64'd). +// encoding of pub. Used only for Fleet's own signing key (JWKS/JWT kid). +// Device key kids are different: the extension computes them as SHA-256 of +// the raw X9.63 point bytes and submits them at registration. func computeKID(pub *ecdsa.PublicKey) (string, error) { der, err := x509.MarshalPKIXPublicKey(pub) if err != nil { @@ -252,8 +253,8 @@ func (svc *Service) PSSORegisterBegin(ctx context.Context) (string, error) { } // PSSORegisterComplete consumes the device-key enrollment POST from the Mac -// extension: it resolves the enrolled host from the hardware device UUID, -// mints a KeyExchangeKey, and persists the device record + KeyID rows. +// extension: it resolves the enrolled host from the hardware device UUID and +// persists the device record plus its public key rows. // // Password-mode registration carries no OAuth code/state — the extension // simply submits the public halves of its Secure Enclave signing and @@ -269,9 +270,19 @@ func (svc *Service) PSSORegisterComplete(ctx context.Context, req fleet.PSSORegi return &fleet.BadRequestError{Message: "missing required psso register fields"} } - // Resolve host_id from device UUID. PSSO requires a matching enrolled host - // since the device record is keyed by host_id. - host, err := svc.ds.HostLiteByIdentifier(ctx, req.DeviceUUID) + // Reject unparseable key material up front: a bad PEM stored here would + // otherwise only surface as opaque verification failures at every + // subsequent login. + if _, err := parseECPublicKeyPEM([]byte(req.DeviceSigningKey)); err != nil { + return &fleet.BadRequestError{Message: "psso register: signing key is not a valid P-256 public key"} + } + if _, err := parseECPublicKeyPEM([]byte(req.DeviceEncryptionKey)); err != nil { + return &fleet.BadRequestError{Message: "psso register: encryption key is not a valid P-256 public key"} + } + + // PSSO requires a matching enrolled host; the registration is keyed by the + // host's UUID. + host, err := svc.ds.HostByUUID(ctx, req.DeviceUUID) if err != nil { if fleet.IsNotFound(err) { return &fleet.BadRequestError{Message: fmt.Sprintf("psso register: no enrolled host matches device UUID %q", req.DeviceUUID)} @@ -279,37 +290,22 @@ func (svc *Service) PSSORegisterComplete(ctx context.Context, req fleet.PSSORegi return ctxerr.Wrap(ctx, err, "look up host by device uuid") } - // Mint a 32-byte KeyExchangeKey. This is the v2 secret returned to the - // device on its first key_request and reused for symmetric session keys - // thereafter. - var kek [32]byte - if _, err := rand.Read(kek[:]); err != nil { - return ctxerr.Wrap(ctx, err, "generate key exchange key") - } - - device := fleet.PSSODevice{ - HostID: host.ID, - DeviceUUID: req.DeviceUUID, - SigningKeyPEM: req.DeviceSigningKey, - EncryptionKeyPEM: req.DeviceEncryptionKey, - KeyExchangeKey: kek[:], - } // Store kids in canonical form so the token endpoint's lookup (which // canonicalizes the JWT's kid) matches regardless of base64 padding or // alphabet differences between the extension and Apple's framework. - signKID := fleet.PSSOKeyID{ - KID: canonicalizeKID(req.SignKeyID), - HostID: host.ID, - KeyType: fleet.PSSOKeyTypeSigning, - PEM: req.DeviceSigningKey, - } - encKID := fleet.PSSOKeyID{ - KID: canonicalizeKID(req.EncKeyID), - HostID: host.ID, - KeyType: fleet.PSSOKeyTypeEncryption, - PEM: req.DeviceEncryptionKey, - } - if err := svc.ds.SetOrUpdatePSSODevice(ctx, device, signKID, encKID); err != nil { + keys := []fleet.PSSOKey{ + { + KID: canonicalizeKID(req.SignKeyID), + KeyType: fleet.PSSOKeyTypeSigning, + PEM: req.DeviceSigningKey, + }, + { + KID: canonicalizeKID(req.EncKeyID), + KeyType: fleet.PSSOKeyTypeEncryption, + PEM: req.DeviceEncryptionKey, + }, + } + if err := svc.ds.SetOrUpdatePSSODevice(ctx, host.UUID, keys); err != nil { return ctxerr.Wrap(ctx, err, "persist psso device registration") } return nil @@ -328,7 +324,7 @@ func (svc *Service) PSSOToken(ctx context.Context, jwtBytes []byte) ([]byte, err return nil, &fleet.BadRequestError{Message: "psso token: empty request body"} } - claims, device, err := svc.parsePSSOInboundJWT(ctx, jwtBytes) + claims, signKey, err := svc.parsePSSOInboundJWT(ctx, jwtBytes) if err != nil { return nil, err } @@ -336,18 +332,14 @@ func (svc *Service) PSSOToken(ctx context.Context, jwtBytes []byte) ([]byte, err // PSSO v2 Password login: a single grant_type=password round trip carrying // a plaintext password and a jwe_crypto response recipe. if claims.GrantType == pssoGrantTypePassword { - return svc.handlePSSOPasswordLogin(ctx, device, claims) + return svc.handlePSSOPasswordLogin(ctx, signKey.HostUUID, claims) } - // Legacy request_type handshake model — retained but not exercised by the - // Password flow. switch claims.RequestType { case pssoRequestKey: - return svc.handlePSSOKeyRequest(ctx, device, claims) + return svc.handlePSSOKeyRequest(ctx, signKey.HostUUID, claims) case pssoRequestExchange: - return svc.handlePSSOKeyExchange(ctx, device, claims) - case pssoRequestPassword: - return svc.handlePSSOPasswordRequest(ctx, device, claims) + return svc.handlePSSOKeyExchange(ctx, signKey.HostUUID, claims) default: return nil, &fleet.BadRequestError{Message: "psso token: unsupported grant_type/request_type"} } @@ -391,7 +383,7 @@ func (svc *Service) pssoIDTokenIssuer(ctx context.Context) (string, error) { // Fleet validates the password against the upstream IdP, then returns the // resulting OIDC claims as a server-signed JWT wrapped in a JWE encrypted per // that recipe. -func (svc *Service) handlePSSOPasswordLogin(ctx context.Context, device *fleet.PSSODevice, claims *pssoTokenClaims) ([]byte, error) { +func (svc *Service) handlePSSOPasswordLogin(ctx context.Context, hostUUID string, claims *pssoTokenClaims) ([]byte, error) { if svc.pssoIdPClient == nil { return nil, ctxerr.New(ctx, "psso idp client not configured") } @@ -429,9 +421,9 @@ func (svc *Service) handlePSSOPasswordLogin(ctx context.Context, device *fleet.P return nil, ctxerr.Wrap(ctx, err, "psso password validation") } - recipientPub, err := parseECPublicKeyPEM([]byte(device.EncryptionKeyPEM)) + recipientPub, err := svc.resolvePSSOEncryptionKey(ctx, hostUUID, claims.JWECrypto.APV) if err != nil { - return nil, ctxerr.Wrap(ctx, err, "parse device encryption pubkey") + return nil, ctxerr.Wrap(ctx, err, "resolve device encryption pubkey") } // Per Apple's JWE login-response doc, the response id_token is verified by @@ -503,13 +495,13 @@ func (svc *Service) handlePSSOPasswordLogin(ctx context.Context, device *fleet.P // key_context} in a JWE (typ=platformsso-key-response+jwt) encrypted to the // device. key_context carries the provisioned PRIVATE key, sealed under a // server key, so the later key exchange can recover it statelessly. -func (svc *Service) handlePSSOKeyRequest(ctx context.Context, device *fleet.PSSODevice, claims *pssoTokenClaims) ([]byte, error) { +func (svc *Service) handlePSSOKeyRequest(ctx context.Context, hostUUID string, claims *pssoTokenClaims) ([]byte, error) { if claims.JWECrypto == nil || claims.JWECrypto.APV == "" { return nil, &fleet.BadRequestError{Message: "psso key request: missing jwe_crypto recipe"} } - encPub, err := parseECPublicKeyPEM([]byte(device.EncryptionKeyPEM)) + encPub, err := svc.resolvePSSOEncryptionKey(ctx, hostUUID, claims.JWECrypto.APV) if err != nil { - return nil, ctxerr.Wrap(ctx, err, "parse device encryption pubkey") + return nil, ctxerr.Wrap(ctx, err, "resolve device encryption pubkey") } provisioned, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) @@ -605,7 +597,7 @@ func (svc *Service) issuePSSOProvisionedCertificate(ctx context.Context, provisi // provisioned private key from key_context, computes the raw ECDH shared // secret against other_publickey (this is the unlock key), and returns // {iat, exp, key, key_context} in the same JWE envelope. -func (svc *Service) handlePSSOKeyExchange(ctx context.Context, device *fleet.PSSODevice, claims *pssoTokenClaims) ([]byte, error) { +func (svc *Service) handlePSSOKeyExchange(ctx context.Context, hostUUID string, claims *pssoTokenClaims) ([]byte, error) { if claims.JWECrypto == nil || claims.JWECrypto.APV == "" { return nil, &fleet.BadRequestError{Message: "psso key exchange: missing jwe_crypto recipe"} } @@ -635,9 +627,9 @@ func (svc *Service) handlePSSOKeyExchange(ctx context.Context, device *fleet.PSS return nil, ctxerr.Wrap(ctx, err, "compute key exchange shared secret") } - encPub, err := parseECPublicKeyPEM([]byte(device.EncryptionKeyPEM)) + encPub, err := svc.resolvePSSOEncryptionKey(ctx, hostUUID, claims.JWECrypto.APV) if err != nil { - return nil, ctxerr.Wrap(ctx, err, "parse device encryption pubkey") + return nil, ctxerr.Wrap(ctx, err, "resolve device encryption pubkey") } now := time.Now() @@ -658,46 +650,6 @@ func (svc *Service) handlePSSOKeyExchange(ctx context.Context, device *fleet.PSS return jwe, nil } -// handlePSSOPasswordRequest decrypts the password the device sent under -// the previously-established session key, validates it against the -// upstream IdP via the wired PSSOIdPClient, and returns the resulting -// claims as a JWT-inside-JWE. -func (svc *Service) handlePSSOPasswordRequest(ctx context.Context, device *fleet.PSSODevice, claims *pssoTokenClaims) ([]byte, error) { - if svc.pssoIdPClient == nil { - return nil, ctxerr.New(ctx, "psso idp client not configured") - } - if claims.Username == "" || claims.EncryptedPwd == "" { - return nil, &fleet.BadRequestError{Message: "psso password_request missing username or encrypted_password"} - } - - sessionKey, err := deriveSessionKey(device.KeyExchangeKey, []byte(claims.RequestNonce)) - if err != nil { - return nil, fmt.Errorf("derive session key: %w", err) - } - pwdPlain, err := decryptSymmetricBlob([]byte(claims.EncryptedPwd), sessionKey) - if err != nil { - return nil, fmt.Errorf("decrypt password blob: %w", err) - } - - idpClaims, err := svc.pssoIdPClient.ValidatePasswordAndGetClaims(ctx, claims.Username, string(pwdPlain)) - if err != nil { - return nil, err - } - - // Wrap the OIDC-shaped claims in a server-signed JWT, then JWE-wrap the - // JWT under the session key. - innerToken, err := svc.signServerJWT(ctx, jwt.MapClaims{ - "sub": idpClaims.Subject, - "email": idpClaims.Email, - "name": idpClaims.Name, - "preferred_username": idpClaims.PreferredUsername, - }) - if err != nil { - return nil, err - } - return buildSymmetricJWE(innerToken, sessionKey) -} - // PSSOJWKS returns the JWKS JSON with Fleet's PSSO signing public key. func (svc *Service) PSSOJWKS(ctx context.Context) ([]byte, error) { // skipauth: This is an unauthenticated public endpoint serving only the diff --git a/ee/server/service/apple_psso_crypto.go b/ee/server/service/apple_psso_crypto.go index 893905938c3..1b13a4a2c85 100644 --- a/ee/server/service/apple_psso_crypto.go +++ b/ee/server/service/apple_psso_crypto.go @@ -6,18 +6,11 @@ package service // // Cryptographic choices for the POC: // - Inbound JWTs from the Mac extension are ES256 (P-256). The kid in the -// header points to a PEM stored in mdm_apple_psso_key_ids. -// - "Asymmetric" JWE responses (key_request) use ECDH-ES with A256GCM, -// wrapped to the device's encryption pubkey. -// - "Symmetric" JWE responses (key_exchange, password_request) use -// A256GCM with the content-encryption key derived from KeyExchangeKey -// via HKDF-SHA256. -// -// TODO(apple-psso-spec): The exact claim names and the precise HKDF salt / -// info bindings in Apple's published spec should be confirmed before this -// POC ships to a real Mac. The names below ("key_exchange_key", "claims", -// etc.) are clean-room placeholders; if Apple's framework rejects them, this -// is the first place to look. +// header points to a PEM stored in mdm_apple_psso_keys. +// - JWE responses use ECDH-ES with A256GCM, wrapped to the device's +// registered encryption pubkey (resolved from the request's apv). +// - key_context blobs are sealed with A256GCM under a key derived from +// Fleet's PSSO signing key via HKDF-SHA256 — no per-device server state. import ( "context" @@ -51,15 +44,13 @@ type pssoRequestType string const ( pssoRequestKey pssoRequestType = "key_request" pssoRequestExchange pssoRequestType = "key_exchange" - pssoRequestPassword pssoRequestType = "password_request" ) // pssoTokenClaims models the union of claims an inbound token JWT can -// carry. The real PSSO v2 Password login request identifies itself with +// carry. The PSSO v2 Password login request identifies itself with // GrantType=="password" and carries a plaintext Password plus a JWECrypto -// recipe describing how the response must be encrypted. The RequestType / -// Encrypted* fields belong to an earlier handshake model and are retained -// only so the legacy dispatch path still compiles. +// recipe describing how the response must be encrypted; key requests and +// key exchanges identify themselves via RequestType instead. type pssoTokenClaims struct { jwt.RegisteredClaims @@ -76,11 +67,6 @@ type pssoTokenClaims struct { RequestType pssoRequestType `json:"request_type,omitempty"` OtherPublicKey string `json:"other_publickey,omitempty"` // device DH public key (key_exchange) KeyContext string `json:"key_context,omitempty"` // server-sealed provisioned key, echoed back - - // Legacy symmetric password_request handshake (unused by the Password - // grant flow). - EncryptedPwd string `json:"encrypted_password,omitempty"` - EncryptedNonce string `json:"encrypted_nonce,omitempty"` } // pssoJWECrypto is the jwe_crypto claim the extension sends to tell Fleet how @@ -96,8 +82,8 @@ type pssoJWECrypto struct { // parsePSSOInboundJWT verifies the inbound compact JWS using the device's // signing pubkey (resolved by kid) and returns the parsed claims plus the -// associated device record. -func (svc *Service) parsePSSOInboundJWT(ctx context.Context, jwtBytes []byte) (*pssoTokenClaims, *fleet.PSSODevice, error) { +// signing key row that matched (its HostUUID identifies the device). +func (svc *Service) parsePSSOInboundJWT(ctx context.Context, jwtBytes []byte) (*pssoTokenClaims, *fleet.PSSOKey, error) { // First parse without verification to extract kid. unverified, _, err := jwt.NewParser(jwt.WithoutClaimsValidation()).ParseUnverified(string(jwtBytes), &pssoTokenClaims{}) if err != nil { @@ -109,15 +95,15 @@ func (svc *Service) parsePSSOInboundJWT(ctx context.Context, jwtBytes []byte) (* } kid = canonicalizeKID(kid) - device, keyID, err := svc.ds.GetPSSODeviceByKeyID(ctx, kid) + signKey, err := svc.ds.GetPSSOKey(ctx, kid) if err != nil { - return nil, nil, ctxerr.Wrap(ctx, err, "look up psso device by kid") + return nil, nil, ctxerr.Wrap(ctx, err, "look up psso key by kid") } - if keyID.KeyType != fleet.PSSOKeyTypeSigning { + if signKey.KeyType != fleet.PSSOKeyTypeSigning { return nil, nil, &fleet.BadRequestError{Message: "psso jwt kid does not reference a signing key"} } - pub, err := parseECPublicKeyPEM([]byte(keyID.PEM)) + pub, err := parseECPublicKeyPEM([]byte(signKey.PEM)) if err != nil { return nil, nil, ctxerr.Wrap(ctx, err, "parse device signing pubkey") } @@ -132,7 +118,67 @@ func (svc *Service) parsePSSOInboundJWT(ctx context.Context, jwtBytes []byte) (* if !ok || !tok.Valid { return nil, nil, &fleet.BadRequestError{Message: "psso jwt claims invalid"} } - return claims, device, nil + return claims, signKey, nil +} + +// resolvePSSOEncryptionKey returns the registered encryption public key the +// response JWE must be wrapped to. The device names its encryption key inside +// the request's apv party-info blob ("Apple" || deviceEncKey || nonce), and +// the extension registered that key under kid = base64url(SHA-256(raw key +// bytes)) — so the kid is recomputed from apv and looked up. As a fallback +// against any re-encoding of the key by Apple's framework, the raw point is +// compared against each of the host's registered encryption keys. A key that +// resolves but belongs to a different host, or doesn't resolve at all, is +// rejected: responses are only ever encrypted to keys the host registered. +func (svc *Service) resolvePSSOEncryptionKey(ctx context.Context, hostUUID, apvB64 string) (*ecdsa.PublicKey, error) { + apvRaw, err := decodeJOSEB64(apvB64) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "decode apv") + } + fields, err := parseApplePartyInfo(apvRaw) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "parse apv party-info") + } + if len(fields) < 2 || string(fields[0]) != apvPartyLabel { + return nil, &fleet.BadRequestError{Message: "psso: apv is not an Apple party-info blob"} + } + encKeyRaw := fields[1] + + sum := sha256.Sum256(encKeyRaw) + kid := canonicalizeKID(base64.RawURLEncoding.EncodeToString(sum[:])) + key, err := svc.ds.GetPSSOKey(ctx, kid) + switch { + case err == nil: + if key.KeyType != fleet.PSSOKeyTypeEncryption || key.HostUUID != hostUUID { + return nil, &fleet.BadRequestError{Message: "psso: apv key is not a registered encryption key for this device"} + } + return parseECPublicKeyPEM([]byte(key.PEM)) + case !fleet.IsNotFound(err): + return nil, ctxerr.Wrap(ctx, err, "look up encryption key by apv kid") + } + + apvPub, err := parseRawECPoint(encKeyRaw) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "parse apv encryption key") + } + hostKeys, err := svc.ds.ListPSSOKeys(ctx, hostUUID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "list psso keys for apv fallback") + } + for _, k := range hostKeys { + if k.KeyType != fleet.PSSOKeyTypeEncryption { + continue + } + pub, err := parseECPublicKeyPEM([]byte(k.PEM)) + if err != nil { + svc.logger.WarnContext(ctx, "psso: skipping unparseable registered encryption key", "kid", k.KID, "err", err) + continue + } + if pub.Equal(apvPub) { + return pub, nil + } + } + return nil, &fleet.BadRequestError{Message: "psso: apv key is not a registered encryption key for this device"} } // canonicalizeKID normalizes a key ID to a stable comparison form. Apple's @@ -198,8 +244,8 @@ func parseRawECPoint(raw []byte) (*ecdsa.PublicKey, error) { } // buildAsymmetricJWE encrypts payload to deviceEncPub using JWE -// ECDH-ES + A256GCM. Used for the key_request response that delivers the -// initial KeyExchangeKey to the device. +// ECDH-ES + A256GCM via go-jose's stock encrypter (empty apu/apv — see +// buildPSSOResponseJWE for the Apple-party-info variant the handlers use). func buildAsymmetricJWE(payload []byte, deviceEncPub *ecdsa.PublicKey, kid string) ([]byte, error) { enc, err := jose.NewEncrypter( jose.A256GCM, @@ -422,16 +468,15 @@ func decodeBase64Flexible(s string) ([]byte, error) { return nil, errors.New("psso: value is not valid base64") } -// pssoSessionInfo is the HKDF info string distinguishing PSSO session keys -// from any other purpose KeyExchangeKey could be used for. +// pssoSessionInfo is the HKDF info string distinguishing PSSO-derived keys +// from any other derivation the same input keying material could feed. var pssoSessionInfo = []byte("fleetdm-psso-session-key-v1") -// deriveSessionKey returns a 32-byte AES-256 key derived from the device's -// KeyExchangeKey via HKDF-SHA256. The salt parameter binds the derivation -// to a specific request (typically the request_nonce) so each sign-in uses -// a distinct content-encryption key. -func deriveSessionKey(kek []byte, salt []byte) ([]byte, error) { - r := hkdf.New(sha256.New, kek, salt, pssoSessionInfo) +// deriveSessionKey returns a 32-byte AES-256 key derived from ikm via +// HKDF-SHA256. The salt parameter binds the derivation to a purpose (e.g. +// the key_context info string in deriveKeyContextKey). +func deriveSessionKey(ikm []byte, salt []byte) ([]byte, error) { + r := hkdf.New(sha256.New, ikm, salt, pssoSessionInfo) out := make([]byte, 32) if _, err := r.Read(out); err != nil { return nil, fmt.Errorf("hkdf read: %w", err) @@ -440,8 +485,8 @@ func deriveSessionKey(kek []byte, salt []byte) ([]byte, error) { } // buildSymmetricJWE returns an A256GCM JWE of payload, keyed by sessionKey. -// Used for key_exchange and password_request responses where the device -// has already established a shared secret via the KeyExchangeKey handshake. +// Used to seal key_context blobs so the provisioned private key can +// round-trip statelessly between key_request and key_exchange. func buildSymmetricJWE(payload []byte, sessionKey []byte) ([]byte, error) { if len(sessionKey) != 32 { return nil, fmt.Errorf("psso: session key must be 32 bytes, got %d", len(sessionKey)) @@ -476,9 +521,8 @@ func buildSymmetricJWE(payload []byte, sessionKey []byte) ([]byte, error) { return json.Marshal(envelope) } -// decryptSymmetricBlob is the inverse of buildSymmetricJWE — used in -// password_request to decrypt the password the device sent under the -// previously-established session key. +// decryptSymmetricBlob is the inverse of buildSymmetricJWE — used to open +// the key_context blob a device echoes back in a key-exchange request. func decryptSymmetricBlob(blob []byte, sessionKey []byte) ([]byte, error) { if len(sessionKey) != 32 { return nil, fmt.Errorf("psso: session key must be 32 bytes, got %d", len(sessionKey)) @@ -517,7 +561,7 @@ func (svc *Service) signServerJWT(ctx context.Context, claims jwt.Claims) ([]byt tok.Header["kid"] = kid signed, err := tok.SignedString(key) if err != nil { - return nil, fmt.Errorf("sign server jwt: %w", err) + return nil, ctxerr.Wrap(ctx, err, "sign server jwt") } return []byte(signed), nil } diff --git a/ee/server/service/apple_psso_crypto_test.go b/ee/server/service/apple_psso_crypto_test.go index 163b87ba39e..3b99736948c 100644 --- a/ee/server/service/apple_psso_crypto_test.go +++ b/ee/server/service/apple_psso_crypto_test.go @@ -1,22 +1,28 @@ package service import ( + "context" "crypto/ecdsa" "crypto/elliptic" "crypto/rand" + "crypto/sha256" "crypto/x509" "encoding/base64" "encoding/pem" + "io" + "log/slog" "testing" + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/mock" jose "github.com/go-jose/go-jose/v3" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -// TestPSSO_SymmetricRoundTrip exercises the AES-256-GCM envelope used for -// key_exchange and password_request responses. Encrypting and then -// decrypting under the same session key must yield the original plaintext. +// TestPSSO_SymmetricRoundTrip exercises the AES-256-GCM envelope used to +// seal key_context blobs. Encrypting and then decrypting under the same +// session key must yield the original plaintext. func TestPSSO_SymmetricRoundTrip(t *testing.T) { key := make([]byte, 32) _, err := rand.Read(key) @@ -81,13 +87,13 @@ func TestPSSO_HKDFDifferentSaltDifferentKey(t *testing.T) { } // TestPSSO_AsymmetricEncryptRoundTrip confirms that a payload encrypted to -// a device's encryption pubkey via JWE ECDH-ES + A256GCM can be decrypted -// with the corresponding private key. This is the key_request flow. +// a device's encryption pubkey via JWE ECDH-ES + A256GCM produces a valid +// compact JWE. func TestPSSO_AsymmetricEncryptRoundTrip(t *testing.T) { deviceKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) require.NoError(t, err) - payload := []byte(`{"key_exchange_key":"AAECAwQF"}`) + payload := []byte(`{"claims":"AAECAwQF"}`) jweCompact, err := buildAsymmetricJWE(payload, &deviceKey.PublicKey, "") require.NoError(t, err) require.NotEmpty(t, jweCompact) @@ -275,6 +281,108 @@ func TestPSSO_ParseECPublicKey(t *testing.T) { require.Error(t, err) } +// TestPSSO_ResolveEncryptionKey covers resolving the response-encryption key +// from a request's apv blob: the kid is recomputed as SHA-256 of the raw key +// bytes the device placed in apv (matching how the extension registers its +// kids), looked up, and validated as an encryption key belonging to the +// requesting host. When the kid lookup misses, the host's registered +// encryption keys are compared point-by-point as a fallback. +func TestPSSO_ResolveEncryptionKey(t *testing.T) { + const hostUUID = "ABCDEFGH-0000-0000-0000-111111111111" + + encPriv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + encECDH, err := encPriv.PublicKey.ECDH() + require.NoError(t, err) + rawPoint := encECDH.Bytes() + pemBytes := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: rawPoint}) + + sum := sha256.Sum256(rawPoint) + kid := canonicalizeKID(base64.RawURLEncoding.EncodeToString(sum[:])) + + apv := base64.RawURLEncoding.EncodeToString( + encodeApplePartyInfo([]byte(apvPartyLabel), rawPoint, []byte("nonce"))) + + newSvc := func() (*Service, *mock.DataStore) { + ds := new(mock.DataStore) + svc := &Service{ds: ds, logger: slog.New(slog.NewTextHandler(io.Discard, nil))} + return svc, ds + } + registeredKey := &fleet.PSSOKey{ + KID: kid, + HostUUID: hostUUID, + KeyType: fleet.PSSOKeyTypeEncryption, + PEM: string(pemBytes), + } + + t.Run("resolves by kid computed from apv", func(t *testing.T) { + svc, ds := newSvc() + ds.GetPSSOKeyFunc = func(ctx context.Context, gotKID string) (*fleet.PSSOKey, error) { + require.Equal(t, kid, gotKID) + return registeredKey, nil + } + pub, err := svc.resolvePSSOEncryptionKey(t.Context(), hostUUID, apv) + require.NoError(t, err) + assert.True(t, pub.Equal(&encPriv.PublicKey)) + }) + + t.Run("rejects a key registered to a different host", func(t *testing.T) { + svc, ds := newSvc() + ds.GetPSSOKeyFunc = func(ctx context.Context, _ string) (*fleet.PSSOKey, error) { + other := *registeredKey + other.HostUUID = "some-other-host" + return &other, nil + } + _, err := svc.resolvePSSOEncryptionKey(t.Context(), hostUUID, apv) + require.Error(t, err) + }) + + t.Run("rejects a signing key", func(t *testing.T) { + svc, ds := newSvc() + ds.GetPSSOKeyFunc = func(ctx context.Context, _ string) (*fleet.PSSOKey, error) { + other := *registeredKey + other.KeyType = fleet.PSSOKeyTypeSigning + return &other, nil + } + _, err := svc.resolvePSSOEncryptionKey(t.Context(), hostUUID, apv) + require.Error(t, err) + }) + + t.Run("falls back to comparing the host's registered keys", func(t *testing.T) { + svc, ds := newSvc() + ds.GetPSSOKeyFunc = func(ctx context.Context, _ string) (*fleet.PSSOKey, error) { + return nil, &testNotFoundError{} + } + ds.ListPSSOKeysFunc = func(ctx context.Context, gotUUID string) ([]*fleet.PSSOKey, error) { + require.Equal(t, hostUUID, gotUUID) + return []*fleet.PSSOKey{registeredKey}, nil + } + pub, err := svc.resolvePSSOEncryptionKey(t.Context(), hostUUID, apv) + require.NoError(t, err) + assert.True(t, pub.Equal(&encPriv.PublicKey)) + assert.True(t, ds.ListPSSOKeysFuncInvoked) + }) + + t.Run("rejects when no registered key matches", func(t *testing.T) { + svc, ds := newSvc() + ds.GetPSSOKeyFunc = func(ctx context.Context, _ string) (*fleet.PSSOKey, error) { + return nil, &testNotFoundError{} + } + ds.ListPSSOKeysFunc = func(ctx context.Context, _ string) ([]*fleet.PSSOKey, error) { + return nil, nil + } + _, err := svc.resolvePSSOEncryptionKey(t.Context(), hostUUID, apv) + require.Error(t, err) + }) + + t.Run("rejects a malformed apv", func(t *testing.T) { + svc, _ := newSvc() + _, err := svc.resolvePSSOEncryptionKey(t.Context(), hostUUID, + base64.RawURLEncoding.EncodeToString([]byte("not party info"))) + require.Error(t, err) + }) +} + // TestPSSO_ParseRawECPointPEM covers the form the macOS extension actually // sends: a raw ANSI X9.63 uncompressed point (0x04 || X || Y) PEM-wrapped // under a "PUBLIC KEY" label rather than DER SubjectPublicKeyInfo. diff --git a/ee/server/service/service.go b/ee/server/service/service.go index 3aa89bddeb4..88be39177ee 100644 --- a/ee/server/service/service.go +++ b/ee/server/service/service.go @@ -27,7 +27,7 @@ type Service struct { // PSSO POC. Required for PSSO nonce/register/token flows. pssoNonceStore fleet.PSSONonceStore - // pssoIdPClient validates passwords for the PSSO password_request flow. + // pssoIdPClient validates passwords for the PSSO password login flow. // Wired via SetPSSOIdPClient. pssoIdPClient fleet.PSSOIdPClient diff --git a/server/datastore/mysql/apple_mdm.go b/server/datastore/mysql/apple_mdm.go index 3d7f79594b6..ef3e83cfb32 100644 --- a/server/datastore/mysql/apple_mdm.go +++ b/server/datastore/mysql/apple_mdm.go @@ -7482,6 +7482,12 @@ func (ds *Datastore) MDMAppleResetOnReenrollment(ctx context.Context, hostUUID s } } + // Clear the PSSO registration (keys cascade) so an ADE re-enrollment + // starts from fresh device keys. + if _, err := tx.ExecContext(ctx, "DELETE FROM mdm_apple_psso_devices WHERE host_uuid = ?", hostUUID); err != nil { + return ctxerr.Wrap(ctx, err, "clear psso registration for mdm reset", "host_uuid", hostUUID) + } + if !preserveHostActivities { if err := ds.clearHostActivitiesForAppleMDMReset(ctx, tx, hostUUID, hostID); err != nil { return ctxerr.Wrap(ctx, err, "clear host activities for mdm reset") diff --git a/server/datastore/mysql/apple_mdm_test.go b/server/datastore/mysql/apple_mdm_test.go index d9503bd6403..1bc4fba70d4 100644 --- a/server/datastore/mysql/apple_mdm_test.go +++ b/server/datastore/mysql/apple_mdm_test.go @@ -5597,11 +5597,18 @@ func testMDMAppleResetOnReenrollment(t *testing.T, ds *Datastore) { _, err = ds.writer(ctx).ExecContext(ctx, `INSERT INTO script_upcoming_activities (upcoming_activity_id) VALUES (?)`, uaID) require.NoError(t, err) + + // PSSO registration (host_uuid ref - device row plus a cascading key) + require.NoError(t, ds.SetOrUpdatePSSODevice(ctx, h.UUID, []fleet.PSSOKey{ + {KID: "kid-" + h.UUID, KeyType: fleet.PSSOKeyTypeSigning, PEM: "pem-" + h.UUID}, + })) } type counts struct { - label int - upcoming int + label int + upcoming int + pssoDevice int + pssoKey int } countRows := func(t *testing.T, h *fleet.Host) counts { var c counts @@ -5609,9 +5616,13 @@ func testMDMAppleResetOnReenrollment(t *testing.T, ds *Datastore) { `SELECT COUNT(*) FROM label_membership WHERE host_id = ?`, h.ID)) require.NoError(t, sqlx.GetContext(ctx, ds.writer(ctx), &c.upcoming, `SELECT COUNT(*) FROM upcoming_activities WHERE host_id = ?`, h.ID)) + require.NoError(t, sqlx.GetContext(ctx, ds.writer(ctx), &c.pssoDevice, + `SELECT COUNT(*) FROM mdm_apple_psso_devices WHERE host_uuid = ?`, h.UUID)) + require.NoError(t, sqlx.GetContext(ctx, ds.writer(ctx), &c.pssoKey, + `SELECT COUNT(*) FROM mdm_apple_psso_keys WHERE host_uuid = ?`, h.UUID)) return c } - seeded := counts{label: 1, upcoming: 1} + seeded := counts{label: 1, upcoming: 1, pssoDevice: 1, pssoKey: 1} t.Run("clears expected tables and leaves other hosts untouched", func(t *testing.T) { hostA := newHost("clear-A") @@ -5626,7 +5637,7 @@ func testMDMAppleResetOnReenrollment(t *testing.T, ds *Datastore) { require.NoError(t, ds.MDMAppleResetOnReenrollment(ctx, hostA.UUID, true)) // host A: everything cleared - assert.Equal(t, counts{label: 0, upcoming: 0}, countRows(t, hostA)) + assert.Equal(t, counts{}, countRows(t, hostA)) // host B: untouched (control - proves the reset is host-scoped) assert.Equal(t, seeded, countRows(t, hostB)) diff --git a/server/datastore/mysql/apple_psso.go b/server/datastore/mysql/apple_psso.go index 6a368b93ccc..ad50dcbc828 100644 --- a/server/datastore/mysql/apple_psso.go +++ b/server/datastore/mysql/apple_psso.go @@ -10,114 +10,89 @@ import ( "github.com/jmoiron/sqlx" ) -// SetOrUpdatePSSODevice replaces (or creates) a host's PSSO registration in a -// single transaction: upserts the device row, deletes any stale KeyID rows for -// the host, then inserts the two new KeyID rows (signing + encryption). -func (ds *Datastore) SetOrUpdatePSSODevice( - ctx context.Context, - device fleet.PSSODevice, - signKeyID fleet.PSSOKeyID, - encKeyID fleet.PSSOKeyID, -) error { +// SetOrUpdatePSSODevice upserts a host's PSSO registration: the device row +// plus the given key rows in a single transaction. Keys are upserted by kid; +// keys from earlier registrations are left in place so they keep working. +func (ds *Datastore) SetOrUpdatePSSODevice(ctx context.Context, hostUUID string, keys []fleet.PSSOKey) error { return ds.withTx(ctx, func(tx sqlx.ExtContext) error { const upsertDevice = ` - INSERT INTO mdm_apple_psso_devices - (host_id, device_uuid, signing_key_pem, encryption_key_pem, key_exchange_key) - VALUES (?, ?, ?, ?, ?) - ON DUPLICATE KEY UPDATE - device_uuid = VALUES(device_uuid), - signing_key_pem = VALUES(signing_key_pem), - encryption_key_pem = VALUES(encryption_key_pem), - key_exchange_key = VALUES(key_exchange_key) + INSERT INTO mdm_apple_psso_devices (host_uuid) + VALUES (?) + ON DUPLICATE KEY UPDATE updated_at = CURRENT_TIMESTAMP(6) ` - if _, err := tx.ExecContext(ctx, upsertDevice, - device.HostID, - device.DeviceUUID, - device.SigningKeyPEM, - device.EncryptionKeyPEM, - device.KeyExchangeKey, - ); err != nil { + if _, err := tx.ExecContext(ctx, upsertDevice, hostUUID); err != nil { return ctxerr.Wrap(ctx, err, "upsert psso device") } - if _, err := tx.ExecContext(ctx, - `DELETE FROM mdm_apple_psso_key_ids WHERE host_id = ?`, - device.HostID, - ); err != nil { - return ctxerr.Wrap(ctx, err, "clear existing psso key_ids") - } - - const insertKeyID = ` - INSERT INTO mdm_apple_psso_key_ids (kid, host_id, key_type, pem) + const upsertKey = ` + INSERT INTO mdm_apple_psso_keys (kid, host_uuid, key_type, pem) VALUES (?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + host_uuid = VALUES(host_uuid), + key_type = VALUES(key_type), + pem = VALUES(pem) ` - for _, k := range []fleet.PSSOKeyID{signKeyID, encKeyID} { - if _, err := tx.ExecContext(ctx, insertKeyID, k.KID, k.HostID, k.KeyType, k.PEM); err != nil { - return ctxerr.Wrap(ctx, err, "insert psso key_id") + for _, k := range keys { + if _, err := tx.ExecContext(ctx, upsertKey, k.KID, hostUUID, k.KeyType, k.PEM); err != nil { + return ctxerr.Wrap(ctx, err, "upsert psso key") } } return nil }) } -// GetPSSODeviceByKeyID resolves a kid back to its owning device and the -// specific KeyID row that matched (so callers know whether they're holding the -// signing or encryption side of the device's keypair). -func (ds *Datastore) GetPSSODeviceByKeyID(ctx context.Context, kid string) (*fleet.PSSODevice, *fleet.PSSOKeyID, error) { - type joined struct { - // device columns - HostID uint `db:"host_id"` - DeviceUUID string `db:"device_uuid"` - SigningKeyPEM string `db:"signing_key_pem"` - EncryptionKeyPEM string `db:"encryption_key_pem"` - KeyExchangeKey []byte `db:"key_exchange_key"` - DeviceCreatedAt []byte `db:"device_created_at"` - DeviceUpdatedAt []byte `db:"device_updated_at"` - // key_id columns - KID string `db:"kid"` - KeyType fleet.PSSOKeyType `db:"key_type"` - PEM string `db:"pem"` - KIDCreated []byte `db:"kid_created_at"` +func (ds *Datastore) GetPSSODevice(ctx context.Context, hostUUID string) (*fleet.PSSODevice, error) { + const stmt = ` + SELECT host_uuid, created_at, updated_at + FROM mdm_apple_psso_devices + WHERE host_uuid = ? + ` + var device fleet.PSSODevice + if err := sqlx.GetContext(ctx, ds.reader(ctx), &device, stmt, hostUUID); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ctxerr.Wrap(ctx, notFound("PSSODevice").WithName(hostUUID)) + } + return nil, ctxerr.Wrap(ctx, err, "get psso device") } + return &device, nil +} +func (ds *Datastore) GetPSSOKey(ctx context.Context, kid string) (*fleet.PSSOKey, error) { const stmt = ` - SELECT - d.host_id AS host_id, - d.device_uuid AS device_uuid, - d.signing_key_pem AS signing_key_pem, - d.encryption_key_pem AS encryption_key_pem, - d.key_exchange_key AS key_exchange_key, - d.created_at AS device_created_at, - d.updated_at AS device_updated_at, - k.kid AS kid, - k.key_type AS key_type, - k.pem AS pem, - k.created_at AS kid_created_at - FROM mdm_apple_psso_key_ids k - JOIN mdm_apple_psso_devices d ON d.host_id = k.host_id - WHERE k.kid = ? + SELECT kid, host_uuid, key_type, pem, created_at, updated_at + FROM mdm_apple_psso_keys + WHERE kid = ? ` - - var row joined - if err := sqlx.GetContext(ctx, ds.reader(ctx), &row, stmt, kid); err != nil { + var key fleet.PSSOKey + if err := sqlx.GetContext(ctx, ds.reader(ctx), &key, stmt, kid); err != nil { if errors.Is(err, sql.ErrNoRows) { - return nil, nil, ctxerr.Wrap(ctx, notFound("PSSOKeyID").WithName(kid)) + return nil, ctxerr.Wrap(ctx, notFound("PSSOKey").WithName(kid)) } - return nil, nil, ctxerr.Wrap(ctx, err, "get psso device by kid") + return nil, ctxerr.Wrap(ctx, err, "get psso key") } + return &key, nil +} - device := &fleet.PSSODevice{ - HostID: row.HostID, - DeviceUUID: row.DeviceUUID, - SigningKeyPEM: row.SigningKeyPEM, - EncryptionKeyPEM: row.EncryptionKeyPEM, - KeyExchangeKey: row.KeyExchangeKey, +func (ds *Datastore) ListPSSOKeys(ctx context.Context, hostUUID string) ([]*fleet.PSSOKey, error) { + const stmt = ` + SELECT kid, host_uuid, key_type, pem, created_at, updated_at + FROM mdm_apple_psso_keys + WHERE host_uuid = ? + ORDER BY created_at DESC, kid + ` + var keys []*fleet.PSSOKey + if err := sqlx.SelectContext(ctx, ds.reader(ctx), &keys, stmt, hostUUID); err != nil { + return nil, ctxerr.Wrap(ctx, err, "list psso keys") } - keyID := &fleet.PSSOKeyID{ - KID: row.KID, - HostID: row.HostID, - KeyType: row.KeyType, - PEM: row.PEM, + return keys, nil +} + +// DeletePSSODevice clears a host's PSSO registration; the keys cascade. +func (ds *Datastore) DeletePSSODevice(ctx context.Context, hostUUID string) error { + if _, err := ds.writer(ctx).ExecContext(ctx, + `DELETE FROM mdm_apple_psso_devices WHERE host_uuid = ?`, hostUUID, + ); err != nil { + return ctxerr.Wrap(ctx, err, "delete psso device") } - return device, keyID, nil + return nil } diff --git a/server/datastore/mysql/apple_psso_test.go b/server/datastore/mysql/apple_psso_test.go new file mode 100644 index 00000000000..deddbb63b00 --- /dev/null +++ b/server/datastore/mysql/apple_psso_test.go @@ -0,0 +1,147 @@ +package mysql + +import ( + "testing" + + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestApplePSSO(t *testing.T) { + ds := CreateMySQLDS(t) + + cases := []struct { + name string + fn func(t *testing.T, ds *Datastore) + }{ + {"SetOrUpdateAndGet", testPSSOSetOrUpdateAndGet}, + {"ReRegistrationKeepsOldKeys", testPSSOReRegistrationKeepsOldKeys}, + {"DeleteDevice", testPSSODeleteDevice}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + defer TruncateTables(t, ds) + c.fn(t, ds) + }) + } +} + +func testPSSOSetOrUpdateAndGet(t *testing.T, ds *Datastore) { + ctx := t.Context() + const hostUUID = "ABCDEFGH-0000-0000-0000-111111111111" + + keys := []fleet.PSSOKey{ + {KID: "kid-sign-1", KeyType: fleet.PSSOKeyTypeSigning, PEM: "sign-pem-1"}, + {KID: "kid-enc-1", KeyType: fleet.PSSOKeyTypeEncryption, PEM: "enc-pem-1"}, + } + require.NoError(t, ds.SetOrUpdatePSSODevice(ctx, hostUUID, keys)) + + device, err := ds.GetPSSODevice(ctx, hostUUID) + require.NoError(t, err) + assert.Equal(t, hostUUID, device.HostUUID) + assert.False(t, device.CreatedAt.IsZero()) + assert.False(t, device.UpdatedAt.IsZero()) + + _, err = ds.GetPSSODevice(ctx, "unregistered-uuid") + require.Error(t, err) + assert.True(t, fleet.IsNotFound(err)) + + signKey, err := ds.GetPSSOKey(ctx, "kid-sign-1") + require.NoError(t, err) + assert.Equal(t, hostUUID, signKey.HostUUID) + assert.Equal(t, fleet.PSSOKeyTypeSigning, signKey.KeyType) + assert.Equal(t, "sign-pem-1", signKey.PEM) + + encKey, err := ds.GetPSSOKey(ctx, "kid-enc-1") + require.NoError(t, err) + assert.Equal(t, fleet.PSSOKeyTypeEncryption, encKey.KeyType) + assert.Equal(t, "enc-pem-1", encKey.PEM) + + _, err = ds.GetPSSOKey(ctx, "no-such-kid") + require.Error(t, err) + assert.True(t, fleet.IsNotFound(err)) + + listed, err := ds.ListPSSOKeys(ctx, hostUUID) + require.NoError(t, err) + assert.Len(t, listed, 2) + + listed, err = ds.ListPSSOKeys(ctx, "unregistered-uuid") + require.NoError(t, err) + assert.Empty(t, listed) + + // Upserting the same kid updates the row in place. + require.NoError(t, ds.SetOrUpdatePSSODevice(ctx, hostUUID, []fleet.PSSOKey{ + {KID: "kid-sign-1", KeyType: fleet.PSSOKeyTypeSigning, PEM: "sign-pem-1-rotated"}, + })) + signKey, err = ds.GetPSSOKey(ctx, "kid-sign-1") + require.NoError(t, err) + assert.Equal(t, "sign-pem-1-rotated", signKey.PEM) + + listed, err = ds.ListPSSOKeys(ctx, hostUUID) + require.NoError(t, err) + assert.Len(t, listed, 2) +} + +func testPSSOReRegistrationKeepsOldKeys(t *testing.T, ds *Datastore) { + ctx := t.Context() + const hostUUID = "ABCDEFGH-0000-0000-0000-222222222222" + + require.NoError(t, ds.SetOrUpdatePSSODevice(ctx, hostUUID, []fleet.PSSOKey{ + {KID: "kid-sign-old", KeyType: fleet.PSSOKeyTypeSigning, PEM: "sign-pem-old"}, + {KID: "kid-enc-old", KeyType: fleet.PSSOKeyTypeEncryption, PEM: "enc-pem-old"}, + })) + + // Re-register with fresh keys: old keys must remain resolvable. + require.NoError(t, ds.SetOrUpdatePSSODevice(ctx, hostUUID, []fleet.PSSOKey{ + {KID: "kid-sign-new", KeyType: fleet.PSSOKeyTypeSigning, PEM: "sign-pem-new"}, + {KID: "kid-enc-new", KeyType: fleet.PSSOKeyTypeEncryption, PEM: "enc-pem-new"}, + })) + + for _, kid := range []string{"kid-sign-old", "kid-enc-old", "kid-sign-new", "kid-enc-new"} { + key, err := ds.GetPSSOKey(ctx, kid) + require.NoError(t, err, "kid %s", kid) + assert.Equal(t, hostUUID, key.HostUUID) + } + + listed, err := ds.ListPSSOKeys(ctx, hostUUID) + require.NoError(t, err) + assert.Len(t, listed, 4) +} + +func testPSSODeleteDevice(t *testing.T, ds *Datastore) { + ctx := t.Context() + const ( + hostUUID1 = "ABCDEFGH-0000-0000-0000-333333333333" + hostUUID2 = "ABCDEFGH-0000-0000-0000-444444444444" + ) + + require.NoError(t, ds.SetOrUpdatePSSODevice(ctx, hostUUID1, []fleet.PSSOKey{ + {KID: "kid-sign-h1", KeyType: fleet.PSSOKeyTypeSigning, PEM: "p"}, + {KID: "kid-enc-h1", KeyType: fleet.PSSOKeyTypeEncryption, PEM: "p"}, + })) + require.NoError(t, ds.SetOrUpdatePSSODevice(ctx, hostUUID2, []fleet.PSSOKey{ + {KID: "kid-sign-h2", KeyType: fleet.PSSOKeyTypeSigning, PEM: "p"}, + })) + + require.NoError(t, ds.DeletePSSODevice(ctx, hostUUID1)) + + _, err := ds.GetPSSODevice(ctx, hostUUID1) + assert.True(t, fleet.IsNotFound(err)) + + // Keys cascade with the device row. + _, err = ds.GetPSSOKey(ctx, "kid-sign-h1") + assert.True(t, fleet.IsNotFound(err)) + listed, err := ds.ListPSSOKeys(ctx, hostUUID1) + require.NoError(t, err) + assert.Empty(t, listed) + + // Other hosts are untouched. + _, err = ds.GetPSSODevice(ctx, hostUUID2) + require.NoError(t, err) + _, err = ds.GetPSSOKey(ctx, "kid-sign-h2") + require.NoError(t, err) + + // Deleting an unregistered host is a no-op. + require.NoError(t, ds.DeletePSSODevice(ctx, "never-registered")) +} diff --git a/server/datastore/mysql/hosts.go b/server/datastore/mysql/hosts.go index 52ff44057fd..ea7937357c5 100644 --- a/server/datastore/mysql/hosts.go +++ b/server/datastore/mysql/hosts.go @@ -626,6 +626,11 @@ var hostRefs = []string{ // Orbit re-enrollment recreates the host row and the existing password row remains // reachable for view/rotate. Apple-MDM unenroll/re-enroll is handled separately by // MDMResetEnrollment, which soft-deletes the row. +// - mdm_apple_psso_devices / mdm_apple_psso_keys: keyed by host_uuid, intentionally +// preserved across host deletion for the same reason — the Mac may still be +// MDM-enrolled with Platform SSO active, and its registered keys must keep +// authenticating token requests. ADE re-enrollment clears them via +// MDMAppleResetOnReenrollment. // additionalHostRefsByUUID are host refs cannot be deleted using the host.id like the hostRefs // above. They use the host.uuid instead. Additionally, the column name that refers to diff --git a/server/datastore/mysql/migrations/tables/20260611140639_CreateApplePSSOTables.go b/server/datastore/mysql/migrations/tables/20260611140639_CreateApplePSSOTables.go index 80a887e3f67..20e3d3255fe 100644 --- a/server/datastore/mysql/migrations/tables/20260611140639_CreateApplePSSOTables.go +++ b/server/datastore/mysql/migrations/tables/20260611140639_CreateApplePSSOTables.go @@ -10,36 +10,36 @@ func init() { } func Up_20260611140639(tx *sql.Tx) error { + // Like most host-adjacent tables, these are keyed by host UUID with no FK + // to hosts (avoided for performance). A device row marks a host as + // PSSO-registered and is the cascade anchor for clearing all of a host's + // PSSO state; the public keys themselves live in mdm_apple_psso_keys, + // possibly several per host since old keys keep working after the device + // rotates or re-registers. if _, err := tx.Exec(` CREATE TABLE mdm_apple_psso_devices ( - host_id INT UNSIGNED NOT NULL, - device_uuid VARCHAR(255) COLLATE utf8mb4_unicode_ci NOT NULL, - signing_key_pem TEXT COLLATE utf8mb4_unicode_ci NOT NULL, - encryption_key_pem TEXT COLLATE utf8mb4_unicode_ci NOT NULL, - key_exchange_key VARBINARY(64) NOT NULL, - created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), - updated_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), - PRIMARY KEY (host_id), - UNIQUE KEY idx_mdm_apple_psso_devices_device_uuid (device_uuid), - CONSTRAINT fk_mdm_apple_psso_devices_host_id FOREIGN KEY (host_id) REFERENCES hosts (id) ON DELETE CASCADE + host_uuid VARCHAR(255) COLLATE utf8mb4_unicode_ci NOT NULL, + created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + updated_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + PRIMARY KEY (host_uuid) ) `); err != nil { return fmt.Errorf("creating mdm_apple_psso_devices table: %w", err) } if _, err := tx.Exec(` - CREATE TABLE mdm_apple_psso_key_ids ( - kid VARCHAR(255) COLLATE utf8mb4_unicode_ci NOT NULL, - host_id INT UNSIGNED NOT NULL, - key_type ENUM('signing','encryption') COLLATE utf8mb4_unicode_ci NOT NULL, - pem TEXT COLLATE utf8mb4_unicode_ci NOT NULL, - created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + CREATE TABLE mdm_apple_psso_keys ( + kid VARCHAR(255) COLLATE utf8mb4_unicode_ci NOT NULL, + host_uuid VARCHAR(255) COLLATE utf8mb4_unicode_ci NOT NULL, + key_type ENUM('signing','encryption') COLLATE utf8mb4_unicode_ci NOT NULL, + pem TEXT COLLATE utf8mb4_unicode_ci NOT NULL, + created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + updated_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), PRIMARY KEY (kid), - UNIQUE KEY idx_mdm_apple_psso_key_ids_host_type (host_id, key_type), - CONSTRAINT fk_mdm_apple_psso_key_ids_host_id FOREIGN KEY (host_id) REFERENCES hosts (id) ON DELETE CASCADE + CONSTRAINT fk_mdm_apple_psso_keys_host_uuid FOREIGN KEY (host_uuid) REFERENCES mdm_apple_psso_devices (host_uuid) ON DELETE CASCADE ) `); err != nil { - return fmt.Errorf("creating mdm_apple_psso_key_ids table: %w", err) + return fmt.Errorf("creating mdm_apple_psso_keys table: %w", err) } return nil diff --git a/server/datastore/mysql/migrations/tables/20260611140639_CreateApplePSSOTables_test.go b/server/datastore/mysql/migrations/tables/20260611140639_CreateApplePSSOTables_test.go index fba885f33c1..101034d7ea5 100644 --- a/server/datastore/mysql/migrations/tables/20260611140639_CreateApplePSSOTables_test.go +++ b/server/datastore/mysql/migrations/tables/20260611140639_CreateApplePSSOTables_test.go @@ -10,130 +10,75 @@ import ( func TestUp_20260611140639(t *testing.T) { db := applyUpToPrev(t) - - hostInsert := `INSERT INTO hosts (hardware_serial, osquery_host_id, node_key, uuid, platform) VALUES (?, ?, ?, ?, ?)` - hostID1 := execNoErrLastID(t, db, hostInsert, "serial-1", "osq-1", "node-key-1", "uuid-1", "darwin") - hostID2 := execNoErrLastID(t, db, hostInsert, "serial-2", "osq-2", "node-key-2", "uuid-2", "darwin") - applyNext(t, db) - // Insert a device row with explicit values. - _, err := db.Exec(` - INSERT INTO mdm_apple_psso_devices - (host_id, device_uuid, signing_key_pem, encryption_key_pem, key_exchange_key) - VALUES (?, ?, ?, ?, ?) - `, hostID1, "ABCDEFGH-0000-0000-0000-111111111111", "signing-pem-1", "encryption-pem-1", []byte("0123456789abcdef0123456789abcdef")) - require.NoError(t, err) + const ( + hostUUID1 = "ABCDEFGH-0000-0000-0000-111111111111" + hostUUID2 = "ABCDEFGH-0000-0000-0000-222222222222" + ) + + // Register two devices. + execNoErr(t, db, `INSERT INTO mdm_apple_psso_devices (host_uuid) VALUES (?)`, hostUUID1) + execNoErr(t, db, `INSERT INTO mdm_apple_psso_devices (host_uuid) VALUES (?)`, hostUUID2) var ( - gotUUID string - gotSigningPEM string - gotEncryptPEM string - gotKEK []byte - gotCreatedAt time.Time - gotUpdatedAt time.Time + gotCreatedAt time.Time + gotUpdatedAt time.Time ) - err = db.QueryRow(` - SELECT device_uuid, signing_key_pem, encryption_key_pem, key_exchange_key, created_at, updated_at - FROM mdm_apple_psso_devices WHERE host_id = ? - `, hostID1).Scan(&gotUUID, &gotSigningPEM, &gotEncryptPEM, &gotKEK, &gotCreatedAt, &gotUpdatedAt) + err := db.QueryRow(` + SELECT created_at, updated_at FROM mdm_apple_psso_devices WHERE host_uuid = ? + `, hostUUID1).Scan(&gotCreatedAt, &gotUpdatedAt) require.NoError(t, err) - assert.Equal(t, "ABCDEFGH-0000-0000-0000-111111111111", gotUUID) - assert.Equal(t, "signing-pem-1", gotSigningPEM) - assert.Equal(t, "encryption-pem-1", gotEncryptPEM) - assert.Equal(t, []byte("0123456789abcdef0123456789abcdef"), gotKEK) assert.False(t, gotCreatedAt.IsZero()) assert.False(t, gotUpdatedAt.IsZero()) - // Duplicate host_id is rejected by the PK. - _, err = db.Exec(` - INSERT INTO mdm_apple_psso_devices - (host_id, device_uuid, signing_key_pem, encryption_key_pem, key_exchange_key) - VALUES (?, ?, ?, ?, ?) - `, hostID1, "different-uuid", "x", "y", []byte("kek")) - require.Error(t, err) - - // Duplicate device_uuid across hosts is rejected by the unique index. - _, err = db.Exec(` - INSERT INTO mdm_apple_psso_devices - (host_id, device_uuid, signing_key_pem, encryption_key_pem, key_exchange_key) - VALUES (?, ?, ?, ?, ?) - `, hostID2, "ABCDEFGH-0000-0000-0000-111111111111", "x", "y", []byte("kek")) - require.Error(t, err) - - // FK to hosts is enforced. - _, err = db.Exec(` - INSERT INTO mdm_apple_psso_devices - (host_id, device_uuid, signing_key_pem, encryption_key_pem, key_exchange_key) - VALUES (?, ?, ?, ?, ?) - `, 999999, "ghost-uuid", "x", "y", []byte("kek")) + // Duplicate host_uuid is rejected by the PK. + _, err = db.Exec(`INSERT INTO mdm_apple_psso_devices (host_uuid) VALUES (?)`, hostUUID1) require.Error(t, err) - // Second host can register independently. - _, err = db.Exec(` - INSERT INTO mdm_apple_psso_devices - (host_id, device_uuid, signing_key_pem, encryption_key_pem, key_exchange_key) - VALUES (?, ?, ?, ?, ?) - `, hostID2, "ABCDEFGH-0000-0000-0000-222222222222", "signing-pem-2", "encryption-pem-2", []byte("ffeeddccbbaa99887766554433221100")) - require.NoError(t, err) + keyInsert := `INSERT INTO mdm_apple_psso_keys (kid, host_uuid, key_type, pem) VALUES (?, ?, ?, ?)` - // Insert key_id rows for host1: one signing, one encryption. - _, err = db.Exec(` - INSERT INTO mdm_apple_psso_key_ids (kid, host_id, key_type, pem) - VALUES (?, ?, ?, ?) - `, "kid-sign-host1", hostID1, "signing", "signing-pem-1") - require.NoError(t, err) - _, err = db.Exec(` - INSERT INTO mdm_apple_psso_key_ids (kid, host_id, key_type, pem) - VALUES (?, ?, ?, ?) - `, "kid-enc-host1", hostID1, "encryption", "encryption-pem-1") - require.NoError(t, err) + // One signing and one encryption key for host1. + execNoErr(t, db, keyInsert, "kid-sign-host1", hostUUID1, "signing", "signing-pem-1") + execNoErr(t, db, keyInsert, "kid-enc-host1", hostUUID1, "encryption", "encryption-pem-1") - // Duplicate kid is rejected by PK. - _, err = db.Exec(` - INSERT INTO mdm_apple_psso_key_ids (kid, host_id, key_type, pem) - VALUES (?, ?, ?, ?) - `, "kid-sign-host1", hostID2, "signing", "x") - require.Error(t, err) + // Multiple keys of the same type per host are allowed (re-registration + // keeps old keys working). + execNoErr(t, db, keyInsert, "kid-sign-host1-v2", hostUUID1, "signing", "signing-pem-1-v2") + execNoErr(t, db, keyInsert, "kid-enc-host1-v2", hostUUID1, "encryption", "encryption-pem-1-v2") - // Duplicate (host_id, key_type) is rejected by unique index — a host has at most one signing and one encryption key. - _, err = db.Exec(` - INSERT INTO mdm_apple_psso_key_ids (kid, host_id, key_type, pem) - VALUES (?, ?, ?, ?) - `, "kid-sign-host1-v2", hostID1, "signing", "x") + // Duplicate kid is rejected by the PK. + _, err = db.Exec(keyInsert, "kid-sign-host1", hostUUID2, "signing", "x") require.Error(t, err) - // Invalid key_type is rejected by ENUM. - _, err = db.Exec(` - INSERT INTO mdm_apple_psso_key_ids (kid, host_id, key_type, pem) - VALUES (?, ?, ?, ?) - `, "kid-bogus", hostID1, "bogus", "x") + // Invalid key_type is rejected by the ENUM. + _, err = db.Exec(keyInsert, "kid-bogus", hostUUID1, "bogus", "x") require.Error(t, err) - // FK to hosts is enforced. - _, err = db.Exec(` - INSERT INTO mdm_apple_psso_key_ids (kid, host_id, key_type, pem) - VALUES (?, ?, ?, ?) - `, "kid-ghost", 999999, "signing", "x") + // Keys must reference a registered device. + _, err = db.Exec(keyInsert, "kid-ghost", "no-such-device-uuid", "signing", "x") require.Error(t, err) - // ON DELETE CASCADE: deleting host2 wipes its psso rows. - _, err = db.Exec(`DELETE FROM hosts WHERE id = ?`, hostID2) + // Key timestamps are populated. + err = db.QueryRow(` + SELECT created_at, updated_at FROM mdm_apple_psso_keys WHERE kid = ? + `, "kid-sign-host1").Scan(&gotCreatedAt, &gotUpdatedAt) require.NoError(t, err) + assert.False(t, gotCreatedAt.IsZero()) + assert.False(t, gotUpdatedAt.IsZero()) - var devicesRemaining int - err = db.QueryRow(`SELECT COUNT(*) FROM mdm_apple_psso_devices WHERE host_id = ?`, hostID2).Scan(&devicesRemaining) - require.NoError(t, err) - assert.Equal(t, 0, devicesRemaining) + // ON DELETE CASCADE: deleting a device wipes its keys. + execNoErr(t, db, keyInsert, "kid-sign-host2", hostUUID2, "signing", "signing-pem-2") + execNoErr(t, db, `DELETE FROM mdm_apple_psso_devices WHERE host_uuid = ?`, hostUUID2) - // host1's rows survive. - var host1Devices int - err = db.QueryRow(`SELECT COUNT(*) FROM mdm_apple_psso_devices WHERE host_id = ?`, hostID1).Scan(&host1Devices) + var keysRemaining int + err = db.QueryRow(`SELECT COUNT(*) FROM mdm_apple_psso_keys WHERE host_uuid = ?`, hostUUID2).Scan(&keysRemaining) require.NoError(t, err) - assert.Equal(t, 1, host1Devices) + assert.Equal(t, 0, keysRemaining) + // host1's rows survive. var host1Keys int - err = db.QueryRow(`SELECT COUNT(*) FROM mdm_apple_psso_key_ids WHERE host_id = ?`, hostID1).Scan(&host1Keys) + err = db.QueryRow(`SELECT COUNT(*) FROM mdm_apple_psso_keys WHERE host_uuid = ?`, hostUUID1).Scan(&host1Keys) require.NoError(t, err) - assert.Equal(t, 2, host1Keys) + assert.Equal(t, 4, host1Keys) } diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql index 9b379f593d6..01332af9c05 100644 --- a/server/datastore/mysql/schema.sql +++ b/server/datastore/mysql/schema.sql @@ -1826,29 +1826,24 @@ CREATE TABLE `mdm_apple_installers` ( /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `mdm_apple_psso_devices` ( - `host_id` int unsigned NOT NULL, - `device_uuid` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, - `signing_key_pem` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, - `encryption_key_pem` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, - `key_exchange_key` varbinary(64) NOT NULL, + `host_uuid` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, `created_at` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `updated_at` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), - PRIMARY KEY (`host_id`), - UNIQUE KEY `idx_mdm_apple_psso_devices_device_uuid` (`device_uuid`), - CONSTRAINT `fk_mdm_apple_psso_devices_host_id` FOREIGN KEY (`host_id`) REFERENCES `hosts` (`id`) ON DELETE CASCADE + PRIMARY KEY (`host_uuid`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; -CREATE TABLE `mdm_apple_psso_key_ids` ( +CREATE TABLE `mdm_apple_psso_keys` ( `kid` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, - `host_id` int unsigned NOT NULL, + `host_uuid` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, `key_type` enum('signing','encryption') CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, `pem` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, `created_at` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `updated_at` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), PRIMARY KEY (`kid`), - UNIQUE KEY `idx_mdm_apple_psso_key_ids_host_type` (`host_id`,`key_type`), - CONSTRAINT `fk_mdm_apple_psso_key_ids_host_id` FOREIGN KEY (`host_id`) REFERENCES `hosts` (`id`) ON DELETE CASCADE + KEY `fk_mdm_apple_psso_keys_host_uuid` (`host_uuid`), + CONSTRAINT `fk_mdm_apple_psso_keys_host_uuid` FOREIGN KEY (`host_uuid`) REFERENCES `mdm_apple_psso_devices` (`host_uuid`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; /*!40101 SET @saved_cs_client = @@character_set_client */; diff --git a/server/fleet/apple_psso.go b/server/fleet/apple_psso.go index aa04aecdd13..cd9678991ba 100644 --- a/server/fleet/apple_psso.go +++ b/server/fleet/apple_psso.go @@ -5,15 +5,15 @@ import ( "time" ) -// PSSODevice is a Mac host's Apple Platform SSO registration record. +// PSSODevice marks a Mac host as Apple Platform SSO-registered. It carries no +// key material itself — the device's public keys live in PSSOKey rows that +// cascade-delete with this record, so removing it completely clears a host's +// PSSO registration. HostUUID is the hardware UUID (matches hosts.uuid; no FK, +// like other host-adjacent tables). type PSSODevice struct { - HostID uint `db:"host_id"` - DeviceUUID string `db:"device_uuid"` - SigningKeyPEM string `db:"signing_key_pem"` - EncryptionKeyPEM string `db:"encryption_key_pem"` - KeyExchangeKey []byte `db:"key_exchange_key"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` + HostUUID string `db:"host_uuid"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` } // PSSOKeyType discriminates a device's signing key from its encryption key. @@ -24,15 +24,18 @@ const ( PSSOKeyTypeEncryption PSSOKeyType = "encryption" ) -// PSSOKeyID indexes a device key by its kid (base64 SHA-256 of the key) so the -// server can look up the owning device when an extension presents a JWT with -// that kid in its header. -type PSSOKeyID struct { +// PSSOKey is one of a registered device's public keys, indexed by kid (base64 +// SHA-256 of the key bytes) so the server can resolve the owning device when +// an extension presents a JWT with that kid in its header. A host may hold +// several keys of the same type: re-registration adds new keys without +// invalidating old ones. +type PSSOKey struct { KID string `db:"kid"` - HostID uint `db:"host_id"` + HostUUID string `db:"host_uuid"` KeyType PSSOKeyType `db:"key_type"` PEM string `db:"pem"` CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` } // PSSOClaims is the OIDC-shaped claim set the upstream IdP returns after a diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 84c0fe70c3e..875a7c7e55f 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -3438,15 +3438,25 @@ type Datastore interface { // Apple Platform SSO (PSSO) // SetOrUpdatePSSODevice persists a Mac's PSSO registration: the device row - // plus both KeyID rows (signing + encryption) in a single transaction. - // Replaces any existing registration for the same host. - SetOrUpdatePSSODevice(ctx context.Context, device PSSODevice, signKeyID PSSOKeyID, encKeyID PSSOKeyID) error - - // GetPSSODeviceByKeyID looks up the device that owns the given kid and - // returns both the device row and the specific KeyID row (so the caller - // knows which key — signing or encryption — was referenced). - GetPSSODeviceByKeyID(ctx context.Context, kid string) (*PSSODevice, *PSSOKeyID, error) - + // plus the given key rows in a single transaction. Keys are upserted by + // kid; existing keys for the host are left in place so they keep working + // after a re-registration. + SetOrUpdatePSSODevice(ctx context.Context, hostUUID string, keys []PSSOKey) error + + // GetPSSODevice returns the PSSO registration record for the given host + // UUID, or a notFound error if the host isn't registered. + GetPSSODevice(ctx context.Context, hostUUID string) (*PSSODevice, error) + + // GetPSSOKey looks up a registered device key by its kid. + GetPSSOKey(ctx context.Context, kid string) (*PSSOKey, error) + + // ListPSSOKeys returns all keys registered for the given host UUID. + ListPSSOKeys(ctx context.Context, hostUUID string) ([]*PSSOKey, error) + + // DeletePSSODevice removes a host's PSSO registration and, via cascade, + // all of its registered keys. Deleting an unregistered host is a no-op. + DeletePSSODevice(ctx context.Context, hostUUID string) error + // HasAppleUpdateConfigProfileConfigured checks if a declaration profile for the team already exists in the update_settings table. HasAppleUpdateConfigProfileConfigured(ctx context.Context, teamID uint) (bool, error) diff --git a/server/fleet/service.go b/server/fleet/service.go index a53cbdbeba1..b3562c21c4a 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -1556,8 +1556,8 @@ type Service interface { // validates the device-key payload, and persists the registration. PSSORegisterComplete(ctx context.Context, req PSSORegisterRequest) error // PSSOToken handles the per-sign-in protocol message: parses the inbound - // signed JWT, dispatches on RequestType (key_request / key_exchange / - // password_request), and returns the JWE response body. + // signed JWT, dispatches on grant_type (password login) or request_type + // (key_request / key_exchange), and returns the JWE response body. PSSOToken(ctx context.Context, jwtBytes []byte) ([]byte, error) // PSSOJWKS returns the JSON web key set that publishes Fleet's PSSO // signing public key. diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index 88b1dfbb7a9..986e2db74d6 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -2090,9 +2090,15 @@ type MDMAppleResetOnReenrollmentFunc func(ctx context.Context, hostUUID string, type VerifyAppleConfigProfileScopesDoNotConflictFunc func(ctx context.Context, cps []*fleet.MDMAppleConfigProfile) error -type SetOrUpdatePSSODeviceFunc func(ctx context.Context, device fleet.PSSODevice, signKeyID fleet.PSSOKeyID, encKeyID fleet.PSSOKeyID) error +type SetOrUpdatePSSODeviceFunc func(ctx context.Context, hostUUID string, keys []fleet.PSSOKey) error -type GetPSSODeviceByKeyIDFunc func(ctx context.Context, kid string) (*fleet.PSSODevice, *fleet.PSSOKeyID, error) +type GetPSSODeviceFunc func(ctx context.Context, hostUUID string) (*fleet.PSSODevice, error) + +type GetPSSOKeyFunc func(ctx context.Context, kid string) (*fleet.PSSOKey, error) + +type ListPSSOKeysFunc func(ctx context.Context, hostUUID string) ([]*fleet.PSSOKey, error) + +type DeletePSSODeviceFunc func(ctx context.Context, hostUUID string) error type HasAppleUpdateConfigProfileConfiguredFunc func(ctx context.Context, teamID uint) (bool, error) @@ -5205,8 +5211,17 @@ type DataStore struct { SetOrUpdatePSSODeviceFunc SetOrUpdatePSSODeviceFunc SetOrUpdatePSSODeviceFuncInvoked bool - GetPSSODeviceByKeyIDFunc GetPSSODeviceByKeyIDFunc - GetPSSODeviceByKeyIDFuncInvoked bool + GetPSSODeviceFunc GetPSSODeviceFunc + GetPSSODeviceFuncInvoked bool + + GetPSSOKeyFunc GetPSSOKeyFunc + GetPSSOKeyFuncInvoked bool + + ListPSSOKeysFunc ListPSSOKeysFunc + ListPSSOKeysFuncInvoked bool + + DeletePSSODeviceFunc DeletePSSODeviceFunc + DeletePSSODeviceFuncInvoked bool HasAppleUpdateConfigProfileConfiguredFunc HasAppleUpdateConfigProfileConfiguredFunc HasAppleUpdateConfigProfileConfiguredFuncInvoked bool @@ -12454,18 +12469,39 @@ func (s *DataStore) VerifyAppleConfigProfileScopesDoNotConflict(ctx context.Cont return s.VerifyAppleConfigProfileScopesDoNotConflictFunc(ctx, cps) } -func (s *DataStore) SetOrUpdatePSSODevice(ctx context.Context, device fleet.PSSODevice, signKeyID fleet.PSSOKeyID, encKeyID fleet.PSSOKeyID) error { +func (s *DataStore) SetOrUpdatePSSODevice(ctx context.Context, hostUUID string, keys []fleet.PSSOKey) error { s.mu.Lock() s.SetOrUpdatePSSODeviceFuncInvoked = true s.mu.Unlock() - return s.SetOrUpdatePSSODeviceFunc(ctx, device, signKeyID, encKeyID) + return s.SetOrUpdatePSSODeviceFunc(ctx, hostUUID, keys) +} + +func (s *DataStore) GetPSSODevice(ctx context.Context, hostUUID string) (*fleet.PSSODevice, error) { + s.mu.Lock() + s.GetPSSODeviceFuncInvoked = true + s.mu.Unlock() + return s.GetPSSODeviceFunc(ctx, hostUUID) +} + +func (s *DataStore) GetPSSOKey(ctx context.Context, kid string) (*fleet.PSSOKey, error) { + s.mu.Lock() + s.GetPSSOKeyFuncInvoked = true + s.mu.Unlock() + return s.GetPSSOKeyFunc(ctx, kid) +} + +func (s *DataStore) ListPSSOKeys(ctx context.Context, hostUUID string) ([]*fleet.PSSOKey, error) { + s.mu.Lock() + s.ListPSSOKeysFuncInvoked = true + s.mu.Unlock() + return s.ListPSSOKeysFunc(ctx, hostUUID) } -func (s *DataStore) GetPSSODeviceByKeyID(ctx context.Context, kid string) (*fleet.PSSODevice, *fleet.PSSOKeyID, error) { +func (s *DataStore) DeletePSSODevice(ctx context.Context, hostUUID string) error { s.mu.Lock() - s.GetPSSODeviceByKeyIDFuncInvoked = true + s.DeletePSSODeviceFuncInvoked = true s.mu.Unlock() - return s.GetPSSODeviceByKeyIDFunc(ctx, kid) + return s.DeletePSSODeviceFunc(ctx, hostUUID) } func (s *DataStore) HasAppleUpdateConfigProfileConfigured(ctx context.Context, teamID uint) (bool, error) { From 35524ad0f647b7d0acb6fa8a661fd50a41dd67fa Mon Sep 17 00:00:00 2001 From: Jordan Montgomery Date: Thu, 11 Jun 2026 11:53:07 -0400 Subject: [PATCH 02/28] Remove unneeded comment --- .../tables/20260611140639_CreateApplePSSOTables.go | 6 ------ 1 file changed, 6 deletions(-) diff --git a/server/datastore/mysql/migrations/tables/20260611140639_CreateApplePSSOTables.go b/server/datastore/mysql/migrations/tables/20260611140639_CreateApplePSSOTables.go index 20e3d3255fe..44887b05167 100644 --- a/server/datastore/mysql/migrations/tables/20260611140639_CreateApplePSSOTables.go +++ b/server/datastore/mysql/migrations/tables/20260611140639_CreateApplePSSOTables.go @@ -10,12 +10,6 @@ func init() { } func Up_20260611140639(tx *sql.Tx) error { - // Like most host-adjacent tables, these are keyed by host UUID with no FK - // to hosts (avoided for performance). A device row marks a host as - // PSSO-registered and is the cascade anchor for clearing all of a host's - // PSSO state; the public keys themselves live in mdm_apple_psso_keys, - // possibly several per host since old keys keep working after the device - // rotates or re-registers. if _, err := tx.Exec(` CREATE TABLE mdm_apple_psso_devices ( host_uuid VARCHAR(255) COLLATE utf8mb4_unicode_ci NOT NULL, From cf5aac9a7cb3d1a693aa398259c2f116fb60ada7 Mon Sep 17 00:00:00 2001 From: Jordan Montgomery Date: Thu, 11 Jun 2026 16:14:56 -0400 Subject: [PATCH 03/28] Cleanup comments, fix clock skew bug --- ee/server/service/apple_psso_crypto.go | 24 +++++++++++++++ ee/server/service/apple_psso_crypto_test.go | 34 +++++++++++++++++++++ server/fleet/apple_psso.go | 5 +-- 3 files changed, 59 insertions(+), 4 deletions(-) diff --git a/ee/server/service/apple_psso_crypto.go b/ee/server/service/apple_psso_crypto.go index 1b13a4a2c85..65512f02532 100644 --- a/ee/server/service/apple_psso_crypto.go +++ b/ee/server/service/apple_psso_crypto.go @@ -29,6 +29,7 @@ import ( "errors" "fmt" "strings" + "time" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/fleet" @@ -69,6 +70,29 @@ type pssoTokenClaims struct { KeyContext string `json:"key_context,omitempty"` // server-sealed provisioned key, echoed back } +// pssoJWTLeeway is the clock-skew tolerance applied to inbound JWT time +// claims. The default RegisteredClaims validation allows zero skew, so a Mac +// whose clock runs even a second ahead of the server gets "token used before +// issued" on every login. +const pssoJWTLeeway = time.Minute + +// Valid overrides the embedded RegisteredClaims validation to apply +// pssoJWTLeeway to exp, iat, and nbf. jwt/v4 has no parser-level leeway +// option (that arrived in v5), so the claims type does it. +func (c *pssoTokenClaims) Valid() error { + now := time.Now() + if !c.VerifyExpiresAt(now.Add(-pssoJWTLeeway), false) { + return jwt.ErrTokenExpired + } + if !c.VerifyIssuedAt(now.Add(pssoJWTLeeway), false) { + return jwt.ErrTokenUsedBeforeIssued + } + if !c.VerifyNotBefore(now.Add(pssoJWTLeeway), false) { + return jwt.ErrTokenNotValidYet + } + return nil +} + // pssoJWECrypto is the jwe_crypto claim the extension sends to tell Fleet how // to encrypt the login response: ECDH-ES key agreement to the device // encryption key with A256GCM content encryption, binding the agreed key to diff --git a/ee/server/service/apple_psso_crypto_test.go b/ee/server/service/apple_psso_crypto_test.go index 3b99736948c..de55c052868 100644 --- a/ee/server/service/apple_psso_crypto_test.go +++ b/ee/server/service/apple_psso_crypto_test.go @@ -12,10 +12,12 @@ import ( "io" "log/slog" "testing" + "time" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/mock" jose "github.com/go-jose/go-jose/v3" + jwt "github.com/golang-jwt/jwt/v4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -233,6 +235,38 @@ func TestPSSO_KeyExchangeSharedSecretMatches(t *testing.T) { assert.Equal(t, deviceShared, serverShared) } +// TestPSSO_TokenClaimsLeeway confirms inbound JWT time claims tolerate small +// clock skew between the Mac and the server: an iat slightly in the future +// (Mac clock ahead) or an exp slightly in the past must not fail validation, +// while skew beyond the leeway still does. +func TestPSSO_TokenClaimsLeeway(t *testing.T) { + now := time.Now() + claimsAt := func(iat, exp time.Time) *pssoTokenClaims { + return &pssoTokenClaims{RegisteredClaims: jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(iat), + ExpiresAt: jwt.NewNumericDate(exp), + }} + } + + // In sync: valid. + require.NoError(t, claimsAt(now, now.Add(5*time.Minute)).Valid()) + + // Mac clock slightly ahead: iat in the (server's) future, within leeway. + require.NoError(t, claimsAt(now.Add(30*time.Second), now.Add(5*time.Minute)).Valid()) + + // exp just passed, within leeway. + require.NoError(t, claimsAt(now.Add(-5*time.Minute), now.Add(-30*time.Second)).Valid()) + + // Beyond leeway both ways. + err := claimsAt(now.Add(pssoJWTLeeway+time.Minute), now.Add(10*time.Minute)).Valid() + require.ErrorIs(t, err, jwt.ErrTokenUsedBeforeIssued) + err = claimsAt(now.Add(-10*time.Minute), now.Add(-pssoJWTLeeway-time.Minute)).Valid() + require.ErrorIs(t, err, jwt.ErrTokenExpired) + + // Absent time claims are not required (registration-era JWTs). + require.NoError(t, (&pssoTokenClaims{}).Valid()) +} + // TestPSSO_CanonicalizeKID confirms the padded base64 kid Apple's framework // sends in the JWT header and the unpadded base64url kid the extension // registers collapse to the same value, so device lookup by kid succeeds. diff --git a/server/fleet/apple_psso.go b/server/fleet/apple_psso.go index cd9678991ba..e87f2c2f684 100644 --- a/server/fleet/apple_psso.go +++ b/server/fleet/apple_psso.go @@ -6,10 +6,7 @@ import ( ) // PSSODevice marks a Mac host as Apple Platform SSO-registered. It carries no -// key material itself — the device's public keys live in PSSOKey rows that -// cascade-delete with this record, so removing it completely clears a host's -// PSSO registration. HostUUID is the hardware UUID (matches hosts.uuid; no FK, -// like other host-adjacent tables). +// key material itself. The device's public keys live in PSSOKey rows type PSSODevice struct { HostUUID string `db:"host_uuid"` CreatedAt time.Time `db:"created_at"` From 98060b3eecce1a86890010bc265958398d9bd54f Mon Sep 17 00:00:00 2001 From: Jordan Montgomery Date: Mon, 15 Jun 2026 13:41:30 -0400 Subject: [PATCH 04/28] Initial work to refactor endpoints/handlers --- .../Fleet PSSO.xcodeproj/project.pbxproj | 8 +- ...henticationViewController+Networking.swift | 58 ++++ .../AuthenticationViewController+Shared.swift | 45 ++- ...AuthenticationViewController+WebView.swift | 88 ----- .../AuthenticationViewController.swift | 16 +- apple-sso-extension/README.md | 30 +- .../fleet-sso-extension-example.mobileconfig | 15 +- cmd/fleet/serve.go | 16 +- docs/Contributing/research/mdm/psso.md | 47 ++- ee/server/service/apple_psso.go | 227 +++++++------ ee/server/service/apple_psso_crypto.go | 18 +- ee/server/service/apple_psso_idp_oidc_ropg.go | 25 +- ee/server/service/apple_psso_idp_stub.go | 28 -- ee/server/service/apple_psso_test.go | 114 +++++++ ee/server/service/mdm_external_test.go | 1 + ee/server/service/service.go | 12 +- server/fleet/apple_psso.go | 48 +-- server/fleet/mdm.go | 2 +- server/fleet/service.go | 13 +- server/mock/service/service_mock.go | 24 +- server/service/apple_psso.go | 300 ++++++++++-------- server/service/apple_psso_test.go | 60 ++++ server/service/handler.go | 27 +- server/service/svctest/service.go | 1 + server/service/testing_utils_test.go | 1 + 25 files changed, 671 insertions(+), 553 deletions(-) create mode 100644 apple-sso-extension/FleetPSSOExtension/AuthenticationViewController+Networking.swift delete mode 100644 apple-sso-extension/FleetPSSOExtension/AuthenticationViewController+WebView.swift delete mode 100644 ee/server/service/apple_psso_idp_stub.go create mode 100644 ee/server/service/apple_psso_test.go create mode 100644 server/service/apple_psso_test.go diff --git a/apple-sso-extension/Fleet PSSO.xcodeproj/project.pbxproj b/apple-sso-extension/Fleet PSSO.xcodeproj/project.pbxproj index b34b9d42163..1ef75ca05d8 100644 --- a/apple-sso-extension/Fleet PSSO.xcodeproj/project.pbxproj +++ b/apple-sso-extension/Fleet PSSO.xcodeproj/project.pbxproj @@ -12,7 +12,7 @@ A1000001000000000000A003 /* AuthenticationViewController+PSSO.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1000001000000000000B003 /* AuthenticationViewController+PSSO.swift */; }; A1000001000000000000A004 /* AuthenticationViewController+Shared.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1000001000000000000B004 /* AuthenticationViewController+Shared.swift */; }; A1000001000000000000A005 /* FleetPSSOExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = C1000001000000000000C002 /* FleetPSSOExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; - A1000001000000000000A006 /* AuthenticationViewController+WebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1000001000000000000B009 /* AuthenticationViewController+WebView.swift */; }; + A1000001000000000000A006 /* AuthenticationViewController+Networking.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1000001000000000000B009 /* AuthenticationViewController+Networking.swift */; }; A1000001000000000000A007 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B1000001000000000000B010 /* Assets.xcassets */; }; A1000001000000000000A008 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B1000001000000000000B010 /* Assets.xcassets */; }; /* End PBXBuildFile section */ @@ -50,7 +50,7 @@ B1000001000000000000B006 /* FleetPSSO.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = FleetPSSO.entitlements; sourceTree = ""; }; B1000001000000000000B007 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; B1000001000000000000B008 /* FleetPSSOExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = FleetPSSOExtension.entitlements; sourceTree = ""; }; - B1000001000000000000B009 /* AuthenticationViewController+WebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AuthenticationViewController+WebView.swift"; sourceTree = ""; }; + B1000001000000000000B009 /* AuthenticationViewController+Networking.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AuthenticationViewController+Networking.swift"; sourceTree = ""; }; C1000001000000000000C001 /* FleetPSSO.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = FleetPSSO.app; sourceTree = BUILT_PRODUCTS_DIR; }; C1000001000000000000C002 /* FleetPSSOExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = FleetPSSOExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; B1000001000000000000B010 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -119,7 +119,7 @@ B1000001000000000000B002 /* AuthenticationViewController.swift */, B1000001000000000000B003 /* AuthenticationViewController+PSSO.swift */, B1000001000000000000B004 /* AuthenticationViewController+Shared.swift */, - B1000001000000000000B009 /* AuthenticationViewController+WebView.swift */, + B1000001000000000000B009 /* AuthenticationViewController+Networking.swift */, B1000001000000000000B007 /* Info.plist */, B1000001000000000000B008 /* FleetPSSOExtension.entitlements */, ); @@ -227,7 +227,7 @@ A1000001000000000000A002 /* AuthenticationViewController.swift in Sources */, A1000001000000000000A003 /* AuthenticationViewController+PSSO.swift in Sources */, A1000001000000000000A004 /* AuthenticationViewController+Shared.swift in Sources */, - A1000001000000000000A006 /* AuthenticationViewController+WebView.swift in Sources */, + A1000001000000000000A006 /* AuthenticationViewController+Networking.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/apple-sso-extension/FleetPSSOExtension/AuthenticationViewController+Networking.swift b/apple-sso-extension/FleetPSSOExtension/AuthenticationViewController+Networking.swift new file mode 100644 index 00000000000..733b052fd4a --- /dev/null +++ b/apple-sso-extension/FleetPSSOExtension/AuthenticationViewController+Networking.swift @@ -0,0 +1,58 @@ +// AuthenticationViewController+Networking.swift +// FleetPSSOExtension +// +// Direct URLSession networking against the Fleet server. Device registration +// must POST directly (no web view): Password-mode registration has no browser +// auth step, and the prior(to macOS 26) pattern of using a WKWebView isn't +// functional during Setup Assistant (EnableRegistrationDuringSetup) — this was +// found to silently skip registration, so the later token request presents an +// unregistered key. +// +// TODO: If we ever want to add support for a browser-based registration flow(e.g. +// in lieu of, or when the registration token is bad) we may need to figure out how +// to support a web view + +import Foundation + +extension AuthenticationViewController { + + // postDeviceRegistration POSTs the registration payload to Fleet and + // returns true on a 2xx response. + func postDeviceRegistration(payload: [String: String]) async -> Bool { + guard let endpoint = registrationEndpointURL else { return false } + var req = URLRequest(url: endpoint) + req.httpMethod = "POST" + req.setValue("application/x-www-form-urlencoded", + forHTTPHeaderField: "Content-Type") + let items = payload.map { URLQueryItem(name: $0.key, value: $0.value) } + req.httpBody = formURLEncodedBody(items) + guard let (_, resp) = try? await URLSession.shared.data(for: req), + let http = resp as? HTTPURLResponse else { + return false + } + return (200...299).contains(http.statusCode) + } + + // formURLEncodedBody serializes query items as an x-www-form-urlencoded + // body, percent-encoding everything outside the RFC 3986 unreserved set so + // '+', '/', '=', spaces and newlines in PEM values survive intact. + private func formURLEncodedBody(_ items: [URLQueryItem]) -> Data { + var allowed = CharacterSet.alphanumerics + allowed.insert(charactersIn: "-._~") + let pairs = items.map { item -> String in + let name = item.name.addingPercentEncoding(withAllowedCharacters: allowed) ?? item.name + let value = (item.value ?? "").addingPercentEncoding(withAllowedCharacters: allowed) ?? "" + return "\(name)=\(value)" + } + return Data(pairs.joined(separator: "&").utf8) + } +} + +extension Data { + func base64URLEncodedString() -> String { + base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} diff --git a/apple-sso-extension/FleetPSSOExtension/AuthenticationViewController+Shared.swift b/apple-sso-extension/FleetPSSOExtension/AuthenticationViewController+Shared.swift index 497dce7e8ac..78d3521ad4f 100644 --- a/apple-sso-extension/FleetPSSOExtension/AuthenticationViewController+Shared.swift +++ b/apple-sso-extension/FleetPSSOExtension/AuthenticationViewController+Shared.swift @@ -17,11 +17,11 @@ extension AuthenticationViewController { func registrationPayload(signing: SecKey, encryption: SecKey) -> [String: String] { [ - "deviceUUID": deviceUUID(), - "signPubKey": pemRepresentation(of: signing), - "encPubKey": pemRepresentation(of: encryption), - "signKeyID": keyID(signing), - "encKeyID": keyID(encryption), + "device_uuid": deviceUUID(), + "device_signing_key": pemRepresentation(of: signing), + "device_encryption_key": pemRepresentation(of: encryption), + "signing_key_id": keyID(signing), + "encryption_key_id": keyID(encryption), ] } @@ -55,35 +55,30 @@ extension AuthenticationViewController { return uuid } + // applyLoginConfiguration derives every endpoint from the single BaseURL + // key in the profile's ExtensionData — the Fleet server URL, e.g. + // https://fleet.example.com. The issuer/audience is its bare hostname, + // matching the `iss` claim Fleet mints into login-response id_tokens. func applyLoginConfiguration( _ mgr: ASAuthorizationProviderExtensionLoginManager ) throws { let data = mgr.extensionData - guard let issuer = data["IssuerHostname"] as? String, - let token = (data["TokenEndpoint"] as? String).flatMap(URL.init(string:)), - let jwks = (data["JwksEndpoint"] as? String).flatMap(URL.init(string:)), - let nonce = (data["NonceEndpoint"] as? String).flatMap(URL.init(string:)), - let reg = (data["RegistrationEndpoint"] as? String).flatMap(URL.init(string:)) + guard let baseString = data["BaseURL"] as? String, + let base = URL(string: baseString), + let host = base.host else { throw NSError(domain: "FleetPSSO", code: -1) } let cfg = ASAuthorizationProviderExtensionLoginConfiguration( clientID: Bundle.main.bundleIdentifier ?? "", - issuer: issuer, - tokenEndpointURL: token, - jwksEndpointURL: jwks, - audience: issuer) - cfg.nonceEndpointURL = nonce - self.registrationEndpointURL = reg + issuer: host, + tokenEndpointURL: pssoEndpointURL(base, "token"), + jwksEndpointURL: pssoEndpointURL(base, "jwks"), + audience: host) + cfg.nonceEndpointURL = pssoEndpointURL(base, "nonce") + self.registrationEndpointURL = pssoEndpointURL(base, "registration") try mgr.saveLoginConfiguration(cfg) } - func registrationStartURL( - _ mgr: ASAuthorizationProviderExtensionLoginManager, - payload: [String: String] - ) -> URL? { - guard let base = registrationEndpointURL, - var comps = URLComponents(url: base, resolvingAgainstBaseURL: false) - else { return nil } - comps.queryItems = payload.map { URLQueryItem(name: $0.key, value: $0.value) } - return comps.url + private func pssoEndpointURL(_ base: URL, _ name: String) -> URL { + base.appendingPathComponent("api/mdm/apple/psso/\(name)") } } diff --git a/apple-sso-extension/FleetPSSOExtension/AuthenticationViewController+WebView.swift b/apple-sso-extension/FleetPSSOExtension/AuthenticationViewController+WebView.swift deleted file mode 100644 index fb4e6cf91be..00000000000 --- a/apple-sso-extension/FleetPSSOExtension/AuthenticationViewController+WebView.swift +++ /dev/null @@ -1,88 +0,0 @@ -// AuthenticationViewController+WebView.swift -// FleetPSSOExtension -// -// WKNavigationDelegate conformance. Intercepts the registration redirect -// from the IdP's web flow, harvests query parameters (`code`, `state`), -// and POSTs them back to Fleet's registration endpoint along with cookies -// forwarded from the WKWebView's cookie jar. - -import Foundation -import WebKit - -extension AuthenticationViewController: WKNavigationDelegate { - - func webView(_ webView: WKWebView, - decidePolicyFor navigationAction: WKNavigationAction, - decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { - guard let url = navigationAction.request.url, - let registration = registrationEndpointURL, - url.absoluteString.hasPrefix(registration.absoluteString) else { - decisionHandler(.allow); return - } - decisionHandler(.cancel) - Task { await self.postRegistration(redirectURL: url) } - } - - func postRegistration(redirectURL: URL) async { - guard let endpoint = registrationEndpointURL else { return } - let comps = URLComponents(url: redirectURL, resolvingAgainstBaseURL: false) - let cookies = await webView.configuration.websiteDataStore.httpCookieStore.allCookies() - var req = URLRequest(url: endpoint) - req.httpMethod = "POST" - // Re-encode from decoded query items rather than reusing - // percentEncodedQuery: URLQueryItem leaves '+' literal (valid in a - // query string), but in an x-www-form-urlencoded body '+' decodes to a - // space and would corrupt the base64 PEM keys. formURLEncodedBody - // escapes '+' as %2B. - req.httpBody = formURLEncodedBody(comps?.queryItems ?? []) - req.setValue("application/x-www-form-urlencoded", - forHTTPHeaderField: "Content-Type") - req.setValue(HTTPCookie.requestHeaderFields(with: cookies)["Cookie"], - forHTTPHeaderField: "Cookie") - _ = try? await URLSession.shared.data(for: req) - } - - // postDeviceRegistration POSTs the registration payload directly to Fleet, - // without a WKWebView round-trip. Password-mode registration has no browser - // auth step, and the web view isn't functional during Setup Assistant - // (EnableRegistrationDuringSetup) — relying on it there silently skips - // registration, so the later token request presents an unregistered key. - // Returns true on a 2xx response. - func postDeviceRegistration(payload: [String: String]) async -> Bool { - guard let endpoint = registrationEndpointURL else { return false } - var req = URLRequest(url: endpoint) - req.httpMethod = "POST" - req.setValue("application/x-www-form-urlencoded", - forHTTPHeaderField: "Content-Type") - let items = payload.map { URLQueryItem(name: $0.key, value: $0.value) } - req.httpBody = formURLEncodedBody(items) - guard let (_, resp) = try? await URLSession.shared.data(for: req), - let http = resp as? HTTPURLResponse else { - return false - } - return (200...299).contains(http.statusCode) - } - - // formURLEncodedBody serializes query items as an x-www-form-urlencoded - // body, percent-encoding everything outside the RFC 3986 unreserved set so - // '+', '/', '=', spaces and newlines in PEM values survive intact. - private func formURLEncodedBody(_ items: [URLQueryItem]) -> Data { - var allowed = CharacterSet.alphanumerics - allowed.insert(charactersIn: "-._~") - let pairs = items.map { item -> String in - let name = item.name.addingPercentEncoding(withAllowedCharacters: allowed) ?? item.name - let value = (item.value ?? "").addingPercentEncoding(withAllowedCharacters: allowed) ?? "" - return "\(name)=\(value)" - } - return Data(pairs.joined(separator: "&").utf8) - } -} - -extension Data { - func base64URLEncodedString() -> String { - base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - } -} diff --git a/apple-sso-extension/FleetPSSOExtension/AuthenticationViewController.swift b/apple-sso-extension/FleetPSSOExtension/AuthenticationViewController.swift index 47859bbd36b..c9826649e90 100644 --- a/apple-sso-extension/FleetPSSOExtension/AuthenticationViewController.swift +++ b/apple-sso-extension/FleetPSSOExtension/AuthenticationViewController.swift @@ -2,29 +2,23 @@ // FleetPSSOExtension // // Principal class for Fleet's Platform SSO v2 extension. Hosts the -// ASAuthorizationProviderExtensionLoginManager and a WKWebView used for -// the browser-redirect leg of device registration. Conforms minimally -// to ASAuthorizationProviderExtensionAuthorizationRequestHandler so the -// extension binary loads; full sign-in flows are out of scope for the POC. +// ASAuthorizationProviderExtensionLoginManager. Conforms minimally to +// ASAuthorizationProviderExtensionAuthorizationRequestHandler so the +// extension binary loads; Password-mode registration and sign-in have no +// browser leg, so no web view is needed. import AuthenticationServices import Cocoa -import WebKit final class AuthenticationViewController: NSViewController, ASAuthorizationProviderExtensionAuthorizationRequestHandler { var loginManager: ASAuthorizationProviderExtensionLoginManager? - var webView: WKWebView! var pendingRequest: ASAuthorizationProviderExtensionAuthorizationRequest? var registrationEndpointURL: URL? override func loadView() { - let frame = NSRect(x: 0, y: 0, width: 640, height: 720) - let config = WKWebViewConfiguration() - webView = WKWebView(frame: frame, configuration: config) - webView.navigationDelegate = self - view = webView + view = NSView(frame: NSRect(x: 0, y: 0, width: 640, height: 720)) } func beginAuthorization( diff --git a/apple-sso-extension/README.md b/apple-sso-extension/README.md index 6361b5f9f9a..779df694676 100644 --- a/apple-sso-extension/README.md +++ b/apple-sso-extension/README.md @@ -24,6 +24,7 @@ apple-sso-extension/ │ ├── AuthenticationViewController.swift │ ├── AuthenticationViewController+PSSO.swift │ ├── AuthenticationViewController+Shared.swift +│ ├── AuthenticationViewController+Networking.swift │ ├── Info.plist │ └── FleetPSSOExtension.entitlements └── README.md @@ -35,29 +36,28 @@ launching it once is enough for macOS to discover the bundled ## How it fits with Fleet's server -The Fleet Go server exposes (paths are illustrative — confirm against -the actual handler registrations): +The Fleet server exposes the Platform SSO endpoints under +`/api/mdm/apple/psso/`: -- `IssuerHostname` — issuer string returned in JWTs -- `NonceEndpoint` — single-use nonces for PSSO requests -- `JwksEndpoint` — IdP public keys -- `TokenEndpoint` — password-grant token exchange -- `RegistrationEndpoint` — device registration callback +- `POST /api/mdm/apple/psso/nonce` — single-use nonces for token requests +- `POST /api/mdm/apple/psso/registration` — device key registration +- `POST /api/mdm/apple/psso/token` — password login / key request / key exchange +- `GET /api/mdm/apple/psso/jwks` — Fleet's PSSO signing public key -The extension picks these up from `loginManager.extensionData`, i.e. -the *second* arbitrary dictionary in the extensible-SSO profile. +The extension derives all of them from the single `BaseURL` value in +`loginManager.extensionData`, i.e. the arbitrary dictionary in the +extensible-SSO profile. The issuer/audience is the BaseURL's bare +hostname. ## Configuration profile Install a `com.apple.extensiblesso` profile referencing the extension -bundle ID and Team ID, with an `ExtensionData` dict that includes: +bundle ID and Team ID, with an `ExtensionData` dict that includes only +the Fleet server URL (see `fleet-sso-extension-example.mobileconfig` +for a complete profile): ```xml -IssuerHostname fleet.example.com -NonceEndpoint https://fleet.example.com/api/v1/fleet/psso/nonce -JwksEndpoint https://fleet.example.com/api/v1/fleet/psso/jwks -TokenEndpoint https://fleet.example.com/api/v1/fleet/psso/token -RegistrationEndpointhttps://fleet.example.com/api/v1/fleet/psso/register +BaseURL https://fleet.example.com ``` The hostname must also be served as an Apple App Site Association diff --git a/apple-sso-extension/fleet-sso-extension-example.mobileconfig b/apple-sso-extension/fleet-sso-extension-example.mobileconfig index f5c2eb26d41..dd2d20b5dc2 100644 --- a/apple-sso-extension/fleet-sso-extension-example.mobileconfig +++ b/apple-sso-extension/fleet-sso-extension-example.mobileconfig @@ -7,11 +7,10 @@ ExtensionData - IssuerHostname jordan-fleetdm.ngrok.app - NonceEndpoint https://jordan-fleetdm.ngrok.app/mdm/apple/psso/nonce - JwksEndpoint https://jordan-fleetdm.ngrok.app/.well-known/jwks.json - TokenEndpoint https://jordan-fleetdm.ngrok.app/mdm/apple/psso/token - RegistrationEndpointhttps://jordan-fleetdm.ngrok.app/mdm/apple/psso/register + + BaseURL + https://fleet.example.com ExtensionIdentifier com.fleetdm.pssotesting.extension @@ -42,14 +41,14 @@ Redirect URLs - https://jordan-fleetdm.ngrok.app + https://fleet.example.com PayloadDisplayName - PlatformSSO Fleet + Fleet Platform SSO PayloadIdentifier - com.fleetdm.platformsso.fleet.a72B07D0-2E08-45CE-9423-1FCAFFAEC390 + com.fleetdm.platformsso.fleet.A72B07D0-2E08-45CE-9423-1FCAFFAEC390 PayloadType Configuration PayloadUUID diff --git a/cmd/fleet/serve.go b/cmd/fleet/serve.go index 91183684a9e..6636b516714 100644 --- a/cmd/fleet/serve.go +++ b/cmd/fleet/serve.go @@ -598,25 +598,11 @@ func runServeCmd(cmd *cobra.Command, configManager configpkg.Manager, debug, dev digiCertService, androidSvc, hydrantService, + psso.NewRedisNonceStore(redisPool), ) if err != nil { initFatal(err, "initial Fleet Premium service") } - // PSSO POC wiring: attach the Redis-backed nonce store and the OIDC - // ROPG IdP client to the ee service. These are wired via setters so - // the eeservice.NewService signature doesn't churn for an - // optional feature. - if eesvc, ok := svc.(*eeservice.Service); ok { - eesvc.SetPSSONonceStore(psso.NewRedisNonceStore(redisPool)) - if cfg := appCfg.PSSOSettings; cfg != nil { - eesvc.SetPSSOIdPClient(eeservice.PSSOOIDCROPGClient{ - TokenURL: cfg.IdPTokenURL, - ClientID: cfg.IdPClientID, - ClientSecret: cfg.IdPClientSecret, - Scopes: cfg.IdPScopes, - }) - } - } } instanceID, err := server.GenerateRandomText(64) diff --git a/docs/Contributing/research/mdm/psso.md b/docs/Contributing/research/mdm/psso.md index 053622d144a..72c9acfcb48 100644 --- a/docs/Contributing/research/mdm/psso.md +++ b/docs/Contributing/research/mdm/psso.md @@ -38,8 +38,8 @@ sequenceDiagram rect rgb(235, 244, 255) Note over User,IdP: Phase 1 — Device registration (once; after enrollment or during Setup Assistant) macOS->>Ext: beginDeviceRegistration (provides Secure Enclave signing + encryption keys) - Ext->>Ext: Build payload (deviceUUID, signPubKey, encPubKey, signKeyID, encKeyID) - Ext->>Fleet: POST /mdm/apple/psso/register (direct URLSession) + Ext->>Ext: Build payload (device_uuid, device_signing_key, device_encryption_key, signing_key_id, encryption_key_id) + Ext->>Fleet: POST /api/mdm/apple/psso/registration (direct URLSession) Fleet->>Fleet: Resolve host by UUID; store device + key IDs Fleet-->>Ext: 200 OK Ext-->>macOS: completion(.success) @@ -47,12 +47,12 @@ sequenceDiagram rect rgb(235, 255, 240) Note over User,IdP: Phase 2 — Provision the offline unlock key (PSSO 2.0) - macOS->>Fleet: POST /mdm/apple/psso/nonce + macOS->>Fleet: POST /api/mdm/apple/psso/nonce Fleet-->>macOS: nonce - macOS->>Fleet: POST /mdm/apple/psso/token (request_type=key_request, signed JWT) + macOS->>Fleet: POST /api/mdm/apple/psso/token (request_type=key_request, signed JWT) Fleet->>Fleet: Generate provisioned EC keypair; seal private key into key_context Fleet-->>macOS: JWE { certificate (provisioned pubkey), key_context } - macOS->>Fleet: POST /mdm/apple/psso/token (request_type=key_exchange, other_publickey + key_context) + macOS->>Fleet: POST /api/mdm/apple/psso/token (request_type=key_exchange, other_publickey + key_context) Fleet->>Fleet: Recover provisioned private key; key = ECDH(private key, other_publickey) Fleet-->>macOS: JWE { key } establishes the unlock key end @@ -60,16 +60,16 @@ sequenceDiagram rect rgb(255, 247, 235) Note over User,IdP: Phase 3 — Password sign-in and sync (every login / unlock) User->>macOS: Enter IdP password - macOS->>Fleet: POST /mdm/apple/psso/nonce + macOS->>Fleet: POST /api/mdm/apple/psso/nonce Fleet-->>macOS: nonce - macOS->>Fleet: POST /mdm/apple/psso/token (grant_type=password)
signed JWT: plaintext password + jwe_crypto recipe + nonce + macOS->>Fleet: POST /api/mdm/apple/psso/token (grant_type=password)
signed JWT: plaintext password + jwe_crypto recipe + nonce Fleet->>Fleet: Verify JWT signature by kid -> device signing key Fleet->>IdP: ROPG grant_type=password (username, password) alt password valid IdP-->>Fleet: id_token, refresh_token, expires_in Fleet->>Fleet: Mint Fleet id_token (ES256); wrap as OAuth JSON;
JWE-encrypt to device encryption key (apu/apv) Fleet-->>macOS: platformsso-login-response+jwt (JWE) - macOS->>Fleet: GET /.well-known/jwks.json + macOS->>Fleet: GET /api/mdm/apple/psso/jwks Fleet-->>macOS: JWKS (Fleet signing key) macOS->>macOS: Decrypt JWE; verify id_token (sig, nonce, iss, aud, exp) macOS->>macOS: Sync local account password to the entered password; start SSO session @@ -98,11 +98,11 @@ Additional backends (LDAP bind, direct-trust flows for IdPs that reject ROPG) sl ### Enterprise-gated with no-license core stubs -Route registration for `/mdm/apple/psso/*` and the related `.well-known` endpoints lives in `server/service/handler.go` (core). The real implementation lives in `ee/server/service/`; the core build provides stubs that return `fleet.ErrMissingLicense`. This matches the pattern already used by `calendar.go` and the enterprise pieces of `apple_mdm.go`. +Route registration for `/api/mdm/apple/psso/*` and the AASA document lives in `server/service/handler.go` (core). The real implementation lives in `ee/server/service/`; the core build provides stubs that return `fleet.ErrMissingLicense`. This matches the pattern already used by `calendar.go` and the enterprise pieces of `apple_mdm.go`. -### Endpoint paths follow the SCEP/MDM convention +### Endpoint paths live under /api/mdm/apple/psso -The PSSO endpoints sit at the root of the URL space — `/mdm/apple/psso/nonce`, `/mdm/apple/psso/register`, `/mdm/apple/psso/token` — with no `/api/` or `/v1/` prefix, matching the existing `/mdm/apple/scep` and `/mdm/apple/mdm` paths Apple devices already talk to. The associated `/.well-known/jwks.json` and `/.well-known/apple-app-site-association` are also served at root, because Apple's frameworks fetch them by spec-defined absolute paths. +The device-facing PSSO endpoints — `/api/mdm/apple/psso/nonce`, `/api/mdm/apple/psso/registration`, `/api/mdm/apple/psso/token`, and `/api/mdm/apple/psso/jwks` — follow the unversioned device-protocol convention of `/api/mdm/apple/enroll` and are registered on the unauthenticated endpointer (which also caps request body sizes). The JWKS deliberately does not live at `/.well-known/jwks.json`: Apple's framework takes the JWKS URL from the extension's login configuration, so a PSSO-specific path avoids advertising (or colliding with) a server-wide JWKS. Only `/.well-known/apple-app-site-association` remains at root, because Apple's CDN fetches it at a spec-defined absolute path; it stays a raw handler on the root `*http.ServeMux`. (The POC originally served everything at root, SCEP-style — `/mdm/apple/psso/*` — this was revised in #46942.) ### Nonces in Redis, not MySQL @@ -115,17 +115,13 @@ The nonce store mirrors `server/mdm/acme/internal/redis_nonces_store/` and expos ### JWKS signing key bootstrap timing (OPEN) -Current placeholder behavior: the JWKS signing key is lazily minted on the first `GET /.well-known/jwks.json` and persisted (encrypted) in `mdm_config_assets` under `MDMAssetPSSOSigningKey`. Alternatives under consideration include minting on `AppConfig.PSSOSettings.Enabled = true`, minting on first device registration, or requiring an explicit `fleetctl psso bootstrap`. The lazy-mint code carries a `TODO`; decision pending. - -### No Fleet-side profile generation for the POC - -A sample `.mobileconfig` template is shipped at `tools/psso/sample.mobileconfig`. Admins fill in placeholders (Fleet base URL, IdP tenant, extension bundle ID) and upload via Fleet's existing custom-profile delivery. Server-side profile templating (analogous to `ensureFleetProfiles`) is out of scope for the POC. Plenty of customers would not want this functionality, either preferring their own existing PSSO IDP implementation or not using PSSO at all, so this is likely not needed +Current placeholder behavior: the JWKS signing key is lazily minted on the first `GET /api/mdm/apple/psso/jwks` and persisted (encrypted) in `mdm_config_assets` under `MDMAssetPSSOSigningKey`. Alternatives under consideration include minting on the first time a user enables the feature. The lazy-mint code carries a `TODO`; decision pending. ### In-tree Swift extension at `apple-sso-extension/` The Swift sources for the SSO extension live in this repo at `apple-sso-extension/`. Signing and notarization happen out-of-band using the deployer's own Apple Developer ID; Fleet does not ship a signed binary. The hostname declared in the extension's `authsrv:` entitlement must match the hostname served by `/.well-known/apple-app-site-association`. -**Device registration must POST directly (no WKWebView).** `beginDeviceRegistration` submits the device's signing/encryption public keys to `/mdm/apple/psso/register` via a direct `URLSession` POST, and reports `.success` only after Fleet returns 2xx. An earlier implementation routed the POST through a WKWebView navigation-delegate intercept (a holdover from an OAuth-code registration model). That web view isn't functional during Setup Assistant, so with `EnableRegistrationDuringSetup` the POST silently never fired, yet `completion(.success)` was still called unconditionally — the framework then went straight to nonce → token with an unregistered key and the token endpoint 404'd ("PSSOKeyID … not found", surfaced on-device as "Incorrect username or password"). Password-mode registration has no browser step, so the web view was never needed; awaiting the direct POST also guarantees the keys are persisted before the framework proceeds to authentication. +**Device registration must POST directly (no WKWebView).** `beginDeviceRegistration` submits the device's signing/encryption public keys to `/api/mdm/apple/psso/registration` via a direct `URLSession` POST, and reports `.success` only after Fleet returns 2xx. An earlier implementation routed the POST through a WKWebView navigation-delegate intercept (a holdover from an OAuth-code registration model). That web view isn't functional during Setup Assistant, so with `EnableRegistrationDuringSetup` the POST silently never fired, yet `completion(.success)` was still called unconditionally — the framework then went straight to nonce → token with an unregistered key and the token endpoint 404'd ("PSSOKeyID … not found", surfaced on-device as "Incorrect username or password"). Password-mode registration has no browser step, so the web view was never needed; awaiting the direct POST also guarantees the keys are persisted before the framework proceeds to authentication. ## Known limitations @@ -133,7 +129,7 @@ The Swift sources for the SSO extension live in this repo at `apple-sso-extensio - **AASA requires a public-CA TLS certificate.** Apple's framework silently rejects self-signed certificates when fetching `/.well-known/apple-app-site-association`. Local development requires a real DNS name with a Let's Encrypt cert, or a tunnel such as ngrok or cloudflared. - **No device revocation or key rotation in the POC.** Devices register once and stay registered for the life of the row. - **Global config only.** PSSO settings live on `AppConfig`; there is no per-team override. -- **Device registration is unauthenticated in the POC.** `POST /mdm/apple/psso/register` accepts any request that presents a device UUID matching an enrolled host plus a set of public keys; nothing proves the request actually originates from that enrolled device. An attacker who can reach the endpoint and knows (or guesses) an enrolled host's hardware UUID could register their own keys for that host. See "Authenticate registration with a per-device token" below for the planned fix. +- **Device registration is unauthenticated in the POC.** `POST /api/mdm/apple/psso/registration` accepts any request that presents a device UUID matching an enrolled host plus a set of public keys; nothing proves the request actually originates from that enrolled device. An attacker who can reach the endpoint and knows (or guesses) an enrolled host's hardware UUID could register their own keys for that host. See "Authenticate registration with a per-device token" below for the planned fix. ## Productionizing steps @@ -177,6 +173,8 @@ These are known-required steps to take the POC to a shippable feature. They are **Planned approach.** Resolve the IdP client (and nonce store, if it grows config) from the current `AppConfig` at request time rather than caching a single instance at boot — e.g. construct it per call from the live config, or cache it behind the existing app-config change signal so edits take effect immediately. Pair that with a PSSO settings page in the console and secret masking on the config API. This removes the restart requirement and the hand-edited-SQL workflow entirely. +**Live reload addressed in #46942.** The OIDC ROPG client is now built from the current `AppConfig` on every password login, and the boot-time `SetPSSOIdPClient` wiring was removed from `serve.go` (the setter remains as a test hook). The admin UI and secret masking remain with #46959 / #47127. + ### LDAP identity backend (Google Workspace Secure LDAP) **Problem / motivation.** The POC validates passwords via OIDC ROPG, but **Google Workspace does not support the OAuth ROPG (`grant_type=password`) flow at all** — so there is no OIDC path to validate a Google user's password server-side. Google's supported mechanism for that is **Secure LDAP**. Adding an LDAP backend therefore isn't just an alternative to ROPG; it's what unlocks Google Workspace as an IdP. The same backend also covers classic LDAP/Active Directory for customers who prefer a directory bind over ROPG. @@ -186,11 +184,12 @@ These are known-required steps to take the POC to a shippable feature. They are **Implementation touch points.** - `ee/server/service/apple_psso_idp_ldap.go` — new `PSSOLDAPClient` implementing the interface (search-then-bind; ~150–250 lines). Adds an LDAP library dependency (`github.com/go-ldap/ldap/v3` — confirm it isn't already vendored; Fleet does not appear to use LDAP today). - `server/fleet/apple_psso.go` — add an `IdPType` discriminator (`oidc_ropg` | `ldap`) to `PSSOSettings` and an `LDAP *PSSOLDAPSettings` block (`ServerURL`, `BaseDN`, `UserSearchAttr`, attribute→claim map, and the directory-auth material — see below). -- `cmd/fleet/serve.go` — switch on `IdPType` when wiring the client instead of always constructing `PSSOOIDCROPGClient` (pairs with the live-reload work under *Admin configuration*). +- `ee/server/service/apple_psso.go` — `pssoIdPClientFromSettings` switches on `IdPType` instead of always constructing `PSSOOIDCROPGClient`. (The client is already built per request from live settings here, so no `serve.go` wiring is involved.) - Secret storage + masking — the Google client certificate/key (and any service bind password) are directory-wide credentials; encrypt at rest via the `mdm_config_assets` pattern and mask on the config API (same write-path work as the IdPClientSecret finding). -- Tests (extend the stub; integrate against glauth/OpenLDAP or a mocked connection) and a Google Admin console setup doc. +- Tests (integrate against glauth/OpenLDAP or a mocked connection) and a Google Admin console setup doc. **Google Secure LDAP specifics.** +- LDAP support of any flavor has been deferred to a later release - Endpoint `ldaps://ldap.google.com:636`, TLS only. - **Directory authentication is mutual TLS, not a bind password.** An "LDAP client" is created in the Google Admin console, which issues a client certificate + private key that Fleet presents (`tls.Config.Certificates`). This is the main structural difference from classic LDAP/AD, which uses a service bind DN + password — so the LDAP settings should accommodate both directory-auth styles. - The Admin console LDAP client must be granted access to the relevant OUs and permission to verify user credentials; the base DN derives from the domain (e.g. `dc=example,dc=com`). @@ -219,10 +218,14 @@ The crypto was otherwise found sound: no passwords/refresh-tokens/client-secrets No PSSO service method consults `cfg.PSSOSettings.Enabled`; the unauthenticated `/mdm/apple/psso/*` surface is live on every licensed instance even when an admin never enabled (or explicitly disabled) PSSO. Gate the device-facing methods (`PSSONonce`, `PSSORegisterComplete`, `PSSOToken`, and arguably JWKS/AASA) on `Enabled` in the service layer so all entry points are covered. +**Addressed in #46942.** Every PSSO service method now checks the live `AppConfig.PSSOSettings` per request (`pssoSettingsIfConfigured`): nonce/registration/token return 400 and JWKS/AASA return 404 when the feature is disabled or incompletely configured. + ### HIGH [deploy] — Unbounded replay of token requests The verified JWT parse validates `exp`/`nbf` only if present, and inbound request JWTs are not required to carry an `exp` (nor is one enforced). The sole anti-replay control, `request_nonce`, is consumed best-effort — a miss is logged, not rejected (`ee/server/service/apple_psso.go`, `handlePSSOPasswordLogin`). A captured login-request JWS can therefore be replayed indefinitely to re-trigger IdP password validation and yield a fresh valid login-response JWE. This supersedes the milder "best-effort nonce, fix before GA" framing — the practical state is unbounded replay of a credential-validating request. Fix: require a short-lived `exp` (and an `iat` max-age) on inbound JWTs, and hard-enforce single-use `request_nonce` (reject when the store rejects). +**Partially addressed in #46942.** `request_nonce` is now hard-enforced and consumed before dispatch for all token flows (password login, key request, key exchange) — a replayed JWS is rejected. Requiring `exp`/`iat` max-age on inbound JWTs remains open (#47122 covers JWT validation cleanup). + ### HIGH [deploy] — Key replacement on registration enables device takeover Compounds the unauthenticated-registration limitation. `SetOrUpdatePSSODevice` (`server/datastore/mysql/apple_psso.go`) deletes a host's existing `key_ids` and inserts the caller's on a plain upsert. The `IOPlatformUUID` is not secret (it appears in osquery results, MDM inventory, logs), so an unauthenticated attacker who knows it can *overwrite* a legitimate device's registered signing/encryption keys and then drive `/token` as that host. The planned per-device registration token closes the spoofing primitive, but the key-replacement semantics are a separate decision: once a host has a registration, require the per-host token to match before replacing keys, and log/emit an activity on key replacement (rotation vs. takeover). @@ -243,10 +246,6 @@ The provisioned private key sealed into `key_context` (key request) is recoverab `issuePSSOProvisionedCertificate` (`ee/server/service/apple_psso.go`) regenerates a self-signed, unconstrained, 10-year signing CA on every key request with fixed serial `1`. Generate the CA once (persist alongside the signing key), use random serials for both CA and leaf, and add EKU/name constraints scoping its use. -### LOW [GA] — No request body-size limit on unauthenticated endpoints - -`/token` and `/register` use `r.ParseForm()` / `io.ReadAll` with no cap, and the ROPG client reads the IdP response with `io.ReadAll`. Wrap the handlers with a body-size limit (e.g. 64 KB for a JWS assertion) as the SCEP/MDM endpoints should. - ### LOW [GA] — Hardcoded developer Team/bundle IDs in the AASA `teamID*`/`bundleID*` constants (`ee/server/service/apple_psso.go`) are baked into the public, always-served `apple-app-site-association`. They leak Fleet-developer identifiers and mis-bind for any deployer using their own signing identity. Make them config-driven before GA. diff --git a/ee/server/service/apple_psso.go b/ee/server/service/apple_psso.go index 08085525cff..1ad0092ce8f 100644 --- a/ee/server/service/apple_psso.go +++ b/ee/server/service/apple_psso.go @@ -45,8 +45,7 @@ type pssoServiceState struct { } const ( - pssoSigningCurve = "P-256" - pssoSigningAlg = "ES256" + pssoSigningAlg = "ES256" // TODO: It's not clear if we need the overall app bundle ID or not either. We'll add it just in case bundleID1 = "com.fleetdm.pssotesting" @@ -168,33 +167,49 @@ func isAssetNotFound(err error) bool { return errors.Is(err, sql.ErrNoRows) } -// SetPSSONonceStore wires the Redis-backed PSSO nonce store. Intended to be -// called from cmd/fleet right after eeservice.NewService so the POC doesn't -// have to expand the NewService signature for an optional collaborator. -func (svc *Service) SetPSSONonceStore(store fleet.PSSONonceStore) { - svc.pssoNonceStore = store +// pssoSettingsIfConfigured returns the PSSO settings from the current +// AppConfig when the feature is enabled and carries everything the flows +// need; otherwise it returns nil. Read per request so enabling, disabling, +// or repointing the IdP takes effect without a server restart. +func (svc *Service) pssoSettingsIfConfigured(ctx context.Context) (*fleet.PSSOSettings, error) { + cfg, err := svc.ds.AppConfig(ctx) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "load app config for psso") + } + s := cfg.PSSOSettings + if s == nil || !s.Enabled || s.IssuerURL == "" || s.IdPTokenURL == "" || s.IdPClientID == "" || s.IdPClientSecret == "" { + return nil, nil + } + return s, nil } -// SetPSSOIdPClient wires the upstream IdP client (a generic OIDC ROPG -// client in production, the deterministic stub in tests). Same rationale as -// SetPSSONonceStore. -func (svc *Service) SetPSSOIdPClient(client fleet.PSSOIdPClient) { - svc.pssoIdPClient = client -} +// errPSSONotConfigured is returned from the device-facing endpoints when the +// feature is disabled or missing required settings. Return it unwrapped (no +// ctxerr.Wrap) so errors.Is matches on pointer identity. +var errPSSONotConfigured = &fleet.BadRequestError{Message: "Platform SSO is not configured"} // pssoNonceTTL is how long an issued nonce remains valid before it's -// rejected. Five minutes covers both registration (browser round-trip -// through the upstream IdP) and sign-in (extension immediate use). +// rejected. Five minutes comfortably covers the extension's immediate +// nonce→token round trip. const pssoNonceTTL = 5 * time.Minute // PSSONonce mints a fresh 32-byte base64url nonce, persists it with a short // TTL via the wired PSSONonceStore, and returns it to the caller. The -// extension embeds this nonce in subsequent JWT claims to prevent replay. +// extension embeds this nonce in its next token-request JWT, where it is +// consumed (single-use) to prevent replay. func (svc *Service) PSSONonce(ctx context.Context) (string, error) { // skipauth: This is an unauthenticated endpoint hit by the Mac extension // before any user identity is established. svc.authz.SkipAuthorization(ctx) + settings, err := svc.pssoSettingsIfConfigured(ctx) + if err != nil { + return "", err + } + if settings == nil { + return "", errPSSONotConfigured + } + if svc.pssoNonceStore == nil { return "", ctxerr.New(ctx, "psso nonce store not configured") } @@ -210,49 +225,29 @@ func (svc *Service) PSSONonce(ctx context.Context) (string, error) { return nonce, nil } -// PSSORegisterBegin builds the redirect URL the Mac extension's WebView -// should follow to start the upstream IdP's OAuth code flow. The returned URL -// embeds a fresh server-issued nonce in the `state` parameter so we can -// detect replay when the extension calls PSSORegisterComplete. -func (svc *Service) PSSORegisterBegin(ctx context.Context) (string, error) { - // skipauth: This is an unauthenticated endpoint hit by the Mac extension's - // WebView before user identity exists. - svc.authz.SkipAuthorization(ctx) - - cfg, err := svc.ds.AppConfig(ctx) - if err != nil { - return "", ctxerr.Wrap(ctx, err, "load app config for psso register") +// consumePSSORequestNonce enforces the single-use request_nonce on token +// requests: the JWT must carry a nonce previously issued by PSSONonce, and +// consuming it must succeed exactly once. Any miss (absent claim, unknown or +// already-used nonce) rejects the request — this is the anti-replay control +// for the unauthenticated token endpoint. +func (svc *Service) consumePSSORequestNonce(ctx context.Context, requestNonce string) error { + if requestNonce == "" { + return &fleet.BadRequestError{Message: "psso token: missing request_nonce"} } - pcfg := cfg.PSSOSettings - if pcfg == nil || pcfg.IdPAuthorizeURL == "" || pcfg.IdPClientID == "" || pcfg.IssuerURL == "" { - return "", &fleet.BadRequestError{Message: "PSSO is not configured: idp_authorize_url, idp_client_id, and issuer_url are required"} + if svc.pssoNonceStore == nil { + return ctxerr.New(ctx, "psso nonce store not configured") } - - state, err := svc.PSSONonce(ctx) + ok, err := svc.pssoNonceStore.Consume(ctx, requestNonce) if err != nil { - return "", ctxerr.Wrap(ctx, err, "issue psso register state nonce") + return ctxerr.Wrap(ctx, err, "consume psso request_nonce") } - - scopes := pcfg.IdPScopes - if scopes == "" { - scopes = defaultOIDCScopes + if !ok { + return &fleet.BadRequestError{Message: "psso token: invalid or expired request_nonce"} } - - params := url.Values{} - params.Set("client_id", pcfg.IdPClientID) - params.Set("response_type", "code") - params.Set("redirect_uri", pcfg.IssuerURL+"/mdm/apple/psso/register") - params.Set("scope", scopes) - params.Set("state", state) - - sep := "?" - if strings.Contains(pcfg.IdPAuthorizeURL, "?") { - sep = "&" - } - return pcfg.IdPAuthorizeURL + sep + params.Encode(), nil + return nil } -// PSSORegisterComplete consumes the device-key enrollment POST from the Mac +// PSSORegisterDevice consumes the device-key enrollment POST from the Mac // extension: it resolves the enrolled host from the hardware device UUID and // persists the device record plus its public key rows. // @@ -260,24 +255,32 @@ func (svc *Service) PSSORegisterBegin(ctx context.Context) (string, error) { // simply submits the public halves of its Secure Enclave signing and // encryption keys. User identity is established later, on each password login // at the token endpoint. -func (svc *Service) PSSORegisterComplete(ctx context.Context, req fleet.PSSORegisterRequest) error { +func (svc *Service) PSSORegisterDevice(ctx context.Context, req fleet.PSSODeviceRegistrationRequest) error { // skipauth: This is an unauthenticated device-initiated endpoint. The // device proves itself later by signing token requests with the signing // key registered here, verified against the kid. svc.authz.SkipAuthorization(ctx) - if req.DeviceUUID == "" || req.DeviceSigningKey == "" || req.DeviceEncryptionKey == "" || req.SignKeyID == "" || req.EncKeyID == "" { - return &fleet.BadRequestError{Message: "missing required psso register fields"} + settings, err := svc.pssoSettingsIfConfigured(ctx) + if err != nil { + return err + } + if settings == nil { + return errPSSONotConfigured + } + + if req.DeviceUUID == "" || req.DeviceSigningKey == "" || req.DeviceEncryptionKey == "" || req.SigningKeyID == "" || req.EncryptionKeyID == "" { + return &fleet.BadRequestError{Message: "missing required psso registration fields"} } // Reject unparseable key material up front: a bad PEM stored here would // otherwise only surface as opaque verification failures at every // subsequent login. if _, err := parseECPublicKeyPEM([]byte(req.DeviceSigningKey)); err != nil { - return &fleet.BadRequestError{Message: "psso register: signing key is not a valid P-256 public key"} + return &fleet.BadRequestError{Message: "psso registration: signing key is not a valid P-256 public key"} } if _, err := parseECPublicKeyPEM([]byte(req.DeviceEncryptionKey)); err != nil { - return &fleet.BadRequestError{Message: "psso register: encryption key is not a valid P-256 public key"} + return &fleet.BadRequestError{Message: "psso registration: encryption key is not a valid P-256 public key"} } // PSSO requires a matching enrolled host; the registration is keyed by the @@ -285,7 +288,7 @@ func (svc *Service) PSSORegisterComplete(ctx context.Context, req fleet.PSSORegi host, err := svc.ds.HostByUUID(ctx, req.DeviceUUID) if err != nil { if fleet.IsNotFound(err) { - return &fleet.BadRequestError{Message: fmt.Sprintf("psso register: no enrolled host matches device UUID %q", req.DeviceUUID)} + return &fleet.BadRequestError{Message: fmt.Sprintf("psso registration: no enrolled host matches device UUID %q", req.DeviceUUID)} } return ctxerr.Wrap(ctx, err, "look up host by device uuid") } @@ -295,12 +298,12 @@ func (svc *Service) PSSORegisterComplete(ctx context.Context, req fleet.PSSORegi // alphabet differences between the extension and Apple's framework. keys := []fleet.PSSOKey{ { - KID: canonicalizeKID(req.SignKeyID), + KID: canonicalizeKID(req.SigningKeyID), KeyType: fleet.PSSOKeyTypeSigning, PEM: req.DeviceSigningKey, }, { - KID: canonicalizeKID(req.EncKeyID), + KID: canonicalizeKID(req.EncryptionKeyID), KeyType: fleet.PSSOKeyTypeEncryption, PEM: req.DeviceEncryptionKey, }, @@ -313,13 +316,21 @@ func (svc *Service) PSSORegisterComplete(ctx context.Context, req fleet.PSSORegi // PSSOToken handles the per-sign-in token endpoint. It parses the inbound // signed JWT, looks up the registered device by kid, verifies the signature, -// then dispatches on the JWT's request_type claim and returns a JWE -// response in the Apple PSSO format. +// consumes the request_nonce, then dispatches on the JWT's claims and returns +// a JWE response in the Apple PSSO format. func (svc *Service) PSSOToken(ctx context.Context, jwtBytes []byte) ([]byte, error) { // skipauth: This is an unauthenticated device-initiated endpoint; the // JWT signature against a known device signing pubkey is the auth. svc.authz.SkipAuthorization(ctx) + settings, err := svc.pssoSettingsIfConfigured(ctx) + if err != nil { + return nil, err + } + if settings == nil { + return nil, errPSSONotConfigured + } + if len(jwtBytes) == 0 { return nil, &fleet.BadRequestError{Message: "psso token: empty request body"} } @@ -329,10 +340,17 @@ func (svc *Service) PSSOToken(ctx context.Context, jwtBytes []byte) ([]byte, err return nil, err } + // Every token request, regardless of flow, must present a fresh + // single-use nonce. Consume it before dispatching so a replayed JWS is + // rejected before any IdP or key work happens. + if err := svc.consumePSSORequestNonce(ctx, claims.RequestNonce); err != nil { + return nil, err + } + // PSSO v2 Password login: a single grant_type=password round trip carrying // a plaintext password and a jwe_crypto response recipe. if claims.GrantType == pssoGrantTypePassword { - return svc.handlePSSOPasswordLogin(ctx, signKey.HostUUID, claims) + return svc.handlePSSOPasswordLogin(ctx, settings, signKey.HostUUID, claims) } switch claims.RequestType { @@ -361,20 +379,27 @@ const pssoDefaultTokenTTL = time.Hour // pssoIDTokenIssuer returns the value the device validates the login-response // id_token `iss` claim against. Apple's login configuration derives the issuer -// from the profile's IssuerHostname — a bare hostname with no scheme — so the -// configured IssuerURL is reduced to its host. -func (svc *Service) pssoIDTokenIssuer(ctx context.Context) (string, error) { - cfg, err := svc.ds.AppConfig(ctx) - if err != nil { - return "", ctxerr.Wrap(ctx, err, "load app config for psso issuer") +// from the extension's configured hostname — a bare hostname with no scheme — +// so the configured IssuerURL is reduced to its host. +func pssoIDTokenIssuer(settings *fleet.PSSOSettings) string { + if u, err := url.Parse(settings.IssuerURL); err == nil && u.Host != "" { + return u.Host } - if cfg.PSSOSettings == nil || cfg.PSSOSettings.IssuerURL == "" { - return "", ctxerr.New(ctx, "psso issuer_url not configured") - } - if u, err := url.Parse(cfg.PSSOSettings.IssuerURL); err == nil && u.Host != "" { - return u.Host, nil + return strings.TrimSuffix(settings.IssuerURL, "/") +} + +// pssoIdPClientFromSettings builds the upstream IdP client for the password +// login flow from the current settings, so config changes apply without a +// restart. Returns the interface so an alternate backend (e.g. an LDAP bind +// client for IdPs that reject ROPG) can be selected here later. Tests fake +// the upstream IdP at the network boundary via PSSOOIDCROPGClient.HTTPClient. +func pssoIdPClientFromSettings(settings *fleet.PSSOSettings) fleet.PSSOIdPClient { + return PSSOOIDCROPGClient{ + TokenURL: settings.IdPTokenURL, + ClientID: settings.IdPClientID, + ClientSecret: settings.IdPClientSecret, + Scopes: settings.IdPScopes, } - return strings.TrimSuffix(cfg.PSSOSettings.IssuerURL, "/"), nil } // handlePSSOPasswordLogin services a PSSO v2 Password login request. The @@ -383,10 +408,7 @@ func (svc *Service) pssoIDTokenIssuer(ctx context.Context) (string, error) { // Fleet validates the password against the upstream IdP, then returns the // resulting OIDC claims as a server-signed JWT wrapped in a JWE encrypted per // that recipe. -func (svc *Service) handlePSSOPasswordLogin(ctx context.Context, hostUUID string, claims *pssoTokenClaims) ([]byte, error) { - if svc.pssoIdPClient == nil { - return nil, ctxerr.New(ctx, "psso idp client not configured") - } +func (svc *Service) handlePSSOPasswordLogin(ctx context.Context, settings *fleet.PSSOSettings, hostUUID string, claims *pssoTokenClaims) ([]byte, error) { if claims.JWECrypto == nil || claims.JWECrypto.APV == "" { return nil, &fleet.BadRequestError{Message: "psso password login: missing jwe_crypto recipe"} } @@ -402,21 +424,8 @@ func (svc *Service) handlePSSOPasswordLogin(ctx context.Context, hostUUID string return nil, &fleet.BadRequestError{Message: "psso password login: missing username or password"} } - // Best-effort single-use nonce check. request_nonce is the value Fleet - // issued from /mdm/apple/psso/nonce that the extension echoes here. It is - // not hard-enforced for the POC: the exact nonce the AppSSOAgent replays is - // still being confirmed end-to-end, so a miss is logged rather than - // rejected to keep password validation testable. Enforce before GA. - if claims.RequestNonce != "" && svc.pssoNonceStore != nil { - ok, err := svc.pssoNonceStore.Consume(ctx, claims.RequestNonce) - if err != nil { - svc.logger.WarnContext(ctx, "psso password login: nonce consume error", "err", err) - } else if !ok { - svc.logger.WarnContext(ctx, "psso password login: request_nonce not recognized", "request_nonce", claims.RequestNonce) - } - } - - idpClaims, err := svc.pssoIdPClient.ValidatePasswordAndGetClaims(ctx, username, claims.Password) + idpClient := pssoIdPClientFromSettings(settings) + idpClaims, err := idpClient.ValidatePasswordAndGetClaims(ctx, username, claims.Password) if err != nil { return nil, ctxerr.Wrap(ctx, err, "psso password validation") } @@ -432,10 +441,7 @@ func (svc *Service) handlePSSOPasswordLogin(ctx context.Context, hostUUID string // mints its own id_token. The device validates: nonce == request nonce, // iss == the profile issuer (hostname, no scheme), aud contains the // clientID, iat in the past, exp in the future. - issuer, err := svc.pssoIDTokenIssuer(ctx) - if err != nil { - return nil, err - } + issuer := pssoIDTokenIssuer(settings) expiresIn := idpClaims.ExpiresIn if expiresIn <= 0 { expiresIn = int(pssoDefaultTokenTTL.Seconds()) @@ -650,12 +656,22 @@ func (svc *Service) handlePSSOKeyExchange(ctx context.Context, hostUUID string, return jwe, nil } -// PSSOJWKS returns the JWKS JSON with Fleet's PSSO signing public key. +// PSSOJWKS returns the JWKS JSON with Fleet's PSSO signing public key. When +// the feature is not configured it returns a 404 (not a 400 like the +// device-facing endpoints) so the endpoint is indistinguishable from absent. func (svc *Service) PSSOJWKS(ctx context.Context) ([]byte, error) { // skipauth: This is an unauthenticated public endpoint serving only the // signing public key — there is no caller identity to authorize. svc.authz.SkipAuthorization(ctx) + settings, err := svc.pssoSettingsIfConfigured(ctx) + if err != nil { + return nil, err + } + if settings == nil { + return nil, ¬FoundError{} + } + key, kid, err := svc.getOrMintPSSOSigningKey(ctx) if err != nil { return nil, ctxerr.Wrap(ctx, err, "load psso signing key") @@ -684,11 +700,22 @@ type pssoAASAApps struct { // PSSOAASA returns the apple-app-site-association JSON Apple's framework // uses to validate the extension's authsrv: entitlement against Fleet's -// hostname. +// hostname. Returns 404 when the feature is not configured. Note Apple's CDN +// caches this document for hours, so hosts may see a config change with a +// 6–24h delay. func (svc *Service) PSSOAASA(ctx context.Context) ([]byte, error) { // skipauth: This is an unauthenticated public endpoint — Apple's // framework fetches it anonymously to validate the extension binding. svc.authz.SkipAuthorization(ctx) + + settings, err := svc.pssoSettingsIfConfigured(ctx) + if err != nil { + return nil, err + } + if settings == nil { + return nil, ¬FoundError{} + } + ids := []string{teamID1 + "." + bundleID1, teamID2 + "." + bundleID1, teamID1 + "." + bundleID2, teamID2 + "." + bundleID2} doc := pssoAASA{ WebCredentials: pssoAASAApps{ diff --git a/ee/server/service/apple_psso_crypto.go b/ee/server/service/apple_psso_crypto.go index 65512f02532..3215d4bd442 100644 --- a/ee/server/service/apple_psso_crypto.go +++ b/ee/server/service/apple_psso_crypto.go @@ -1,16 +1,16 @@ package service -// PSSO crypto helpers. Implemented clean-room against Apple's -// ASAuthorizationProviderExtension* protocol surface and standard JOSE -// primitives. No third-party PSSO SDK or sample code is referenced. +// PSSO crypto helpers. Implemented against Apple's ASAuthorizationProviderExtension* +// protocol surface and standard JOSE primitives. // -// Cryptographic choices for the POC: -// - Inbound JWTs from the Mac extension are ES256 (P-256). The kid in the +// Cryptographic choices: +// - Inbound JWTs from the Mac extension are ES256 (P-256). We may need to allow more +// algorithms in the future but this is what has been observed today. The kid in the // header points to a PEM stored in mdm_apple_psso_keys. -// - JWE responses use ECDH-ES with A256GCM, wrapped to the device's -// registered encryption pubkey (resolved from the request's apv). -// - key_context blobs are sealed with A256GCM under a key derived from -// Fleet's PSSO signing key via HKDF-SHA256 — no per-device server state. +// - JWE responses use ECDH-ES with A256GCM, wrapped to the device's registered encryption +// pubkey (resolved from the request's apv). +// - key_context blobs are sealed with A256GCM under a key derived from Fleet's PSSO signing +// key via HKDF-SHA256 — no per-device server state is stored. import ( "context" diff --git a/ee/server/service/apple_psso_idp_oidc_ropg.go b/ee/server/service/apple_psso_idp_oidc_ropg.go index 74fa0a896cb..7c362115a4d 100644 --- a/ee/server/service/apple_psso_idp_oidc_ropg.go +++ b/ee/server/service/apple_psso_idp_oidc_ropg.go @@ -21,23 +21,12 @@ import ( // pull the user's claims from. const defaultOIDCScopes = "openid profile email" +// maxOIDCTokenResponseSize bounds how much of the IdP token response Fleet +// will read. Real responses (id_token + refresh_token JSON) are a few KB. +const maxOIDCTokenResponseSize = 1 << 20 // 1 MiB + // PSSOOIDCROPGClient validates passwords against any OIDC IdP that exposes -// the OAuth2 Resource Owner Password Grant on its token endpoint. The POC -// has been exercised against Okta first; Entra ID, Auth0, Keycloak, and -// other providers that allow ROPG will work with no code changes, only -// different TokenURL values. -// -// Known limitations: -// - Okta: ROPG must be explicitly enabled on the application ("Allowed -// Grant Types → Resource Owner Password" in the Okta app config), and -// the app type must be Native or Service. SPAs and standard web apps -// reject the grant. -// - Entra: MFA-required users fail with conditional access errors; -// federated (AD FS) users are not supported. Both are upstream -// constraints, not Fleet bugs. -// - All providers: ROPG is widely considered deprecated for new -// deployments. Use a different PSSOIdPClient implementation (e.g. LDAP -// bind) if your IdP doesn't support it. +// the OAuth2 Resource Owner Password Grant on its token endpoint type PSSOOIDCROPGClient struct { // TokenURL is the full URL of the IdP's token endpoint. TokenURL string @@ -101,7 +90,9 @@ func (c PSSOOIDCROPGClient) ValidatePasswordAndGetClaims(ctx context.Context, us } defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) + // Cap the read so a misbehaving (or misconfigured) IdP can't exhaust + // memory; a token response is a few KB. + body, err := io.ReadAll(io.LimitReader(resp.Body, maxOIDCTokenResponseSize)) if err != nil { return nil, ctxerr.Wrap(ctx, err, "read oidc ropg response") } diff --git a/ee/server/service/apple_psso_idp_stub.go b/ee/server/service/apple_psso_idp_stub.go deleted file mode 100644 index f7cd6ea42fb..00000000000 --- a/ee/server/service/apple_psso_idp_stub.go +++ /dev/null @@ -1,28 +0,0 @@ -package service - -import ( - "context" - - "github.com/fleetdm/fleet/v4/server/fleet" -) - -// PSSOStubIdPClient is a deterministic fleet.PSSOIdPClient used in tests and -// local development. It accepts any non-empty password and returns claims -// derived directly from the username. -type PSSOStubIdPClient struct{} - -// ValidatePasswordAndGetClaims accepts any non-empty (username, password) -// pair and returns synthetic OIDC-shaped claims. It exists so the PSSO -// crypto and persistence layers can be exercised end-to-end without -// requiring an upstream OIDC IdP. -func (PSSOStubIdPClient) ValidatePasswordAndGetClaims(_ context.Context, username, password string) (*fleet.PSSOClaims, error) { - if username == "" || password == "" { - return nil, &fleet.BadRequestError{Message: "stub IdP requires non-empty username and password"} - } - return &fleet.PSSOClaims{ - Subject: "stub-sub-" + username, - Email: username, - PreferredUsername: username, - Name: username, - }, nil -} diff --git a/ee/server/service/apple_psso_test.go b/ee/server/service/apple_psso_test.go new file mode 100644 index 00000000000..ca450b4ab76 --- /dev/null +++ b/ee/server/service/apple_psso_test.go @@ -0,0 +1,114 @@ +package service + +import ( + "context" + "log/slog" + "testing" + "time" + + "github.com/fleetdm/fleet/v4/server/authz" + authz_ctx "github.com/fleetdm/fleet/v4/server/contexts/authz" + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/mock" + "github.com/stretchr/testify/require" +) + +func newPSSOTestService(t *testing.T, settings *fleet.PSSOSettings) (*Service, context.Context) { + t.Helper() + ds := new(mock.Store) + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{PSSOSettings: settings}, nil + } + auth, err := authz.NewAuthorizer() + require.NoError(t, err) + ctx := authz_ctx.NewContext(t.Context(), &authz_ctx.AuthorizationContext{}) + return &Service{ + ds: ds, + authz: auth, + logger: slog.New(slog.DiscardHandler), + }, ctx +} + +func configuredPSSOSettings() *fleet.PSSOSettings { + return &fleet.PSSOSettings{ + Enabled: true, + IssuerURL: "https://fleet.example.com", + IdPTokenURL: "https://idp.example.com/oauth2/v1/token", + IdPClientID: "client-id", + IdPClientSecret: "client-secret", + } +} + +// memNonceStore is a minimal in-memory fleet.PSSONonceStore. +type memNonceStore struct { + nonces map[string]struct{} +} + +func (s *memNonceStore) Store(_ context.Context, nonce string, _ time.Duration) error { + if s.nonces == nil { + s.nonces = map[string]struct{}{} + } + s.nonces[nonce] = struct{}{} + return nil +} + +func (s *memNonceStore) Consume(_ context.Context, nonce string) (bool, error) { + if _, ok := s.nonces[nonce]; !ok { + return false, nil + } + delete(s.nonces, nonce) + return true, nil +} + +func TestPSSO_EndpointsGatedOnConfiguration(t *testing.T) { + notConfigured := []*fleet.PSSOSettings{ + nil, + {}, + func() *fleet.PSSOSettings { s := configuredPSSOSettings(); s.Enabled = false; return s }(), + func() *fleet.PSSOSettings { s := configuredPSSOSettings(); s.IssuerURL = ""; return s }(), + func() *fleet.PSSOSettings { s := configuredPSSOSettings(); s.IdPTokenURL = ""; return s }(), + func() *fleet.PSSOSettings { s := configuredPSSOSettings(); s.IdPClientID = ""; return s }(), + func() *fleet.PSSOSettings { s := configuredPSSOSettings(); s.IdPClientSecret = ""; return s }(), + } + + for _, settings := range notConfigured { + svc, ctx := newPSSOTestService(t, settings) + + // Device-facing endpoints return a 400. + _, err := svc.PSSONonce(ctx) + require.ErrorIs(t, err, errPSSONotConfigured) + err = svc.PSSORegisterDevice(ctx, fleet.PSSODeviceRegistrationRequest{}) + require.ErrorIs(t, err, errPSSONotConfigured) + _, err = svc.PSSOToken(ctx, []byte("ignored")) + require.ErrorIs(t, err, errPSSONotConfigured) + + // Discovery endpoints return a 404. + _, err = svc.PSSOJWKS(ctx) + require.True(t, fleet.IsNotFound(err), "jwks should be 404, got %v", err) + _, err = svc.PSSOAASA(ctx) + require.True(t, fleet.IsNotFound(err), "aasa should be 404, got %v", err) + } +} + +func TestPSSO_NonceIssuedAndConsumedWhenConfigured(t *testing.T) { + svc, ctx := newPSSOTestService(t, configuredPSSOSettings()) + svc.pssoNonceStore = &memNonceStore{} + + nonce, err := svc.PSSONonce(ctx) + require.NoError(t, err) + require.NotEmpty(t, nonce) + + // First consume succeeds, replay is rejected. + require.NoError(t, svc.consumePSSORequestNonce(ctx, nonce)) + err = svc.consumePSSORequestNonce(ctx, nonce) + require.Error(t, err) + var bre *fleet.BadRequestError + require.ErrorAs(t, err, &bre) + + // A nonce Fleet never issued is rejected. + err = svc.consumePSSORequestNonce(ctx, "never-issued") + require.Error(t, err) + // And the claim is required at all. + err = svc.consumePSSORequestNonce(ctx, "") + require.Error(t, err) +} diff --git a/ee/server/service/mdm_external_test.go b/ee/server/service/mdm_external_test.go index d877e285176..fde4e1b2044 100644 --- a/ee/server/service/mdm_external_test.go +++ b/ee/server/service/mdm_external_test.go @@ -128,6 +128,7 @@ func setupMockDatastorePremiumService(t testing.TB) (*mock.Store, *eeservice.Ser nil, nil, nil, + nil, ) if err != nil { panic(err) diff --git a/ee/server/service/service.go b/ee/server/service/service.go index 88be39177ee..ea3cd70a75a 100644 --- a/ee/server/service/service.go +++ b/ee/server/service/service.go @@ -22,15 +22,11 @@ type Service struct { // Constructed on first use of a PSSO method. pssoState pssoServiceState - // pssoNonceStore is wired post-construction via SetPSSONonceStore so the - // existing eeservice.NewService signature doesn't need to change for the - // PSSO POC. Required for PSSO nonce/register/token flows. + // pssoNonceStore backs the single-use nonces issued and consumed by the + // PSSO nonce/token flows. Redis-backed in production; may be nil in + // deployments/tests that never exercise PSSO. pssoNonceStore fleet.PSSONonceStore - // pssoIdPClient validates passwords for the PSSO password login flow. - // Wired via SetPSSOIdPClient. - pssoIdPClient fleet.PSSOIdPClient - ds fleet.Datastore logger *slog.Logger config config.FleetConfig @@ -72,6 +68,7 @@ func NewService( digiCertService fleet.DigiCertService, androidService android.Service, estService fleet.ESTService, + pssoNonceStore fleet.PSSONonceStore, ) (*Service, error) { authorizer, err := authz.NewAuthorizer() if err != nil { @@ -99,6 +96,7 @@ func NewService( digiCertService: digiCertService, androidModule: androidService, estService: estService, + pssoNonceStore: pssoNonceStore, } // Override methods that can't be easily overriden via diff --git a/server/fleet/apple_psso.go b/server/fleet/apple_psso.go index e87f2c2f684..862bff093ed 100644 --- a/server/fleet/apple_psso.go +++ b/server/fleet/apple_psso.go @@ -56,56 +56,40 @@ type PSSOClaims struct { // PSSOIdPClient validates a username/password pair against the upstream IdP // and returns OIDC-shaped claims on success. The shipped implementation is a // generic OIDC ROPG client (Okta-first, also tested against Entra and other -// providers); a deterministic test stub is also provided. +// providers). type PSSOIdPClient interface { ValidatePasswordAndGetClaims(ctx context.Context, username, password string) (*PSSOClaims, error) } // PSSONonceStore is a short-lived store for the nonces issued by the PSSO -// /nonce endpoint and consumed in registration and token flows. The Redis +// nonce endpoint and consumed (single-use) on every token request. The Redis // implementation lives in server/mdm/psso/internal/redis_nonces_store. type PSSONonceStore interface { Store(ctx context.Context, nonce string, ttl time.Duration) error Consume(ctx context.Context, nonce string) (ok bool, err error) } -// PSSORegisterRequest carries the device-key enrollment the Mac extension -// POSTs to /mdm/apple/psso/register. In Password mode this is a pure key -// registration: the extension generates Secure Enclave signing + encryption -// keypairs and submits the public halves plus their kids and the hardware -// device UUID. There is no OAuth code/state — user identity is established -// later at each password login. Field names match the form keys the extension -// constructs (signPubKey/encPubKey carry the PEM-encoded public keys). -type PSSORegisterRequest struct { - DeviceUUID string `json:"deviceUUID" form:"deviceUUID"` - DeviceSigningKey string `json:"deviceSigningKey" form:"signPubKey"` - DeviceEncryptionKey string `json:"deviceEncryptionKey" form:"encPubKey"` - SignKeyID string `json:"signKeyID" form:"signKeyID"` - EncKeyID string `json:"encKeyID" form:"encKeyID"` +// PSSODeviceRegistrationRequest carries the device-key enrollment the Mac +// extension POSTs to the PSSO registration endpoint. In Password mode this is +// a pure key registration: the extension generates Secure Enclave signing + +// encryption keypairs and submits the public halves plus their kids and the +// hardware device UUID. User identity is established later, at each password +// login on the token endpoint. +type PSSODeviceRegistrationRequest struct { + DeviceUUID string `json:"device_uuid"` + DeviceSigningKey string `json:"device_signing_key"` + DeviceEncryptionKey string `json:"device_encryption_key"` + SigningKeyID string `json:"signing_key_id"` + EncryptionKeyID string `json:"encryption_key_id"` } -// PSSOSettings holds the global Apple Platform SSO configuration: which -// extension to bind to, what upstream OIDC IdP to proxy password validation -// to, and Fleet's own issuer URL. -// -// IdP-side fields are generic OAuth2/OIDC — Fleet just needs the authorize -// and token URLs plus client credentials. The POC has been exercised against -// Okta first; Entra ID, Google, and any other OIDC provider that exposes -// ROPG (grant_type=password) on its token endpoint should work with no code -// changes, only different URLs. -// -// TODO: IdPClientSecret needs masking on API responses before this leaves -// the POC stage — model the existing AppConfig secret-masking pattern. +// PSSOSettings holds the global Apple Platform SSO configuration. IdP-side fields +// are generic OAuth2/OIDC — Fleet just needs the token URL plus client credentials. type PSSOSettings struct { // Enabled toggles the PSSO endpoints on/off at the service layer. Enabled bool `json:"enabled"` // IssuerURL is the Fleet base URL the extension talks to (e.g. https://fleet.example.com). IssuerURL string `json:"issuer_url"` - // IdPAuthorizeURL is the upstream OIDC authorize endpoint used during - // device registration (browser auth code flow). - // Okta example: https://dev-12345.okta.com/oauth2/default/v1/authorize - // Entra example: https://login.microsoftonline.com//oauth2/v2.0/authorize - IdPAuthorizeURL string `json:"idp_authorize_url"` // IdPTokenURL is the upstream OIDC token endpoint used for the // ROPG (grant_type=password) flow at sign-in. // Okta example: https://dev-12345.okta.com/oauth2/default/v1/token diff --git a/server/fleet/mdm.go b/server/fleet/mdm.go index 2b586ed25f0..69607c37764 100644 --- a/server/fleet/mdm.go +++ b/server/fleet/mdm.go @@ -1040,7 +1040,7 @@ const ( // MDMAssetVPPProxyBearerToken is the bearer token Fleet uses to communicate with the fleetdm.com VPP metadata proxy MDMAssetVPPProxyBearerToken MDMAssetName = "vpp_proxy_bearer_token" //nolint:gosec // no, this is not a credential // MDMAssetPSSOSigningKey is the EC P-256 private key Fleet uses to sign Platform SSO responses - // and publishes via /.well-known/jwks.json for the Mac extension to verify. + // and publishes via the PSSO JWKS endpoint for the Mac extension to verify. MDMAssetPSSOSigningKey MDMAssetName = "psso_signing_key" //nolint:gosec // private key, not a credential string ) diff --git a/server/fleet/service.go b/server/fleet/service.go index b3562c21c4a..179d15b1fe3 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -1546,15 +1546,12 @@ type Service interface { /////////////////////////////////////////////////////////////////////////////// // Apple Platform SSO (PSSO) - // PSSONonce issues a fresh nonce for the Mac extension to embed in - // subsequent registration/token JWTs. + // PSSONonce issues a fresh single-use nonce for the Mac extension to + // embed in subsequent token-request JWTs. PSSONonce(ctx context.Context) (string, error) - // PSSORegisterBegin returns the redirect URL the Mac extension's WebView - // should follow to start the upstream IdP's OAuth code flow. - PSSORegisterBegin(ctx context.Context) (string, error) - // PSSORegisterComplete consumes the code returned by the upstream IdP, - // validates the device-key payload, and persists the registration. - PSSORegisterComplete(ctx context.Context, req PSSORegisterRequest) error + // PSSORegisterDevice validates the device-key payload POSTed by the Mac + // extension and persists the registration. + PSSORegisterDevice(ctx context.Context, req PSSODeviceRegistrationRequest) error // PSSOToken handles the per-sign-in protocol message: parses the inbound // signed JWT, dispatches on grant_type (password login) or request_type // (key_request / key_exchange), and returns the JWE response body. diff --git a/server/mock/service/service_mock.go b/server/mock/service/service_mock.go index 9289808fb51..7e8ec2a9cd4 100644 --- a/server/mock/service/service_mock.go +++ b/server/mock/service/service_mock.go @@ -942,9 +942,7 @@ type UnenrollMDMFunc func(ctx context.Context, hostID uint) error type PSSONonceFunc func(ctx context.Context) (string, error) -type PSSORegisterBeginFunc func(ctx context.Context) (string, error) - -type PSSORegisterCompleteFunc func(ctx context.Context, req fleet.PSSORegisterRequest) error +type PSSORegisterDeviceFunc func(ctx context.Context, req fleet.PSSODeviceRegistrationRequest) error type PSSOTokenFunc func(ctx context.Context, jwtBytes []byte) ([]byte, error) @@ -2336,11 +2334,8 @@ type Service struct { PSSONonceFunc PSSONonceFunc PSSONonceFuncInvoked bool - PSSORegisterBeginFunc PSSORegisterBeginFunc - PSSORegisterBeginFuncInvoked bool - - PSSORegisterCompleteFunc PSSORegisterCompleteFunc - PSSORegisterCompleteFuncInvoked bool + PSSORegisterDeviceFunc PSSORegisterDeviceFunc + PSSORegisterDeviceFuncInvoked bool PSSOTokenFunc PSSOTokenFunc PSSOTokenFuncInvoked bool @@ -5581,18 +5576,11 @@ func (s *Service) PSSONonce(ctx context.Context) (string, error) { return s.PSSONonceFunc(ctx) } -func (s *Service) PSSORegisterBegin(ctx context.Context) (string, error) { - s.mu.Lock() - s.PSSORegisterBeginFuncInvoked = true - s.mu.Unlock() - return s.PSSORegisterBeginFunc(ctx) -} - -func (s *Service) PSSORegisterComplete(ctx context.Context, req fleet.PSSORegisterRequest) error { +func (s *Service) PSSORegisterDevice(ctx context.Context, req fleet.PSSODeviceRegistrationRequest) error { s.mu.Lock() - s.PSSORegisterCompleteFuncInvoked = true + s.PSSORegisterDeviceFuncInvoked = true s.mu.Unlock() - return s.PSSORegisterCompleteFunc(ctx, req) + return s.PSSORegisterDeviceFunc(ctx, req) } func (s *Service) PSSOToken(ctx context.Context, jwtBytes []byte) ([]byte, error) { diff --git a/server/service/apple_psso.go b/server/service/apple_psso.go index 7a4f4130406..2b6b2c4017f 100644 --- a/server/service/apple_psso.go +++ b/server/service/apple_psso.go @@ -2,162 +2,186 @@ package service import ( "context" - "encoding/json" + "crypto/x509" + "io" "log/slog" "net/http" + "net/url" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" + "github.com/fleetdm/fleet/v4/server/contexts/logging" "github.com/fleetdm/fleet/v4/server/fleet" ) -// HTTP paths for the Apple Platform SSO IdP endpoints. These follow the SCEP -// path convention (no /api/ or /v1/ prefix) because they're raw protocol -// endpoints the Mac extension talks to directly, not user-facing API. The -// paths are registered on the root *http.ServeMux (see registerPSSO) rather -// than the versioned /api router, so they resolve exactly as written here. +// HTTP paths for the Apple Platform SSO endpoints. All but the AASA path live +// under /api/mdm/apple/psso and are registered on the unauthenticated +// endpointer (see handler.go); auth is protocol-level (signed JWTs verified +// against registered device keys). The AASA document must be served at the +// /.well-known path(Apple requirement), so it stays on the root *http.ServeMux +// (see registerPSSO). const ( - pssoNoncePath = "/mdm/apple/psso/nonce" - pssoRegisterPath = "/mdm/apple/psso/register" - pssoTokenPath = "/mdm/apple/psso/token" - pssoJWKSPath = "/.well-known/jwks.json" - pssoAASAPath = "/.well-known/apple-app-site-association" + pssoNoncePath = "/api/mdm/apple/psso/nonce" + pssoRegistrationPath = "/api/mdm/apple/psso/registration" + pssoTokenPath = "/api/mdm/apple/psso/token" + pssoJWKSPath = "/api/mdm/apple/psso/jwks" + pssoAASAPath = "/.well-known/apple-app-site-association" ) // pssoContentTypeLoginResponse is the Content-Type Apple's PSSO framework // expects on token endpoint responses. const pssoContentTypeLoginResponse = "application/platformsso-login-response+jwt" -// pssoHandlers returns the set of root-mux PSSO handlers keyed by path. The -// caller is responsible for registering them on a *http.ServeMux. Each handler -// dispatches on r.Method internally since *http.ServeMux does not support -// method-based routing. -func pssoHandlers(svc fleet.Service, logger *slog.Logger) map[string]http.Handler { - return map[string]http.Handler{ - pssoNoncePath: pssoNonceHandler(svc, logger), - pssoRegisterPath: pssoRegisterHandler(svc, logger), - pssoTokenPath: pssoTokenHandler(svc, logger), - pssoJWKSPath: pssoJWKSHandler(svc, logger), - pssoAASAPath: pssoAASAHandler(svc, logger), +//////////////////////////////////////////////////////////////////////////////// +// POST /api/mdm/apple/psso/nonce +//////////////////////////////////////////////////////////////////////////////// + +type pssoNonceRequest struct{} + +type pssoNonceResponse struct { + // Nonce is PascalCase on the wire: Apple's AppSSOAgent consumes this + // response directly and expects the capitalized key. + Nonce string `json:"Nonce"` + Err error `json:"error,omitempty"` +} + +func (r pssoNonceResponse) Error() error { return r.Err } + +func pssoNonceEndpoint(ctx context.Context, _ interface{}, svc fleet.Service) (fleet.Errorer, error) { + nonce, err := svc.PSSONonce(ctx) + if err != nil { + return pssoNonceResponse{Err: err}, nil } + return pssoNonceResponse{Nonce: nonce}, nil } -// pssoNonceHandler serves POST /mdm/apple/psso/nonce — returns a short-lived -// JSON nonce the device includes in subsequent register/token requests. -func pssoNonceHandler(svc fleet.Service, _ *slog.Logger) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - if r.Method != http.MethodPost && r.Method != http.MethodGet { - encodeError(ctx, &fleet.BadRequestError{Message: "method not allowed"}, w) - return - } - nonce, err := svc.PSSONonce(ctx) - if err != nil { - encodeError(ctx, err, w) - return - } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]string{"Nonce": nonce}) - }) +//////////////////////////////////////////////////////////////////////////////// +// POST /api/mdm/apple/psso/registration +//////////////////////////////////////////////////////////////////////////////// + +type pssoRegistrationRequest struct { + fleet.PSSODeviceRegistrationRequest } -// pssoRegisterHandler serves both halves of the registration handshake on -// /mdm/apple/psso/register. GET returns a 302 to the configured upstream OIDC -// authorize URL; POST receives the device's signing/encryption keys and the -// authorization code from the IdP callback. -func pssoRegisterHandler(svc fleet.Service, _ *slog.Logger) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - switch r.Method { - case http.MethodGet: - redirectURL, err := svc.PSSORegisterBegin(ctx) - if err != nil { - encodeError(ctx, err, w) - return - } - http.Redirect(w, r, redirectURL, http.StatusFound) - case http.MethodPost: - if err := r.ParseForm(); err != nil { - encodeError(ctx, ctxerr.Wrap(ctx, err, "parse psso register form"), w) - return - } - req := fleet.PSSORegisterRequest{ - DeviceUUID: r.FormValue("deviceUUID"), - DeviceSigningKey: r.FormValue("signPubKey"), - DeviceEncryptionKey: r.FormValue("encPubKey"), - SignKeyID: r.FormValue("signKeyID"), - EncKeyID: r.FormValue("encKeyID"), - } - if err := svc.PSSORegisterComplete(ctx, req); err != nil { - encodeError(ctx, err, w) - return - } - w.WriteHeader(http.StatusNoContent) - default: - encodeError(ctx, &fleet.BadRequestError{Message: "method not allowed"}, w) - } - }) +// DecodeBody parses the urlencoded form the extension POSTs. The reader is +// already capped by the endpointer's request body size limit. +func (req *pssoRegistrationRequest) DecodeBody(ctx context.Context, r io.Reader, _ url.Values, _ []*x509.Certificate) error { + form, err := parseURLEncodedForm(ctx, r) + if err != nil { + return err + } + req.DeviceUUID = form.Get("device_uuid") + req.DeviceSigningKey = form.Get("device_signing_key") + req.DeviceEncryptionKey = form.Get("device_encryption_key") + req.SigningKeyID = form.Get("signing_key_id") + req.EncryptionKeyID = form.Get("encryption_key_id") + return nil } -// pssoTokenHandler serves POST /mdm/apple/psso/token — receives a compact JWS -// from the extension and returns a JWE wrapped with the PSSO-specific -// Content-Type Apple's framework expects. -func pssoTokenHandler(svc fleet.Service, _ *slog.Logger) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - if r.Method != http.MethodPost { - encodeError(ctx, &fleet.BadRequestError{Message: "method not allowed"}, w) - return - } - // Apple sends the login request as an OAuth jwt-bearer grant: a - // urlencoded form whose `assertion` field holds the compact JWS. The - // JWT must be extracted from the form, not parsed from the raw body. - if err := r.ParseForm(); err != nil { - encodeError(ctx, ctxerr.Wrap(ctx, err, "parse psso token form"), w) - return - } - assertion := r.FormValue("assertion") - if assertion == "" { - encodeError(ctx, &fleet.BadRequestError{Message: "psso token: missing assertion"}, w) - return - } - out, err := svc.PSSOToken(ctx, []byte(assertion)) - if err != nil { - encodeError(ctx, err, w) - return - } - w.Header().Set("Content-Type", pssoContentTypeLoginResponse) - w.Header().Set("X-Content-Type-Options", "nosniff") - _, _ = w.Write(out) - }) +type pssoRegistrationResponse struct { + Err error `json:"error,omitempty"` } -// pssoJWKSHandler serves GET /.well-known/jwks.json — exposes Fleet's PSSO -// server signing key as a JWKS so the device extension can verify server JWTs. -func pssoJWKSHandler(svc fleet.Service, _ *slog.Logger) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - if r.Method != http.MethodGet { - encodeError(ctx, &fleet.BadRequestError{Message: "method not allowed"}, w) - return - } - body, err := svc.PSSOJWKS(ctx) - if err != nil { - encodeError(ctx, err, w) - return - } - w.Header().Set("Content-Type", "application/jwk-set+json") - _, _ = w.Write(body) - }) +func (r pssoRegistrationResponse) Error() error { return r.Err } + +func (r pssoRegistrationResponse) Status() int { return http.StatusNoContent } + +func pssoRegistrationEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) { + req := request.(*pssoRegistrationRequest) + if err := svc.PSSORegisterDevice(ctx, req.PSSODeviceRegistrationRequest); err != nil { + return pssoRegistrationResponse{Err: err}, nil + } + return pssoRegistrationResponse{}, nil +} + +//////////////////////////////////////////////////////////////////////////////// +// POST /api/mdm/apple/psso/token +//////////////////////////////////////////////////////////////////////////////// + +type pssoTokenRequest struct { + Assertion string +} + +// DecodeBody parses the OAuth jwt-bearer-style urlencoded form whose +// `assertion` field holds the compact JWS signed by the device. The JWT must +// be extracted from the form, not read from the raw body. +func (req *pssoTokenRequest) DecodeBody(ctx context.Context, r io.Reader, _ url.Values, _ []*x509.Certificate) error { + form, err := parseURLEncodedForm(ctx, r) + if err != nil { + return err + } + req.Assertion = form.Get("assertion") + if req.Assertion == "" { + return &fleet.BadRequestError{Message: "psso token: missing assertion"} + } + return nil +} + +type pssoTokenResponse struct { + Err error `json:"error,omitempty"` + jwe []byte +} + +func (r pssoTokenResponse) Error() error { return r.Err } + +func (r pssoTokenResponse) HijackRender(ctx context.Context, w http.ResponseWriter) { + w.Header().Set("Content-Type", pssoContentTypeLoginResponse) + w.Header().Set("X-Content-Type-Options", "nosniff") + if n, err := w.Write(r.jwe); err != nil { + logging.WithExtras(ctx, "err", err, "written", n) + } +} + +func pssoTokenEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) { + req := request.(*pssoTokenRequest) + out, err := svc.PSSOToken(ctx, []byte(req.Assertion)) + if err != nil { + return pssoTokenResponse{Err: err}, nil + } + return pssoTokenResponse{jwe: out}, nil } -// pssoAASAHandler serves GET /.well-known/apple-app-site-association — the -// Apple App Site Association JSON the extension fetches at install time to -// validate the `authsrv:` entitlement against this hostname. +//////////////////////////////////////////////////////////////////////////////// +// GET /api/mdm/apple/psso/jwks +//////////////////////////////////////////////////////////////////////////////// + +type pssoJWKSRequest struct{} + +type pssoJWKSResponse struct { + Err error `json:"error,omitempty"` + body []byte +} + +func (r pssoJWKSResponse) Error() error { return r.Err } + +func (r pssoJWKSResponse) HijackRender(ctx context.Context, w http.ResponseWriter) { + w.Header().Set("Content-Type", "application/jwk-set+json") + if n, err := w.Write(r.body); err != nil { + logging.WithExtras(ctx, "err", err, "written", n) + } +} + +func pssoJWKSEndpoint(ctx context.Context, _ interface{}, svc fleet.Service) (fleet.Errorer, error) { + body, err := svc.PSSOJWKS(ctx) + if err != nil { + return pssoJWKSResponse{Err: err}, nil + } + return pssoJWKSResponse{body: body}, nil +} + +//////////////////////////////////////////////////////////////////////////////// +// GET /.well-known/apple-app-site-association +//////////////////////////////////////////////////////////////////////////////// + +// pssoAASAHandler serves the Apple App Site Association JSON Apple's CDN +// fetches to validate the extension's `authsrv:` entitlement against this +// hostname. It stays a raw root-mux handler because the path is fixed by +// Apple's spec and can't live under /api. func pssoAASAHandler(svc fleet.Service, _ *slog.Logger) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() if r.Method != http.MethodGet && r.Method != http.MethodHead { - encodeError(ctx, &fleet.BadRequestError{Message: "method not allowed"}, w) + w.Header().Set("Allow", "GET, HEAD") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } body, err := svc.PSSOAASA(ctx) @@ -173,6 +197,20 @@ func pssoAASAHandler(svc fleet.Service, _ *slog.Logger) http.Handler { }) } +// parseURLEncodedForm reads an x-www-form-urlencoded body from an +// already-size-limited reader. +func parseURLEncodedForm(ctx context.Context, r io.Reader) (url.Values, error) { + raw, err := io.ReadAll(r) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "read psso form body") + } + form, err := url.ParseQuery(string(raw)) + if err != nil { + return nil, &fleet.BadRequestError{Message: "invalid urlencoded form body", InternalErr: err} + } + return form, nil +} + // ----- core-side service-method stubs -------------------------------------- // // All PSSO business logic lives in ee/server/service. The core stubs below @@ -186,13 +224,7 @@ func (svc *Service) PSSONonce(ctx context.Context) (string, error) { return "", fleet.ErrMissingLicense } -func (svc *Service) PSSORegisterBegin(ctx context.Context) (string, error) { - // skipauth: Implementation returns only the license error; nothing to authorize. - svc.authz.SkipAuthorization(ctx) - return "", fleet.ErrMissingLicense -} - -func (svc *Service) PSSORegisterComplete(ctx context.Context, _ fleet.PSSORegisterRequest) error { +func (svc *Service) PSSORegisterDevice(ctx context.Context, _ fleet.PSSODeviceRegistrationRequest) error { // skipauth: Implementation returns only the license error; nothing to authorize. svc.authz.SkipAuthorization(ctx) return fleet.ErrMissingLicense diff --git a/server/service/apple_psso_test.go b/server/service/apple_psso_test.go new file mode 100644 index 00000000000..b1eac1b2e52 --- /dev/null +++ b/server/service/apple_psso_test.go @@ -0,0 +1,60 @@ +package service + +import ( + "net/url" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestPSSORegistrationRequestDecodeBody(t *testing.T) { + pem := "-----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", pem) + form.Set("device_encryption_key", pem) + 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, pem, req.DeviceSigningKey) + require.Equal(t, pem, 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) + }) +} diff --git a/server/service/handler.go b/server/service/handler.go index 6e46d8e9d92..ff3c26cb7b6 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -1077,6 +1077,18 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC // This is the account-driven enrollment endpoint for BYoD Apple devices, also known as User Enrollment. neAppleMDM.POST(apple_mdm.AccountDrivenEnrollPath, mdmAppleAccountEnrollEndpoint, mdmAppleAccountEnrollRequest{}) + + // Apple Platform SSO (PSSO) endpoints, used by Fleet's Platform SSO + // extension. Unauthenticated at the HTTP layer: the token endpoint + // authenticates protocol-level via JWTs signed with registered device + // keys, and all endpoints are gated in the service layer on the feature + // being configured. Request bodies are capped at the endpointer's default + // size limit. The related /.well-known/apple-app-site-association document + // is served from the root mux (see registerPSSO). + ne.POST(pssoNoncePath, pssoNonceEndpoint, pssoNonceRequest{}) + ne.POST(pssoRegistrationPath, pssoRegistrationEndpoint, pssoRegistrationRequest{}) + ne.POST(pssoTokenPath, pssoTokenEndpoint, pssoTokenRequest{}) + ne.GET(pssoJWKSPath, pssoJWKSEndpoint, pssoJWKSRequest{}) // This is for OAUTH2 token based auth // ne.POST(apple_mdm.EnrollPath+"/token", mdmAppleAccountEnrollTokenEndpoint, mdmAppleAccountEnrollTokenRequest{}) @@ -1337,16 +1349,13 @@ func registerPSSO( logger *slog.Logger, fleetConfig config.FleetConfig, ) error { - // Apple Platform SSO (PSSO) endpoints. Paths follow the SCEP convention - // (no /api/ prefix, no version). The Mac extension talks to these directly; - // auth is via signed JWTs verified inside the token handler. We register - // directly on the root *http.ServeMux (not the versioned /api router) so - // the paths Apple's framework expects (`/.well-known/...`, raw protocol - // paths) resolve without any prefix rewriting. + // Only the apple-app-site-association document is served from the root + // *http.ServeMux: Apple's CDN fetches it at a spec-defined /.well-known + // path that can't live under /api. The rest of the PSSO endpoints are + // registered on the unauthenticated endpointer in attachFleetAPIRoutes. pssoLogger := logger.With("component", "mdm-apple-psso") - for path, handler := range pssoHandlers(svc, pssoLogger) { - mux.Handle(path, otel.WrapHandler(handler, path, fleetConfig)) - } + handler := pssoAASAHandler(svc, pssoLogger) + mux.Handle(pssoAASAPath, otel.WrapHandler(handler, pssoAASAPath, fleetConfig)) return nil } diff --git a/server/service/svctest/service.go b/server/service/svctest/service.go index 4be4f5e6f7b..35acea1f587 100644 --- a/server/service/svctest/service.go +++ b/server/service/svctest/service.go @@ -258,6 +258,7 @@ func newTestServiceWithConfig(t *testing.T, ds fleet.Datastore, fleetConfig conf digiCertService, androidModule, estCAService, + nil, // PSSO nonce store; integration tests don't exercise PSSO ) if err != nil { panic(err) diff --git a/server/service/testing_utils_test.go b/server/service/testing_utils_test.go index 2e4471369ab..4beb5758eed 100644 --- a/server/service/testing_utils_test.go +++ b/server/service/testing_utils_test.go @@ -295,6 +295,7 @@ func newTestServiceWithConfig(t *testing.T, ds fleet.Datastore, fleetConfig conf digiCertService, androidModule, estCAService, + nil, // PSSO nonce store; integration tests don't exercise PSSO ) if err != nil { panic(err) From 9051e35de3eb44a0b0aa8f13d270bfd0afb4827e Mon Sep 17 00:00:00 2001 From: Jordan Montgomery Date: Mon, 15 Jun 2026 15:25:28 -0400 Subject: [PATCH 05/28] Lint + fixes from testing --- .../fleet-sso-extension-example.mobileconfig | 2 -- cmd/fleet/serve.go | 2 +- ee/server/service/apple_psso_test.go | 2 +- server/service/apple_psso.go | 19 ++++++++++++++----- server/service/apple_psso_test.go | 16 ++++++++++++++++ 5 files changed, 32 insertions(+), 9 deletions(-) diff --git a/apple-sso-extension/fleet-sso-extension-example.mobileconfig b/apple-sso-extension/fleet-sso-extension-example.mobileconfig index dd2d20b5dc2..4ae19258846 100644 --- a/apple-sso-extension/fleet-sso-extension-example.mobileconfig +++ b/apple-sso-extension/fleet-sso-extension-example.mobileconfig @@ -7,8 +7,6 @@ ExtensionData - BaseURL https://fleet.example.com diff --git a/cmd/fleet/serve.go b/cmd/fleet/serve.go index 01ca3c79384..5f3451dec58 100644 --- a/cmd/fleet/serve.go +++ b/cmd/fleet/serve.go @@ -65,7 +65,6 @@ import ( "github.com/fleetdm/fleet/v4/server/launcher" "github.com/fleetdm/fleet/v4/server/live_query" "github.com/fleetdm/fleet/v4/server/mdm/acme" - "github.com/fleetdm/fleet/v4/server/mdm/psso" acme_api "github.com/fleetdm/fleet/v4/server/mdm/acme/api" acme_bootstrap "github.com/fleetdm/fleet/v4/server/mdm/acme/bootstrap" android_service "github.com/fleetdm/fleet/v4/server/mdm/android/service" @@ -75,6 +74,7 @@ import ( "github.com/fleetdm/fleet/v4/server/mdm/cryptoutil" microsoft_mdm "github.com/fleetdm/fleet/v4/server/mdm/microsoft" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/push" + "github.com/fleetdm/fleet/v4/server/mdm/psso" scepdepot "github.com/fleetdm/fleet/v4/server/mdm/scep/depot" "github.com/fleetdm/fleet/v4/server/platform/endpointer" platform_http "github.com/fleetdm/fleet/v4/server/platform/http" diff --git a/ee/server/service/apple_psso_test.go b/ee/server/service/apple_psso_test.go index ca450b4ab76..b7471e86640 100644 --- a/ee/server/service/apple_psso_test.go +++ b/ee/server/service/apple_psso_test.go @@ -35,7 +35,7 @@ func configuredPSSOSettings() *fleet.PSSOSettings { IssuerURL: "https://fleet.example.com", IdPTokenURL: "https://idp.example.com/oauth2/v1/token", IdPClientID: "client-id", - IdPClientSecret: "client-secret", + IdPClientSecret: "client-secret", //nolint:gosec // G101: test value only } } diff --git a/server/service/apple_psso.go b/server/service/apple_psso.go index 2b6b2c4017f..469bc39936b 100644 --- a/server/service/apple_psso.go +++ b/server/service/apple_psso.go @@ -22,7 +22,7 @@ import ( const ( pssoNoncePath = "/api/mdm/apple/psso/nonce" pssoRegistrationPath = "/api/mdm/apple/psso/registration" - pssoTokenPath = "/api/mdm/apple/psso/token" + pssoTokenPath = "/api/mdm/apple/psso/token" //nolint:gosec // G101 false positive, this is a URL path pssoJWKSPath = "/api/mdm/apple/psso/jwks" pssoAASAPath = "/.well-known/apple-app-site-association" ) @@ -37,6 +37,15 @@ const pssoContentTypeLoginResponse = "application/platformsso-login-response+jwt type pssoNonceRequest struct{} +// DecodeBody ignores the request body. Apple's AppSSOAgent POSTs a urlencoded +// grant_type=srv_challenge form to the nonce endpoint, but Fleet needs nothing +// from it — it just mints a nonce. The method must exist so the endpoint +// framework routes the form body here instead of falling through to JSON +// decoding, which rejects the form as malformed. +func (pssoNonceRequest) DecodeBody(context.Context, io.Reader, url.Values, []*x509.Certificate) error { + return nil +} + type pssoNonceResponse struct { // Nonce is PascalCase on the wire: Apple's AppSSOAgent consumes this // response directly and expects the capitalized key. @@ -46,7 +55,7 @@ type pssoNonceResponse struct { func (r pssoNonceResponse) Error() error { return r.Err } -func pssoNonceEndpoint(ctx context.Context, _ interface{}, svc fleet.Service) (fleet.Errorer, error) { +func pssoNonceEndpoint(ctx context.Context, _ any, svc fleet.Service) (fleet.Errorer, error) { nonce, err := svc.PSSONonce(ctx) if err != nil { return pssoNonceResponse{Err: err}, nil @@ -85,7 +94,7 @@ func (r pssoRegistrationResponse) Error() error { return r.Err } func (r pssoRegistrationResponse) Status() int { return http.StatusNoContent } -func pssoRegistrationEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) { +func pssoRegistrationEndpoint(ctx context.Context, request any, svc fleet.Service) (fleet.Errorer, error) { req := request.(*pssoRegistrationRequest) if err := svc.PSSORegisterDevice(ctx, req.PSSODeviceRegistrationRequest); err != nil { return pssoRegistrationResponse{Err: err}, nil @@ -131,7 +140,7 @@ func (r pssoTokenResponse) HijackRender(ctx context.Context, w http.ResponseWrit } } -func pssoTokenEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) { +func pssoTokenEndpoint(ctx context.Context, request any, svc fleet.Service) (fleet.Errorer, error) { req := request.(*pssoTokenRequest) out, err := svc.PSSOToken(ctx, []byte(req.Assertion)) if err != nil { @@ -160,7 +169,7 @@ func (r pssoJWKSResponse) HijackRender(ctx context.Context, w http.ResponseWrite } } -func pssoJWKSEndpoint(ctx context.Context, _ interface{}, svc fleet.Service) (fleet.Errorer, error) { +func pssoJWKSEndpoint(ctx context.Context, _ any, svc fleet.Service) (fleet.Errorer, error) { body, err := svc.PSSOJWKS(ctx) if err != nil { return pssoJWKSResponse{Err: err}, nil diff --git a/server/service/apple_psso_test.go b/server/service/apple_psso_test.go index b1eac1b2e52..cd6fd290081 100644 --- a/server/service/apple_psso_test.go +++ b/server/service/apple_psso_test.go @@ -1,6 +1,7 @@ package service import ( + "net/http/httptest" "net/url" "strings" "testing" @@ -8,6 +9,21 @@ import ( "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) { pem := "-----BEGIN PUBLIC KEY-----\nMFkw+abc/def=\n-----END PUBLIC KEY-----" form := url.Values{} From 084bb1ff631ac283ce532858c4a316c962984aa2 Mon Sep 17 00:00:00 2001 From: Jordan Montgomery Date: Mon, 15 Jun 2026 15:57:10 -0400 Subject: [PATCH 06/28] Fix lint --- ee/server/service/apple_psso_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ee/server/service/apple_psso_test.go b/ee/server/service/apple_psso_test.go index b7471e86640..d2409347605 100644 --- a/ee/server/service/apple_psso_test.go +++ b/ee/server/service/apple_psso_test.go @@ -30,12 +30,12 @@ func newPSSOTestService(t *testing.T, settings *fleet.PSSOSettings) (*Service, c } func configuredPSSOSettings() *fleet.PSSOSettings { - return &fleet.PSSOSettings{ + return &fleet.PSSOSettings{ //nolint:gosec // G101: test value only, not a real credential Enabled: true, IssuerURL: "https://fleet.example.com", IdPTokenURL: "https://idp.example.com/oauth2/v1/token", IdPClientID: "client-id", - IdPClientSecret: "client-secret", //nolint:gosec // G101: test value only + IdPClientSecret: "client-secret", } } From 1b543686a2d2c82ad73a7a2f3025210f9443f5ea Mon Sep 17 00:00:00 2001 From: Jordan Montgomery Date: Tue, 16 Jun 2026 10:36:32 -0400 Subject: [PATCH 07/28] Initial work to refactor cofnig --- cmd/fleetctl/fleetctl/generate_gitops.go | 16 ++ cmd/fleetctl/fleetctl/generate_gitops_test.go | 40 +++++ ee/server/service/apple_psso.go | 80 +++++++-- ee/server/service/apple_psso_test.go | 86 ++++++--- pkg/spec/gitops.go | 13 +- pkg/spec/gitops_test.go | 44 +++++ server/fleet/app.go | 22 +-- server/fleet/apple_psso.go | 58 ++++-- server/fleet/mdm.go | 4 + server/service/appconfig.go | 78 ++++++++ server/service/appconfig_test.go | 166 ++++++++++++++++++ server/service/client.go | 7 + 12 files changed, 554 insertions(+), 60 deletions(-) diff --git a/cmd/fleetctl/fleetctl/generate_gitops.go b/cmd/fleetctl/fleetctl/generate_gitops.go index 0dc465280c8..75f0b9d5c04 100644 --- a/cmd/fleetctl/fleetctl/generate_gitops.go +++ b/cmd/fleetctl/fleetctl/generate_gitops.go @@ -1402,6 +1402,22 @@ func (cmd *GenerateGitopsCommand) generateControls(teamId *uint, teamName string result[jsonFieldName(mdmT, "WindowsEntraClientIDs")] = cmd.AppConfig.MDM.WindowsEntraClientIDs.Value } result[jsonFieldName(mdmT, "AppleRequireHardwareAttestation")] = cmd.AppConfig.MDM.AppleRequireHardwareAttestation + + // apple_account_provisioning is a global-only MDM setting. The IdP + // client secret is masked/non-exportable from the API, so emit a TODO + // for the user to fill in (mirrors the other secret placeholders). + if aap := cmd.AppConfig.MDM.AppleAccountProvisioning; aap.Configured() { + aapT := reflect.TypeFor[fleet.AppleAccountProvisioning]() + controlsFile := "default.yml" + if teamId != nil { + controlsFile = "fleets/" + teamName + ".yml" + } + result[jsonFieldName(mdmT, "AppleAccountProvisioning")] = map[string]any{ + jsonFieldName(aapT, "OAuthIdPTokenURL"): aap.OAuthIdPTokenURL.Value, + jsonFieldName(aapT, "OAuthIdPClientID"): aap.OAuthIdPClientID.Value, + jsonFieldName(aapT, "OAuthIdPClientSecret"): cmd.AddComment(controlsFile, "TODO: Add your IdP client secret here"), + } + } } if cmd.AppConfig.MDM.WindowsEnabledAndConfigured { result["windows_enabled_and_configured"] = cmd.AppConfig.MDM.WindowsEnabledAndConfigured diff --git a/cmd/fleetctl/fleetctl/generate_gitops_test.go b/cmd/fleetctl/fleetctl/generate_gitops_test.go index 7da1f712963..7e95eee7bae 100644 --- a/cmd/fleetctl/fleetctl/generate_gitops_test.go +++ b/cmd/fleetctl/fleetctl/generate_gitops_test.go @@ -1631,6 +1631,46 @@ func TestGenerateControls(t *testing.T) { verifyControlsHasMacosSetup(t, controlsRaw) } +func TestGenerateGitopsAppleAccountProvisioning(t *testing.T) { + fleetClient := &MockClient{} + appConfig, err := fleetClient.GetAppConfig() + require.NoError(t, err) + appConfig.MDM.AppleAccountProvisioning = fleet.AppleAccountProvisioning{ + OAuthIdPTokenURL: optjson.SetString("https://idp.example.com/oauth2/v1/token"), + OAuthIdPClientID: optjson.SetString("client-id"), + } + + cmd := &GenerateGitopsCommand{ + Client: fleetClient, + CLI: cli.NewContext(&cli.App{}, nil, nil), + Messages: Messages{}, + FilesToWrite: make(map[string]any), + AppConfig: appConfig, + } + + controls, err := cmd.generateControls(nil, "", &fleet.TeamMDM{}) + require.NoError(t, err) + + aap, ok := controls["apple_account_provisioning"].(map[string]any) + require.True(t, ok, "apple_account_provisioning should be emitted for global controls") + require.Equal(t, "https://idp.example.com/oauth2/v1/token", aap["oauth_idp_token_url"]) + require.Equal(t, "client-id", aap["oauth_idp_client_id"]) + + // The secret is masked/non-exportable, so it's emitted as a TODO comment + // registered against default.yml rather than the real value. + secretToken, ok := aap["oauth_idp_client_secret"].(string) + require.True(t, ok) + var found bool + for _, c := range cmd.Comments { + if c.Token == secretToken { + require.Equal(t, "default.yml", c.Filename) + require.Contains(t, c.Comment, "TODO") + found = true + } + } + require.True(t, found, "the client secret should be emitted as a registered TODO comment") +} + func TestGenerateSoftware(t *testing.T) { configureFMAManifestServer(t) // Get the test app config. diff --git a/ee/server/service/apple_psso.go b/ee/server/service/apple_psso.go index 1ad0092ce8f..cf37ada0949 100644 --- a/ee/server/service/apple_psso.go +++ b/ee/server/service/apple_psso.go @@ -35,7 +35,7 @@ import ( // unauthenticated GET triggers a write + KMS roundtrip if the key doesn't // exist yet. Acceptable for POC but worth revisiting before GA. Alternatives // to consider: -// - mint when an admin enables PSSO via AppConfig.PSSOSettings.Enabled = true +// - mint when an admin first configures AppConfig.MDM.AppleAccountProvisioning // - mint on the first device registration request // - explicit `fleetctl psso bootstrap` step type pssoServiceState struct { @@ -167,20 +167,72 @@ func isAssetNotFound(err error) bool { return errors.Is(err, sql.ErrNoRows) } -// pssoSettingsIfConfigured returns the PSSO settings from the current -// AppConfig when the feature is enabled and carries everything the flows -// need; otherwise it returns nil. Read per request so enabling, disabling, -// or repointing the IdP takes effect without a server restart. -func (svc *Service) pssoSettingsIfConfigured(ctx context.Context) (*fleet.PSSOSettings, error) { +// loadSecret / skipSecret are readable arguments for pssoSettingsIfConfigured's +// loadSecret parameter. +const ( + loadSecret = true + skipSecret = false +) + +// pssoSettingsIfConfigured resolves the Platform SSO settings for the current +// request, returning nil when the feature isn't configured. The public IdP +// fields come from AppConfig.MDM.AppleAccountProvisioning and the issuer is the +// Fleet server URL. The client secret lives in mdm_config_assets (a separate, +// uncached read + decrypt); only the token flow needs it, so pass skipSecret +// from the endpoints that don't (nonce, registration, JWKS, AASA) to avoid the +// extra read. Read per request so configuring, clearing, or repointing the IdP +// takes effect without a server restart. +func (svc *Service) pssoSettingsIfConfigured(ctx context.Context, loadSecret bool) (*fleet.PSSOSettings, error) { cfg, err := svc.ds.AppConfig(ctx) if err != nil { return nil, ctxerr.Wrap(ctx, err, "load app config for psso") } - s := cfg.PSSOSettings - if s == nil || !s.Enabled || s.IssuerURL == "" || s.IdPTokenURL == "" || s.IdPClientID == "" || s.IdPClientSecret == "" { + aap := cfg.MDM.AppleAccountProvisioning + if !aap.Configured() || cfg.ServerSettings.ServerURL == "" { return nil, nil } - return s, nil + + settings := &fleet.PSSOSettings{ + IssuerURL: cfg.ServerSettings.ServerURL, + IdPTokenURL: aap.OAuthIdPTokenURL.Value, + IdPClientID: aap.OAuthIdPClientID.Value, + } + + if loadSecret { + secret, err := svc.pssoIdPClientSecret(ctx) + if err != nil { + return nil, err + } + if secret == "" { + // Public config is present but the secret asset is missing: treat the + // feature as not configured rather than attempting the ROPG flow with + // empty credentials. + return nil, nil + } + settings.IdPClientSecret = secret + } + + return settings, nil +} + +// pssoIdPClientSecret returns the stored OAuth IdP client secret for the macOS +// account provisioning feature, or "" if none is stored. +func (svc *Service) pssoIdPClientSecret(ctx context.Context) (string, error) { + assets, err := svc.ds.GetAllMDMConfigAssetsByName(ctx, + []fleet.MDMAssetName{fleet.MDMAssetAppleAccountProvisioningIdPClientSecret}, + nil, + ) + if err != nil { + if isAssetNotFound(err) { + return "", nil + } + return "", ctxerr.Wrap(ctx, err, "get psso idp client secret asset") + } + asset, ok := assets[fleet.MDMAssetAppleAccountProvisioningIdPClientSecret] + if !ok || len(asset.Value) == 0 { + return "", nil + } + return string(asset.Value), nil } // errPSSONotConfigured is returned from the device-facing endpoints when the @@ -202,7 +254,7 @@ func (svc *Service) PSSONonce(ctx context.Context) (string, error) { // before any user identity is established. svc.authz.SkipAuthorization(ctx) - settings, err := svc.pssoSettingsIfConfigured(ctx) + settings, err := svc.pssoSettingsIfConfigured(ctx, skipSecret) if err != nil { return "", err } @@ -261,7 +313,7 @@ func (svc *Service) PSSORegisterDevice(ctx context.Context, req fleet.PSSODevice // key registered here, verified against the kid. svc.authz.SkipAuthorization(ctx) - settings, err := svc.pssoSettingsIfConfigured(ctx) + settings, err := svc.pssoSettingsIfConfigured(ctx, skipSecret) if err != nil { return err } @@ -323,7 +375,7 @@ func (svc *Service) PSSOToken(ctx context.Context, jwtBytes []byte) ([]byte, err // JWT signature against a known device signing pubkey is the auth. svc.authz.SkipAuthorization(ctx) - settings, err := svc.pssoSettingsIfConfigured(ctx) + settings, err := svc.pssoSettingsIfConfigured(ctx, loadSecret) if err != nil { return nil, err } @@ -664,7 +716,7 @@ func (svc *Service) PSSOJWKS(ctx context.Context) ([]byte, error) { // signing public key — there is no caller identity to authorize. svc.authz.SkipAuthorization(ctx) - settings, err := svc.pssoSettingsIfConfigured(ctx) + settings, err := svc.pssoSettingsIfConfigured(ctx, skipSecret) if err != nil { return nil, err } @@ -708,7 +760,7 @@ func (svc *Service) PSSOAASA(ctx context.Context) ([]byte, error) { // framework fetches it anonymously to validate the extension binding. svc.authz.SkipAuthorization(ctx) - settings, err := svc.pssoSettingsIfConfigured(ctx) + settings, err := svc.pssoSettingsIfConfigured(ctx, skipSecret) if err != nil { return nil, err } diff --git a/ee/server/service/apple_psso_test.go b/ee/server/service/apple_psso_test.go index d2409347605..ff8f4d46710 100644 --- a/ee/server/service/apple_psso_test.go +++ b/ee/server/service/apple_psso_test.go @@ -6,18 +6,56 @@ import ( "testing" "time" + "github.com/fleetdm/fleet/v4/pkg/optjson" "github.com/fleetdm/fleet/v4/server/authz" authz_ctx "github.com/fleetdm/fleet/v4/server/contexts/authz" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/mock" + "github.com/jmoiron/sqlx" "github.com/stretchr/testify/require" ) -func newPSSOTestService(t *testing.T, settings *fleet.PSSOSettings) (*Service, context.Context) { +// pssoTestConfig describes the resolved configuration the PSSO flows read: +// public IdP fields + Fleet server URL from AppConfig, and the IdP client +// secret from mdm_config_assets. The feature is "configured" only when all of +// them are present. +type pssoTestConfig struct { + serverURL string + tokenURL string + clientID string + secret string // empty => no stored secret asset +} + +func configuredPSSOTestConfig() pssoTestConfig { + return pssoTestConfig{ //nolint:gosec // G101: test value only, not a real credential + serverURL: "https://fleet.example.com", + tokenURL: "https://idp.example.com/oauth2/v1/token", + clientID: "client-id", + secret: "client-secret", + } +} + +func newPSSOTestService(t *testing.T, cfg pssoTestConfig) (*Service, context.Context) { t.Helper() ds := new(mock.Store) ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { - return &fleet.AppConfig{PSSOSettings: settings}, nil + ac := &fleet.AppConfig{} + ac.ServerSettings.ServerURL = cfg.serverURL + ac.MDM.AppleAccountProvisioning = fleet.AppleAccountProvisioning{ + OAuthIdPTokenURL: optjson.SetString(cfg.tokenURL), + OAuthIdPClientID: optjson.SetString(cfg.clientID), + } + return ac, nil + } + ds.GetAllMDMConfigAssetsByNameFunc = func(_ context.Context, names []fleet.MDMAssetName, _ sqlx.QueryerContext) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { + out := map[fleet.MDMAssetName]fleet.MDMConfigAsset{} + if cfg.secret != "" { + out[fleet.MDMAssetAppleAccountProvisioningIdPClientSecret] = fleet.MDMConfigAsset{ + Name: fleet.MDMAssetAppleAccountProvisioningIdPClientSecret, + Value: []byte(cfg.secret), + } + } + return out, nil } auth, err := authz.NewAuthorizer() require.NoError(t, err) @@ -29,16 +67,6 @@ func newPSSOTestService(t *testing.T, settings *fleet.PSSOSettings) (*Service, c }, ctx } -func configuredPSSOSettings() *fleet.PSSOSettings { - return &fleet.PSSOSettings{ //nolint:gosec // G101: test value only, not a real credential - Enabled: true, - IssuerURL: "https://fleet.example.com", - IdPTokenURL: "https://idp.example.com/oauth2/v1/token", - IdPClientID: "client-id", - IdPClientSecret: "client-secret", - } -} - // memNonceStore is a minimal in-memory fleet.PSSONonceStore. type memNonceStore struct { nonces map[string]struct{} @@ -61,18 +89,18 @@ func (s *memNonceStore) Consume(_ context.Context, nonce string) (bool, error) { } func TestPSSO_EndpointsGatedOnConfiguration(t *testing.T) { - notConfigured := []*fleet.PSSOSettings{ - nil, + configured := configuredPSSOTestConfig() + // When the public config is incomplete the feature is off for every + // endpoint, determined without reading the client secret. + notConfigured := []pssoTestConfig{ {}, - func() *fleet.PSSOSettings { s := configuredPSSOSettings(); s.Enabled = false; return s }(), - func() *fleet.PSSOSettings { s := configuredPSSOSettings(); s.IssuerURL = ""; return s }(), - func() *fleet.PSSOSettings { s := configuredPSSOSettings(); s.IdPTokenURL = ""; return s }(), - func() *fleet.PSSOSettings { s := configuredPSSOSettings(); s.IdPClientID = ""; return s }(), - func() *fleet.PSSOSettings { s := configuredPSSOSettings(); s.IdPClientSecret = ""; return s }(), + func() pssoTestConfig { c := configured; c.serverURL = ""; return c }(), + func() pssoTestConfig { c := configured; c.tokenURL = ""; return c }(), + func() pssoTestConfig { c := configured; c.clientID = ""; return c }(), } - for _, settings := range notConfigured { - svc, ctx := newPSSOTestService(t, settings) + for _, cfg := range notConfigured { + svc, ctx := newPSSOTestService(t, cfg) // Device-facing endpoints return a 400. _, err := svc.PSSONonce(ctx) @@ -88,16 +116,30 @@ func TestPSSO_EndpointsGatedOnConfiguration(t *testing.T) { _, err = svc.PSSOAASA(ctx) require.True(t, fleet.IsNotFound(err), "aasa should be 404, got %v", err) } + + // The client secret is only required by the token (password login) flow, so + // a missing secret gates that endpoint alone — the others don't read it. + t.Run("token gated when secret missing", func(t *testing.T) { + cfg := configured + cfg.secret = "" + svc, ctx := newPSSOTestService(t, cfg) + _, err := svc.PSSOToken(ctx, []byte("ignored")) + require.ErrorIs(t, err, errPSSONotConfigured) + }) } func TestPSSO_NonceIssuedAndConsumedWhenConfigured(t *testing.T) { - svc, ctx := newPSSOTestService(t, configuredPSSOSettings()) + svc, ctx := newPSSOTestService(t, configuredPSSOTestConfig()) svc.pssoNonceStore = &memNonceStore{} nonce, err := svc.PSSONonce(ctx) require.NoError(t, err) require.NotEmpty(t, nonce) + // The nonce flow doesn't need the IdP client secret, so it must not pay the + // mdm_config_assets read. + require.False(t, svc.ds.(*mock.Store).GetAllMDMConfigAssetsByNameFuncInvoked) + // First consume succeeds, replay is rejected. require.NoError(t, svc.consumePSSORequestNonce(ctx, nonce)) err = svc.consumePSSORequestNonce(ctx, nonce) diff --git a/pkg/spec/gitops.go b/pkg/spec/gitops.go index b837440107f..2a7ea621875 100644 --- a/pkg/spec/gitops.go +++ b/pkg/spec/gitops.go @@ -178,6 +178,8 @@ type GitOpsControls struct { MacOSSetup *fleet.MacOSSetup `json:"macos_setup" renameto:"setup_experience"` MacOSMigration any `json:"macos_migration"` + AppleAccountProvisioning *fleet.AppleAccountProvisioning `json:"apple_account_provisioning"` + WindowsUpdates any `json:"windows_updates"` WindowsSettings any `json:"windows_settings"` WindowsEnabledAndConfigured any `json:"windows_enabled_and_configured"` @@ -207,7 +209,8 @@ func (c GitOpsControls) Set() bool { c.WindowsMigrationEnabled != nil || c.EnableDiskEncryption != nil || c.EnableRecoveryLockPassword != nil || len(c.Scripts) > 0 || c.AndroidEnabledAndConfigured != nil || c.AndroidSettings != nil || c.AppleRequireHardwareAttestation != nil || c.EnableTurnOnWindowsMDMManually != nil || - c.WindowsEntraTenantIDs != nil || c.WindowsEntraClientIDs != nil || c.RequireBitLockerPIN != nil + c.WindowsEntraTenantIDs != nil || c.WindowsEntraClientIDs != nil || c.RequireBitLockerPIN != nil || + c.AppleAccountProvisioning != nil } type Policy struct { @@ -1045,6 +1048,14 @@ func parseControls(top map[string]json.RawMessage, result *GitOps, logFn Logf, y // Validate unknown keys in controls section. multiError = multierror.Append(multiError, validateRawKeys(controlsRaw, reflect.TypeFor[GitOpsControls](), yamlFilename, []string{"controls"})...) controlsTop.Defined = true + + // apple_account_provisioning is a global-only MDM setting (it maps to global + // AppConfig.MDM). Reject it in a specific team's file; it belongs in the + // global configuration (or the no-team/unassigned file). + if controlsTop.AppleAccountProvisioning != nil && !result.global() && !result.IsNoTeam() && !result.IsUnassignedTeam() { + multiError = multierror.Append(multiError, fmt.Errorf( + "%s: apple_account_provisioning can only be configured in the global configuration, not for a specific team", yamlFilename)) + } controlsFilePath := yamlFilename multiError = multierror.Append(multiError, processControlsPathIfNeeded(controlsTop, result, &controlsFilePath)...) diff --git a/pkg/spec/gitops_test.go b/pkg/spec/gitops_test.go index d4c53a2c3a2..c45d9328603 100644 --- a/pkg/spec/gitops_test.go +++ b/pkg/spec/gitops_test.go @@ -3042,6 +3042,50 @@ func TestGitOpsOSUpdatesProfileConflict(t *testing.T) { }) } +func TestGitOpsAppleAccountProvisioning(t *testing.T) { + t.Parallel() + + const aapControls = ` +controls: + apple_account_provisioning: + oauth_idp_token_url: https://idp.example.com/oauth2/v1/token + oauth_idp_client_id: client-id + oauth_idp_client_secret: super-secret +` + + t.Run("parsed in global config", func(t *testing.T) { + t.Parallel() + config := getGlobalConfig([]string{"controls"}) + aapControls + path, basePath := createTempFile(t, "", config) + gitops, err := GitOpsFromFile(path, basePath, premiumAppConfig(), nopLogf) + require.NoError(t, err) + require.NotNil(t, gitops.Controls.AppleAccountProvisioning) + aap := gitops.Controls.AppleAccountProvisioning + assert.Equal(t, "https://idp.example.com/oauth2/v1/token", aap.OAuthIdPTokenURL.Value) + assert.Equal(t, "client-id", aap.OAuthIdPClientID.Value) + assert.Equal(t, "super-secret", aap.OAuthIdPClientSecret.Value) + assert.True(t, gitops.Controls.Set()) + }) + + t.Run("nil when omitted", func(t *testing.T) { + t.Parallel() + config := getGlobalConfig(nil) + path, basePath := createTempFile(t, "", config) + gitops, err := GitOpsFromFile(path, basePath, premiumAppConfig(), nopLogf) + require.NoError(t, err) + assert.Nil(t, gitops.Controls.AppleAccountProvisioning) + }) + + t.Run("rejected in a specific team's file", func(t *testing.T) { + t.Parallel() + config := getTeamConfig([]string{"controls"}) + aapControls + path, basePath := createTempFile(t, "", config) + _, err := GitOpsFromFile(path, basePath, premiumAppConfig(), nopLogf) + require.Error(t, err) + assert.Contains(t, err.Error(), "apple_account_provisioning can only be configured in the global configuration") + }) +} + func TestUnknownKeyDetection(t *testing.T) { t.Parallel() diff --git a/server/fleet/app.go b/server/fleet/app.go index 5475139a4ef..bd0208a4d5c 100644 --- a/server/fleet/app.go +++ b/server/fleet/app.go @@ -270,6 +270,11 @@ type MDM struct { AndroidEnabledAndConfigured bool `json:"android_enabled_and_configured"` AndroidSettings AndroidSettings `json:"android_settings"` + // AppleAccountProvisioning holds the macOS local account provisioning / + // Platform SSO password sync configuration. The IdP client secret is stored + // in mdm_config_assets, not in this JSON; only the masked value is returned. + AppleAccountProvisioning AppleAccountProvisioning `json:"apple_account_provisioning"` + ///////////////////////////////////////////////////////////////// // WARNING: If you add to this struct make sure it's taken into // account in the AppConfig Clone implementation! @@ -748,12 +753,6 @@ type AppConfig struct { // This field is a pointer to avoid returning this information to non-global-admins. SSOSettings *SSOSettings `json:"sso_settings,omitempty"` - // PSSOSettings holds the global Apple Platform SSO configuration. - // - // This field is a pointer to keep it omitted from API responses when unset - // and to avoid returning Entra credentials to non-global-admins. - PSSOSettings *PSSOSettings `json:"psso_settings,omitempty"` - // FleetDesktop holds settings for Fleet Desktop that can be changed via the API. FleetDesktop FleetDesktopSettings `json:"fleet_desktop"` @@ -806,6 +805,13 @@ func (c *AppConfig) Obfuscate() { for _, gcIntegration := range c.Integrations.GoogleCalendar { gcIntegration.ApiKey.SetMasked() } + // The Apple account provisioning IdP client secret lives in + // mdm_config_assets, never in the AppConfig JSON. Surface the masked value + // whenever the feature is configured (token URL present implies a stored + // secret), so the API never leaks it but still signals it's set. + if c.MDM.AppleAccountProvisioning.Configured() { + c.MDM.AppleAccountProvisioning.OAuthIdPClientSecret = optjson.SetString(MaskedPassword) + } // // TODO(hca): confirm that we're properly masking credentials in the new endpoints // if c.Integrations.NDESSCEPProxy.Valid { // c.Integrations.NDESSCEPProxy.Value.Password = MaskedPassword @@ -861,10 +867,6 @@ func (c *AppConfig) Copy() *AppConfig { clone.AgentOptions = &ao } - if c.PSSOSettings != nil { - pssoSettings := *c.PSSOSettings - clone.PSSOSettings = &pssoSettings - } if c.SSOSettings != nil { ssoSettings := *c.SSOSettings clone.SSOSettings = &ssoSettings diff --git a/server/fleet/apple_psso.go b/server/fleet/apple_psso.go index 862bff093ed..d1190a71b9e 100644 --- a/server/fleet/apple_psso.go +++ b/server/fleet/apple_psso.go @@ -3,6 +3,8 @@ package fleet import ( "context" "time" + + "github.com/fleetdm/fleet/v4/pkg/optjson" ) // PSSODevice marks a Mac host as Apple Platform SSO-registered. It carries no @@ -83,24 +85,54 @@ type PSSODeviceRegistrationRequest struct { EncryptionKeyID string `json:"encryption_key_id"` } -// PSSOSettings holds the global Apple Platform SSO configuration. IdP-side fields -// are generic OAuth2/OIDC — Fleet just needs the token URL plus client credentials. +// AppleAccountProvisioning is the macOS local account provisioning / Platform +// SSO password sync configuration stored on AppConfig.MDM. The IdP fields are +// generic OAuth2 ROPG credentials (the oauth_ prefix leaves room for other +// auth methods, e.g. LDAP, later). +// +// The client secret is never persisted in the AppConfig JSON: on write it's +// stripped out and stored encrypted in mdm_config_assets, and the API only +// returns the masked value. token URL + client ID are stored in the JSON. +type AppleAccountProvisioning struct { + // OAuthIdPTokenURL is the upstream OIDC token endpoint used for the ROPG + // (grant_type=password) flow at sign-in. + // Okta example: https://dev-12345.okta.com/oauth2/default/v1/token + // Entra example: https://login.microsoftonline.com//oauth2/v2.0/token + OAuthIdPTokenURL optjson.String `json:"oauth_idp_token_url"` + // OAuthIdPClientID is the client/application ID registered with the upstream IdP. + OAuthIdPClientID optjson.String `json:"oauth_idp_client_id"` + // OAuthIdPClientSecret is the client secret registered with the upstream IdP. + // Stored in mdm_config_assets, not here; this field carries the masked value + // in API responses and the caller-supplied value on writes. + OAuthIdPClientSecret optjson.String `json:"oauth_idp_client_secret"` +} + +// Configured reports whether the public IdP fields required to operate the +// feature are present. The client secret lives in mdm_config_assets and is not +// part of this check; the write path guarantees a stored secret whenever these +// are set. +func (a AppleAccountProvisioning) Configured() bool { + return a.OAuthIdPTokenURL.Value != "" && a.OAuthIdPClientID.Value != "" +} + +// PSSOSettings is the resolved Platform SSO configuration the service flows +// operate on. It is assembled per request from AppConfig (public IdP fields +// plus the Fleet server URL) and mdm_config_assets (the client secret); it is +// not stored or serialized on its own. type PSSOSettings struct { - // Enabled toggles the PSSO endpoints on/off at the service layer. - Enabled bool `json:"enabled"` - // IssuerURL is the Fleet base URL the extension talks to (e.g. https://fleet.example.com). - IssuerURL string `json:"issuer_url"` + // IssuerURL is Fleet's own base URL (server_settings.server_url), used as the + // token issuer and to build the AASA/JWKS URLs. + IssuerURL string // IdPTokenURL is the upstream OIDC token endpoint used for the // ROPG (grant_type=password) flow at sign-in. - // Okta example: https://dev-12345.okta.com/oauth2/default/v1/token - // Entra example: https://login.microsoftonline.com//oauth2/v2.0/token - IdPTokenURL string `json:"idp_token_url"` + IdPTokenURL string // IdPClientID is the client/application ID registered with the upstream IdP. - IdPClientID string `json:"idp_client_id"` - // IdPClientSecret is the client secret registered with the upstream IdP. - IdPClientSecret string `json:"idp_client_secret"` + IdPClientID string + // IdPClientSecret is the client secret registered with the upstream IdP, + // loaded from mdm_config_assets. + IdPClientSecret string // IdPScopes is the space-separated scope string sent on both the // authorize and token requests. Defaults to "openid profile email" when // empty. - IdPScopes string `json:"idp_scopes"` + IdPScopes string } diff --git a/server/fleet/mdm.go b/server/fleet/mdm.go index ff52bb71be6..8335514d12d 100644 --- a/server/fleet/mdm.go +++ b/server/fleet/mdm.go @@ -1042,6 +1042,10 @@ const ( // MDMAssetPSSOSigningKey is the EC P-256 private key Fleet uses to sign Platform SSO responses // and publishes via the PSSO JWKS endpoint for the Mac extension to verify. MDMAssetPSSOSigningKey MDMAssetName = "psso_signing_key" //nolint:gosec // private key, not a credential string + // MDMAssetAppleAccountProvisioningIdPClientSecret is the OAuth ROPG IdP client + // secret for the macOS account provisioning / Platform SSO feature. Stored + // here (encrypted) rather than in the AppConfig JSON so the API never returns it. + MDMAssetAppleAccountProvisioningIdPClientSecret MDMAssetName = "apple_account_provisioning_idp_client_secret" //nolint:gosec // stored credential, name is not itself a secret ) type MDMConfigAsset struct { diff --git a/server/service/appconfig.go b/server/service/appconfig.go index 744fb41fa44..18753997e7e 100644 --- a/server/service/appconfig.go +++ b/server/service/appconfig.go @@ -474,6 +474,33 @@ func applyAndValidateConditionalAccessOktaFields( return nil } +// persistAppleAccountProvisioningSecret stores, preserves, or soft-deletes the +// Apple account provisioning IdP client secret in mdm_config_assets so it +// matches the (already-validated) incoming config. The secret is only +// soft-deleted when the feature is cleared or the secret genuinely changes: +// - configured + a new secret provided: insert, or soft-delete + insert when +// the value differs (no-op when unchanged). +// - configured + no new secret: preserve the existing secret. +// - feature cleared (was configured, now isn't): soft-delete the secret. +func (svc *Service) persistAppleAccountProvisioningSecret(ctx context.Context, configured, wasConfigured, newSecretProvided bool, secret string) error { + switch { + case configured && newSecretProvided: + if err := svc.ds.InsertOrReplaceMDMConfigAsset(ctx, fleet.MDMConfigAsset{ + Name: fleet.MDMAssetAppleAccountProvisioningIdPClientSecret, + Value: []byte(secret), + }); err != nil { + return ctxerr.Wrap(ctx, err, "store apple account provisioning idp client secret") + } + case !configured && wasConfigured: + if err := svc.ds.DeleteMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{ + fleet.MDMAssetAppleAccountProvisioningIdPClientSecret, + }); err != nil { + return ctxerr.Wrap(ctx, err, "delete apple account provisioning idp client secret") + } + } + return nil +} + func (svc *Service) ModifyAppConfig(ctx context.Context, p []byte, applyOpts fleet.ApplySpecOptions) (*fleet.AppConfig, error) { if err := svc.authz.Authorize(ctx, &fleet.AppConfig{}, fleet.ActionWrite); err != nil { return nil, err @@ -682,6 +709,53 @@ func (svc *Service) ModifyAppConfig(ctx context.Context, p []byte, applyOpts fle appConfig.MDM.EnableDiskEncryption = oldAppConfig.MDM.EnableDiskEncryption } + // Apple account provisioning (Platform SSO): the IdP client secret is never + // persisted in the AppConfig JSON — it's stored encrypted in + // mdm_config_assets. Capture the caller-supplied secret here, validate, then + // strip it from the config that gets saved. The actual asset + // store/preserve/soft-delete happens further down, after the dry-run guard. + oldAAP := oldAppConfig.MDM.AppleAccountProvisioning + incomingAAP := newAppConfig.MDM.AppleAccountProvisioning + // In Overwrite mode (GitOps) an omitted or empty section must clear the + // feature, so take the incoming section wholesale rather than merging it + // onto the stored one. + if applyOpts.Overwrite { + appConfig.MDM.AppleAccountProvisioning = incomingAAP + } + // A "real" secret is one the caller actually wants stored — not omitted, not + // the masked placeholder echoed back by the API/UI. + incomingSecret := incomingAAP.OAuthIdPClientSecret + newAAPSecretProvided := incomingSecret.Valid && incomingSecret.Value != "" && incomingSecret.Value != fleet.MaskedPassword + newAAPSecret := incomingSecret.Value + + mergedAAP := appConfig.MDM.AppleAccountProvisioning + // Never let the secret reach the saved JSON; masking for responses is applied + // separately in Obfuscate (keyed on the token URL being present). + appConfig.MDM.AppleAccountProvisioning.OAuthIdPClientSecret = optjson.String{} + + if mergedAAP.Configured() { + aapProvided := incomingAAP.OAuthIdPTokenURL.Set || incomingAAP.OAuthIdPClientID.Set || incomingAAP.OAuthIdPClientSecret.Set + if aapProvided && !lic.IsPremium() { + invalid.Append("mdm.apple_account_provisioning", ErrMissingLicense.Error()) + } + if newAAPSecretProvided && svc.config.Server.PrivateKey == "" { + invalid.Append("mdm.apple_account_provisioning", + "Missing required private key. Learn how to configure the private key here: https://fleetdm.com/learn-more-about/fleet-server-private-key") + } + // Require https: the client secret is sent to this endpoint, so plaintext + // http would leak it. + if u, err := url.Parse(mergedAAP.OAuthIdPTokenURL.Value); err != nil || u.Host == "" || u.Scheme != "https" { + invalid.Append("mdm.apple_account_provisioning.oauth_idp_token_url", "must be a valid https URL") + } + // Require a freshly-supplied secret whenever the token URL changes, so a + // caller can't repoint the IdP token endpoint while reusing the stored + // secret (which would leak it to the new, possibly hostile, URL). + if mergedAAP.OAuthIdPTokenURL.Value != oldAAP.OAuthIdPTokenURL.Value && !newAAPSecretProvided { + invalid.Append("mdm.apple_account_provisioning.oauth_idp_client_secret", + "must be provided when changing oauth_idp_token_url") + } + } + // this is to handle the case where `apple_enable_release_device_manually: null` is // passed in the request payload, which should be treated as "not present/not // changed" by the PATCH. We should really try to find a more general way to @@ -989,6 +1063,10 @@ func (svc *Service) ModifyAppConfig(ctx context.Context, p []byte, applyOpts fle appConfig.FleetDesktop.AlternativeBrowserHost = "" } + if err := svc.persistAppleAccountProvisioningSecret(ctx, mergedAAP.Configured(), oldAAP.Configured(), newAAPSecretProvided, newAAPSecret); err != nil { + return nil, err + } + if err := svc.ds.SaveAppConfig(ctx, appConfig); err != nil { return nil, err } diff --git a/server/service/appconfig_test.go b/server/service/appconfig_test.go index 82c8f56b94f..fe8328a6c81 100644 --- a/server/service/appconfig_test.go +++ b/server/service/appconfig_test.go @@ -1669,6 +1669,172 @@ func TestModifyAppConfigWindowsEntraClientIDNormalization(t *testing.T) { require.Equal(t, want, modified.MDM.WindowsEntraClientIDs.Value) } +func TestModifyAppConfigAppleAccountProvisioning(t *testing.T) { + admin := &fleet.User{GlobalRole: new(fleet.RoleAdmin)} + + const ( + tokenURL = "https://idp.example.com/oauth2/v1/token" //nolint:gosec // G101: test URL, not a credential + tokenURL2 = "https://other.example.com/oauth2/v1/token" //nolint:gosec // G101: test URL, not a credential + clientID = "client-id" + secret = "super-secret" //nolint:gosec // G101: test value, not a real credential + ) + + // configuredAAP returns a stored AppConfig section as it looks once the + // feature is configured: public fields present, secret stripped (it lives in + // mdm_config_assets, never in the JSON). + configuredAAP := func() fleet.AppleAccountProvisioning { + return fleet.AppleAccountProvisioning{ + OAuthIdPTokenURL: optjson.SetString(tokenURL), + OAuthIdPClientID: optjson.SetString(clientID), + } + } + + type asserts struct { + insertedSecret *string // non-nil => InsertOrReplace expected with this value + deleted bool // DeleteMDMConfigAssetsByName expected + wantErr string // non-empty => ModifyAppConfig should fail containing this + wantMasked bool // response secret should be the masked placeholder + } + + type trackers struct { + insertedSecret *string + deleted bool + saved *fleet.AppConfig + } + + setup := func(t *testing.T, tier string, stored fleet.AppleAccountProvisioning) (fleet.Service, context.Context, *mock.Store, *trackers) { + ds := new(mock.Store) + cfg := config.TestConfig() + cfg.Server.PrivateKey = "test-private-key-not-used-by-mock" + svc, ctx := newTestServiceWithConfig(t, ds, cfg, nil, nil, &TestServerOpts{License: &fleet.LicenseInfo{Tier: tier}}) + ctx = viewer.NewContext(ctx, viewer.Viewer{User: admin}) + + tr := &trackers{} + dsAppConfig := &fleet.AppConfig{ + OrgInfo: fleet.OrgInfo{OrgName: "Test"}, + ServerSettings: fleet.ServerSettings{ServerURL: "https://example.org"}, + MDM: fleet.MDM{AppleAccountProvisioning: stored}, + } + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { return dsAppConfig, nil } + ds.SaveAppConfigFunc = func(ctx context.Context, conf *fleet.AppConfig) error { + // Snapshot at save time: the mock hands back the same pointer that + // ModifyAppConfig mutates (and later obfuscates) in place, unlike a + // real DB read which returns a fresh copy. + tr.saved = conf.Copy() + *dsAppConfig = *conf + return nil + } + ds.ListABMTokensFunc = func(ctx context.Context) ([]*fleet.ABMToken, error) { return []*fleet.ABMToken{}, nil } + ds.ListVPPTokensFunc = func(ctx context.Context) ([]*fleet.VPPTokenDB, error) { return []*fleet.VPPTokenDB{}, nil } + + ds.InsertOrReplaceMDMConfigAssetFunc = func(ctx context.Context, asset fleet.MDMConfigAsset) error { + require.Equal(t, fleet.MDMAssetAppleAccountProvisioningIdPClientSecret, asset.Name) + v := string(asset.Value) + tr.insertedSecret = &v + return nil + } + ds.DeleteMDMConfigAssetsByNameFunc = func(ctx context.Context, names []fleet.MDMAssetName) error { + require.Equal(t, []fleet.MDMAssetName{fleet.MDMAssetAppleAccountProvisioningIdPClientSecret}, names) + tr.deleted = true + return nil + } + return svc, ctx, ds, tr + } + + cases := []struct { + name string + tier string + stored fleet.AppleAccountProvisioning + body string + want asserts + }{ + { + name: "configure stores secret and masks response", + tier: fleet.TierPremium, + body: fmt.Sprintf(`{"mdm":{"apple_account_provisioning":{"oauth_idp_token_url":%q,"oauth_idp_client_id":%q,"oauth_idp_client_secret":%q}}}`, tokenURL, clientID, secret), + want: asserts{insertedSecret: new(secret), wantMasked: true}, + }, + { + name: "free tier rejected", + tier: fleet.TierFree, + body: fmt.Sprintf(`{"mdm":{"apple_account_provisioning":{"oauth_idp_token_url":%q,"oauth_idp_client_id":%q,"oauth_idp_client_secret":%q}}}`, tokenURL, clientID, secret), + want: asserts{wantErr: ErrMissingLicense.Error()}, + }, + { + name: "invalid token url rejected", + tier: fleet.TierPremium, + body: fmt.Sprintf(`{"mdm":{"apple_account_provisioning":{"oauth_idp_token_url":"not-a-url","oauth_idp_client_id":%q,"oauth_idp_client_secret":%q}}}`, clientID, secret), + want: asserts{wantErr: "must be a valid https URL"}, + }, + { + name: "http token url rejected", + tier: fleet.TierPremium, + body: fmt.Sprintf(`{"mdm":{"apple_account_provisioning":{"oauth_idp_token_url":"http://idp.example.com/oauth2/v1/token","oauth_idp_client_id":%q,"oauth_idp_client_secret":%q}}}`, clientID, secret), + want: asserts{wantErr: "must be a valid https URL"}, + }, + { + name: "changing token url without new secret rejected", + tier: fleet.TierPremium, + stored: configuredAAP(), + body: fmt.Sprintf(`{"mdm":{"apple_account_provisioning":{"oauth_idp_token_url":%q,"oauth_idp_client_id":%q,"oauth_idp_client_secret":%q}}}`, tokenURL2, clientID, fleet.MaskedPassword), + want: asserts{wantErr: "must be provided when changing oauth_idp_token_url"}, + }, + { + name: "changing token url with new secret replaces", + tier: fleet.TierPremium, + stored: configuredAAP(), + body: fmt.Sprintf(`{"mdm":{"apple_account_provisioning":{"oauth_idp_token_url":%q,"oauth_idp_client_id":%q,"oauth_idp_client_secret":%q}}}`, tokenURL2, clientID, "rotated-secret"), + want: asserts{insertedSecret: new("rotated-secret"), wantMasked: true}, + }, + { + name: "masked secret preserved on unrelated change", + tier: fleet.TierPremium, + stored: configuredAAP(), + body: fmt.Sprintf(`{"mdm":{"apple_account_provisioning":{"oauth_idp_token_url":%q,"oauth_idp_client_id":"new-client-id","oauth_idp_client_secret":%q}}}`, tokenURL, fleet.MaskedPassword), + want: asserts{wantMasked: true}, // neither insert nor delete + }, + { + name: "clearing config soft-deletes secret", + tier: fleet.TierPremium, + stored: configuredAAP(), + body: `{"mdm":{"apple_account_provisioning":{"oauth_idp_token_url":"","oauth_idp_client_id":"","oauth_idp_client_secret":""}}}`, + want: asserts{deleted: true}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + svc, ctx, ds, tr := setup(t, tc.tier, tc.stored) + + modified, err := svc.ModifyAppConfig(ctx, []byte(tc.body), fleet.ApplySpecOptions{}) + if tc.want.wantErr != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tc.want.wantErr) + require.False(t, ds.InsertOrReplaceMDMConfigAssetFuncInvoked) + require.False(t, ds.DeleteMDMConfigAssetsByNameFuncInvoked) + return + } + require.NoError(t, err) + + if tc.want.insertedSecret != nil { + require.NotNil(t, tr.insertedSecret) + require.Equal(t, *tc.want.insertedSecret, *tr.insertedSecret) + } else { + require.False(t, ds.InsertOrReplaceMDMConfigAssetFuncInvoked) + } + require.Equal(t, tc.want.deleted, tr.deleted) + + // The secret must never be persisted in the AppConfig JSON. + require.True(t, ds.SaveAppConfigFuncInvoked) + require.Empty(t, tr.saved.MDM.AppleAccountProvisioning.OAuthIdPClientSecret.Value) + + if tc.want.wantMasked { + require.Equal(t, fleet.MaskedPassword, modified.MDM.AppleAccountProvisioning.OAuthIdPClientSecret.Value) + } + }) + } +} + // TestValidateMDMEndUserAuthScope exercises the GitOps (overwrite) MDM // end-user-auth IdP validation. Strict validation is keyed on the incoming // global/no-team EUA flag only — NOT on stored team state, because diff --git a/server/service/client.go b/server/service/client.go index 2cdbb07f247..c91be3a65f6 100644 --- a/server/service/client.go +++ b/server/service/client.go @@ -2176,6 +2176,13 @@ func (c *Client) DoGitOps( if enable, ok := macOSMigration["enable"]; !ok || enable == nil { macOSMigration["enable"] = false } + // Put in default value for apple_account_provisioning to clear the + // configuration if it's not set in the gitops config. + if incoming.Controls.AppleAccountProvisioning != nil { + mdmAppConfig["apple_account_provisioning"] = incoming.Controls.AppleAccountProvisioning + } else { + mdmAppConfig["apple_account_provisioning"] = map[string]any{} + } // Put in default values for windows_enabled_and_configured mdmAppConfig["windows_enabled_and_configured"] = incoming.Controls.WindowsEnabledAndConfigured if incoming.Controls.WindowsEnabledAndConfigured != nil { From f8d11cdb55ebb315d7111244a50d62b63217807e Mon Sep 17 00:00:00 2001 From: Jordan Montgomery Date: Tue, 16 Jun 2026 10:45:04 -0400 Subject: [PATCH 08/28] Fix review comments --- ee/server/service/apple_psso.go | 8 ++++++-- server/service/apple_psso.go | 15 +++++++++------ 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/ee/server/service/apple_psso.go b/ee/server/service/apple_psso.go index 1ad0092ce8f..b8383cad7cf 100644 --- a/ee/server/service/apple_psso.go +++ b/ee/server/service/apple_psso.go @@ -382,8 +382,12 @@ const pssoDefaultTokenTTL = time.Hour // from the extension's configured hostname — a bare hostname with no scheme — // so the configured IssuerURL is reduced to its host. func pssoIDTokenIssuer(settings *fleet.PSSOSettings) string { - if u, err := url.Parse(settings.IssuerURL); err == nil && u.Host != "" { - return u.Host + // Hostname() (not Host) so a non-default port is dropped: the extension + // derives the issuer from the BaseURL via Swift's URL.host, which excludes + // the port. Returning Host here would mint iss with the port and the device + // would reject the id_token on mismatch. + if u, err := url.Parse(settings.IssuerURL); err == nil && u.Hostname() != "" { + return u.Hostname() } return strings.TrimSuffix(settings.IssuerURL, "/") } diff --git a/server/service/apple_psso.go b/server/service/apple_psso.go index 469bc39936b..ea0126b1ee9 100644 --- a/server/service/apple_psso.go +++ b/server/service/apple_psso.go @@ -37,12 +37,15 @@ const pssoContentTypeLoginResponse = "application/platformsso-login-response+jwt type pssoNonceRequest struct{} -// DecodeBody ignores the request body. Apple's AppSSOAgent POSTs a urlencoded -// grant_type=srv_challenge form to the nonce endpoint, but Fleet needs nothing -// from it — it just mints a nonce. The method must exist so the endpoint -// framework routes the form body here instead of falling through to JSON -// decoding, which rejects the form as malformed. -func (pssoNonceRequest) DecodeBody(context.Context, io.Reader, url.Values, []*x509.Certificate) error { +// DecodeBody drains and discards the request body. Apple's AppSSOAgent POSTs a +// urlencoded grant_type=srv_challenge form to the nonce endpoint, but Fleet +// needs nothing from it — it just mints a nonce. Draining (rather than leaving +// it unread) keeps the connection reusable; the reader is already size-limited +// by the endpointer. The method must exist so the endpoint framework routes the +// form body here instead of falling through to JSON decoding, which rejects the +// form as malformed. +func (pssoNonceRequest) DecodeBody(_ context.Context, r io.Reader, _ url.Values, _ []*x509.Certificate) error { + _, _ = io.Copy(io.Discard, r) return nil } From 499ce6c76625dcbe33a40a686692e9fc65956ffb Mon Sep 17 00:00:00 2001 From: Jordan Montgomery Date: Tue, 16 Jun 2026 16:14:37 -0400 Subject: [PATCH 09/28] Update cloner for PSSO fields --- tools/cloner-check/generated_files/appconfig.txt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tools/cloner-check/generated_files/appconfig.txt b/tools/cloner-check/generated_files/appconfig.txt index 25368aa43d1..0f2dbfca317 100644 --- a/tools/cloner-check/generated_files/appconfig.txt +++ b/tools/cloner-check/generated_files/appconfig.txt @@ -205,6 +205,10 @@ github.com/fleetdm/fleet/v4/server/fleet/CertificateTemplateSpec Name string github.com/fleetdm/fleet/v4/server/fleet/CertificateTemplateSpec CertificateAuthorityName string github.com/fleetdm/fleet/v4/server/fleet/CertificateTemplateSpec SubjectName string github.com/fleetdm/fleet/v4/server/fleet/CertificateTemplateSpec SubjectAlternativeName string +github.com/fleetdm/fleet/v4/server/fleet/MDM AppleAccountProvisioning fleet.AppleAccountProvisioning +github.com/fleetdm/fleet/v4/server/fleet/AppleAccountProvisioning OAuthIdPTokenURL optjson.String +github.com/fleetdm/fleet/v4/server/fleet/AppleAccountProvisioning OAuthIdPClientID optjson.String +github.com/fleetdm/fleet/v4/server/fleet/AppleAccountProvisioning OAuthIdPClientSecret optjson.String github.com/fleetdm/fleet/v4/server/fleet/AppConfig GitOpsConfig fleet.GitOpsConfig github.com/fleetdm/fleet/v4/server/fleet/GitOpsConfig GitopsModeEnabled bool github.com/fleetdm/fleet/v4/server/fleet/GitOpsConfig RepositoryURL string From 23c2c4892ef833ed4f3cb5376db58d29768d5518 Mon Sep 17 00:00:00 2001 From: Jordan Montgomery Date: Tue, 16 Jun 2026 16:15:42 -0400 Subject: [PATCH 10/28] Fix schema --- server/datastore/mysql/schema.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql index 38878f8dfc6..0c8a16ad4ac 100644 --- a/server/datastore/mysql/schema.sql +++ b/server/datastore/mysql/schema.sql @@ -230,7 +230,7 @@ CREATE TABLE `app_config_json` ( PRIMARY KEY (`id`) ) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; -INSERT INTO `app_config_json` VALUES (1,'{\"mdm\": {\"ios_updates\": {\"deadline\": null, \"minimum_version\": null, \"update_new_hosts\": null}, \"macos_setup\": {\"script\": null, \"software\": null, \"bootstrap_package\": null, \"lock_end_user_info\": false, \"manual_agent_install\": null, \"macos_setup_assistant\": null, \"require_all_software_macos\": false, \"end_user_local_account_type\": \"admin\", \"enable_managed_local_account\": false, \"require_all_software_windows\": false, \"enable_end_user_authentication\": false, \"enable_release_device_manually\": false}, \"macos_updates\": {\"deadline\": null, \"minimum_version\": null, \"update_new_hosts\": false}, \"ipados_updates\": {\"deadline\": null, \"minimum_version\": null, \"update_new_hosts\": null}, \"macos_settings\": {\"custom_settings\": null}, \"macos_migration\": {\"mode\": \"\", \"enable\": false, \"webhook_url\": \"\"}, \"windows_updates\": {\"deadline_days\": null, \"grace_period_days\": null}, \"android_settings\": {\"certificates\": null, \"custom_settings\": null}, \"apple_server_url\": \"\", \"windows_settings\": {\"custom_settings\": null}, \"apple_bm_terms_expired\": false, \"apple_business_manager\": null, \"enable_disk_encryption\": false, \"enabled_and_configured\": false, \"end_user_authentication\": {\"idp_name\": \"\", \"metadata\": \"\", \"entity_id\": \"\", \"issuer_uri\": \"\", \"metadata_url\": \"\"}, \"windows_entra_client_ids\": [], \"windows_entra_tenant_ids\": [], \"volume_purchasing_program\": null, \"windows_migration_enabled\": false, \"enable_recovery_lock_password\": false, \"windows_require_bitlocker_pin\": null, \"android_enabled_and_configured\": false, \"windows_enabled_and_configured\": false, \"apple_bm_enabled_and_configured\": false, \"apple_require_hardware_attestation\": false, \"enable_turn_on_windows_mdm_manually\": false}, \"gitops\": {\"exceptions\": {\"labels\": true, \"secrets\": true, \"software\": false}, \"repository_url\": \"\", \"gitops_mode_enabled\": false}, \"scripts\": null, \"features\": {\"historical_data\": {\"uptime\": true, \"vulnerabilities\": true}, \"enable_host_users\": true, \"enable_software_inventory\": false}, \"org_info\": {\"org_name\": \"\", \"contact_url\": \"\", \"org_logo_url\": \"\", \"org_logo_url_dark_mode\": \"\", \"org_logo_url_light_mode\": \"\", \"org_logo_url_light_background\": \"\"}, \"integrations\": {\"jira\": null, \"zendesk\": null, \"google_calendar\": null, \"conditional_access_enabled\": null}, \"sso_settings\": {\"idp_name\": \"\", \"metadata\": \"\", \"entity_id\": \"\", \"enable_sso\": false, \"issuer_uri\": \"\", \"metadata_url\": \"\", \"idp_image_url\": \"\", \"sso_server_url\": \"\", \"enable_jit_role_sync\": false, \"enable_sso_idp_login\": false, \"enable_jit_provisioning\": false}, \"agent_options\": {\"config\": {\"options\": {\"logger_plugin\": \"tls\", \"pack_delimiter\": \"/\", \"logger_tls_period\": 10, \"distributed_plugin\": \"tls\", \"disable_distributed\": false, \"logger_tls_endpoint\": \"/api/osquery/log\", \"distributed_interval\": 10, \"distributed_tls_max_attempts\": 3}, \"decorators\": {\"load\": [\"SELECT uuid AS host_uuid FROM system_info;\", \"SELECT hostname AS hostname FROM system_info;\"]}}, \"overrides\": {}}, \"fleet_desktop\": {\"transparency_url\": \"\", \"alternative_browser_host\": \"\"}, \"smtp_settings\": {\"port\": 587, \"domain\": \"\", \"server\": \"\", \"password\": \"\", \"user_name\": \"\", \"configured\": false, \"enable_smtp\": false, \"enable_ssl_tls\": true, \"sender_address\": \"\", \"enable_start_tls\": true, \"verify_ssl_certs\": true, \"authentication_type\": \"0\", \"authentication_method\": \"0\"}, \"server_settings\": {\"server_url\": \"\", \"enable_analytics\": false, \"query_report_cap\": 0, \"scripts_disabled\": false, \"deferred_save_host\": false, \"live_query_disabled\": false, \"ai_features_disabled\": false, \"query_reports_disabled\": false}, \"webhook_settings\": {\"interval\": \"0s\", \"activities_webhook\": {\"destination_url\": \"\", \"enable_activities_webhook\": false}, \"host_status_webhook\": {\"days_count\": 0, \"destination_url\": \"\", \"host_percentage\": 0, \"enable_host_status_webhook\": false}, \"vulnerabilities_webhook\": {\"destination_url\": \"\", \"host_batch_size\": 0, \"enable_vulnerabilities_webhook\": false}, \"failing_policies_webhook\": {\"policy_ids\": null, \"destination_url\": \"\", \"host_batch_size\": 0, \"enable_failing_policies_webhook\": false}}, \"host_expiry_settings\": {\"host_expiry_window\": 0, \"host_expiry_enabled\": false}, \"vulnerability_settings\": {\"databases_path\": \"\"}, \"activity_expiry_settings\": {\"activity_expiry_window\": 0, \"activity_expiry_enabled\": false, \"preserve_host_activities_on_reenrollment\": false}}','2020-01-01 01:01:01','2020-01-01 01:01:01'); +INSERT INTO `app_config_json` VALUES (1,'{\"mdm\": {\"ios_updates\": {\"deadline\": null, \"minimum_version\": null, \"update_new_hosts\": null}, \"macos_setup\": {\"script\": null, \"software\": null, \"bootstrap_package\": null, \"lock_end_user_info\": false, \"manual_agent_install\": null, \"macos_setup_assistant\": null, \"require_all_software_macos\": false, \"end_user_local_account_type\": \"admin\", \"enable_managed_local_account\": false, \"require_all_software_windows\": false, \"enable_end_user_authentication\": false, \"enable_release_device_manually\": false}, \"macos_updates\": {\"deadline\": null, \"minimum_version\": null, \"update_new_hosts\": false}, \"ipados_updates\": {\"deadline\": null, \"minimum_version\": null, \"update_new_hosts\": null}, \"macos_settings\": {\"custom_settings\": null}, \"macos_migration\": {\"mode\": \"\", \"enable\": false, \"webhook_url\": \"\"}, \"windows_updates\": {\"deadline_days\": null, \"grace_period_days\": null}, \"android_settings\": {\"certificates\": null, \"custom_settings\": null}, \"apple_server_url\": \"\", \"windows_settings\": {\"custom_settings\": null}, \"apple_bm_terms_expired\": false, \"apple_business_manager\": null, \"enable_disk_encryption\": false, \"enabled_and_configured\": false, \"end_user_authentication\": {\"idp_name\": \"\", \"metadata\": \"\", \"entity_id\": \"\", \"issuer_uri\": \"\", \"metadata_url\": \"\"}, \"windows_entra_client_ids\": [], \"windows_entra_tenant_ids\": [], \"volume_purchasing_program\": null, \"windows_migration_enabled\": false, \"apple_account_provisioning\": {\"oauth_idp_client_id\": null, \"oauth_idp_token_url\": null, \"oauth_idp_client_secret\": null}, \"enable_recovery_lock_password\": false, \"windows_require_bitlocker_pin\": null, \"android_enabled_and_configured\": false, \"windows_enabled_and_configured\": false, \"apple_bm_enabled_and_configured\": false, \"apple_require_hardware_attestation\": false, \"enable_turn_on_windows_mdm_manually\": false}, \"gitops\": {\"exceptions\": {\"labels\": true, \"secrets\": true, \"software\": false}, \"repository_url\": \"\", \"gitops_mode_enabled\": false}, \"scripts\": null, \"features\": {\"historical_data\": {\"uptime\": true, \"vulnerabilities\": true}, \"enable_host_users\": true, \"enable_software_inventory\": false}, \"org_info\": {\"org_name\": \"\", \"contact_url\": \"\", \"org_logo_url\": \"\", \"org_logo_url_dark_mode\": \"\", \"org_logo_url_light_mode\": \"\", \"org_logo_url_light_background\": \"\"}, \"integrations\": {\"jira\": null, \"zendesk\": null, \"google_calendar\": null, \"conditional_access_enabled\": null}, \"sso_settings\": {\"idp_name\": \"\", \"metadata\": \"\", \"entity_id\": \"\", \"enable_sso\": false, \"issuer_uri\": \"\", \"metadata_url\": \"\", \"idp_image_url\": \"\", \"sso_server_url\": \"\", \"enable_jit_role_sync\": false, \"enable_sso_idp_login\": false, \"enable_jit_provisioning\": false}, \"agent_options\": {\"config\": {\"options\": {\"logger_plugin\": \"tls\", \"pack_delimiter\": \"/\", \"logger_tls_period\": 10, \"distributed_plugin\": \"tls\", \"disable_distributed\": false, \"logger_tls_endpoint\": \"/api/osquery/log\", \"distributed_interval\": 10, \"distributed_tls_max_attempts\": 3}, \"decorators\": {\"load\": [\"SELECT uuid AS host_uuid FROM system_info;\", \"SELECT hostname AS hostname FROM system_info;\"]}}, \"overrides\": {}}, \"fleet_desktop\": {\"transparency_url\": \"\", \"alternative_browser_host\": \"\"}, \"smtp_settings\": {\"port\": 587, \"domain\": \"\", \"server\": \"\", \"password\": \"\", \"user_name\": \"\", \"configured\": false, \"enable_smtp\": false, \"enable_ssl_tls\": true, \"sender_address\": \"\", \"enable_start_tls\": true, \"verify_ssl_certs\": true, \"authentication_type\": \"0\", \"authentication_method\": \"0\"}, \"server_settings\": {\"server_url\": \"\", \"enable_analytics\": false, \"query_report_cap\": 0, \"scripts_disabled\": false, \"deferred_save_host\": false, \"live_query_disabled\": false, \"ai_features_disabled\": false, \"query_reports_disabled\": false}, \"webhook_settings\": {\"interval\": \"0s\", \"activities_webhook\": {\"destination_url\": \"\", \"enable_activities_webhook\": false}, \"host_status_webhook\": {\"days_count\": 0, \"destination_url\": \"\", \"host_percentage\": 0, \"enable_host_status_webhook\": false}, \"vulnerabilities_webhook\": {\"destination_url\": \"\", \"host_batch_size\": 0, \"enable_vulnerabilities_webhook\": false}, \"failing_policies_webhook\": {\"policy_ids\": null, \"destination_url\": \"\", \"host_batch_size\": 0, \"enable_failing_policies_webhook\": false}}, \"host_expiry_settings\": {\"host_expiry_window\": 0, \"host_expiry_enabled\": false}, \"vulnerability_settings\": {\"databases_path\": \"\"}, \"activity_expiry_settings\": {\"activity_expiry_window\": 0, \"activity_expiry_enabled\": false, \"preserve_host_activities_on_reenrollment\": false}}','2020-01-01 01:01:01','2020-01-01 01:01:01'); /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `batch_activities` ( From 946f145e4512d9c6c6ec8305a01affc3bf5e75a2 Mon Sep 17 00:00:00 2001 From: Jordan Montgomery Date: Wed, 17 Jun 2026 08:53:02 -0400 Subject: [PATCH 11/28] Fix tests --- .../macosSetupExpectedAppConfigEmpty.yml | 4 +++ .../macosSetupExpectedAppConfigSet.yml | 4 +++ server/service/appconfig_test.go | 28 +++++++++++++++++++ 3 files changed, 36 insertions(+) diff --git a/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml b/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml index be26420c29d..0ca8cee582f 100644 --- a/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml +++ b/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml @@ -35,6 +35,10 @@ spec: android_enabled_and_configured: false apple_business: apple_business_manager: + apple_account_provisioning: + oauth_idp_token_url: null + oauth_idp_client_id: null + oauth_idp_client_secret: null apple_server_url: "" volume_purchasing_program: apple_bm_enabled_and_configured: false diff --git a/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml b/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml index f9b8db26d58..a5bcb2b76c1 100644 --- a/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml +++ b/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml @@ -35,6 +35,10 @@ spec: android_enabled_and_configured: false apple_business: apple_business_manager: + apple_account_provisioning: + oauth_idp_token_url: null + oauth_idp_client_id: null + oauth_idp_client_secret: null apple_server_url: "" volume_purchasing_program: apple_bm_enabled_and_configured: false diff --git a/server/service/appconfig_test.go b/server/service/appconfig_test.go index fe8328a6c81..6f165f90c40 100644 --- a/server/service/appconfig_test.go +++ b/server/service/appconfig_test.go @@ -1058,6 +1058,10 @@ func TestMDMConfig(t *testing.T) { name: "nochange", licenseTier: "free", expectedMDM: fleet.MDM{ + AppleAccountProvisioning: fleet.AppleAccountProvisioning{ + OAuthIdPTokenURL: optjson.String{Set: true}, + OAuthIdPClientID: optjson.String{Set: true}, + }, AppleBusinessManager: optjson.Slice[fleet.MDMAppleABMAssignmentInfo]{Set: true, Value: []fleet.MDMAppleABMAssignmentInfo{}}, MacOSSetup: fleet.MacOSSetup{ BootstrapPackage: optjson.String{Set: true}, @@ -1113,6 +1117,10 @@ func TestMDMConfig(t *testing.T) { findTeam: true, newMDM: fleet.MDM{DeprecatedAppleBMDefaultTeam: "foobar"}, expectedMDM: fleet.MDM{ + AppleAccountProvisioning: fleet.AppleAccountProvisioning{ + OAuthIdPTokenURL: optjson.String{Set: true}, + OAuthIdPClientID: optjson.String{Set: true}, + }, AppleBusinessManager: optjson.Slice[fleet.MDMAppleABMAssignmentInfo]{Set: true, Value: []fleet.MDMAppleABMAssignmentInfo{}}, DeprecatedAppleBMDefaultTeam: "foobar", MacOSSetup: fleet.MacOSSetup{ @@ -1151,6 +1159,10 @@ func TestMDMConfig(t *testing.T) { oldMDM: fleet.MDM{DeprecatedAppleBMDefaultTeam: "bar"}, newMDM: fleet.MDM{DeprecatedAppleBMDefaultTeam: "foobar"}, expectedMDM: fleet.MDM{ + AppleAccountProvisioning: fleet.AppleAccountProvisioning{ + OAuthIdPTokenURL: optjson.String{Set: true}, + OAuthIdPClientID: optjson.String{Set: true}, + }, AppleBusinessManager: optjson.Slice[fleet.MDMAppleABMAssignmentInfo]{Set: true, Value: []fleet.MDMAppleABMAssignmentInfo{}}, DeprecatedAppleBMDefaultTeam: "foobar", MacOSSetup: fleet.MacOSSetup{ @@ -1196,6 +1208,10 @@ func TestMDMConfig(t *testing.T) { newMDM: fleet.MDM{EndUserAuthentication: fleet.MDMEndUserAuthentication{SSOProviderSettings: fleet.SSOProviderSettings{EntityID: "foo"}}}, oldMDM: fleet.MDM{EndUserAuthentication: fleet.MDMEndUserAuthentication{SSOProviderSettings: fleet.SSOProviderSettings{EntityID: "foo"}}}, expectedMDM: fleet.MDM{ + AppleAccountProvisioning: fleet.AppleAccountProvisioning{ + OAuthIdPTokenURL: optjson.String{Set: true}, + OAuthIdPClientID: optjson.String{Set: true}, + }, AppleBusinessManager: optjson.Slice[fleet.MDMAppleABMAssignmentInfo]{Set: true, Value: []fleet.MDMAppleABMAssignmentInfo{}}, EndUserAuthentication: fleet.MDMEndUserAuthentication{SSOProviderSettings: fleet.SSOProviderSettings{EntityID: "foo"}}, MacOSSetup: fleet.MacOSSetup{ @@ -1237,6 +1253,10 @@ func TestMDMConfig(t *testing.T) { IDPName: "onelogin", }}}, expectedMDM: fleet.MDM{ + AppleAccountProvisioning: fleet.AppleAccountProvisioning{ + OAuthIdPTokenURL: optjson.String{Set: true}, + OAuthIdPClientID: optjson.String{Set: true}, + }, AppleBusinessManager: optjson.Slice[fleet.MDMAppleABMAssignmentInfo]{Set: true, Value: []fleet.MDMAppleABMAssignmentInfo{}}, EndUserAuthentication: fleet.MDMEndUserAuthentication{SSOProviderSettings: fleet.SSOProviderSettings{ EntityID: "fleet", @@ -1282,6 +1302,10 @@ func TestMDMConfig(t *testing.T) { IDPName: "onelogin", }}}, expectedMDM: fleet.MDM{ + AppleAccountProvisioning: fleet.AppleAccountProvisioning{ + OAuthIdPTokenURL: optjson.String{Set: true}, + OAuthIdPClientID: optjson.String{Set: true}, + }, AppleBusinessManager: optjson.Slice[fleet.MDMAppleABMAssignmentInfo]{Set: true, Value: []fleet.MDMAppleABMAssignmentInfo{}}, EndUserAuthentication: fleet.MDMEndUserAuthentication{SSOProviderSettings: fleet.SSOProviderSettings{ EntityID: "f", @@ -1356,6 +1380,10 @@ func TestMDMConfig(t *testing.T) { EnableDiskEncryption: optjson.SetBool(false), }, expectedMDM: fleet.MDM{ + AppleAccountProvisioning: fleet.AppleAccountProvisioning{ + OAuthIdPTokenURL: optjson.String{Set: true}, + OAuthIdPClientID: optjson.String{Set: true}, + }, AppleBusinessManager: optjson.Slice[fleet.MDMAppleABMAssignmentInfo]{Set: true, Value: []fleet.MDMAppleABMAssignmentInfo{}}, EnableDiskEncryption: optjson.Bool{Set: true, Valid: true, Value: false}, MacOSSetup: fleet.MacOSSetup{ From 240cb8f0fac091af5f1c66f6527bd08bdc7ca55f Mon Sep 17 00:00:00 2001 From: Jordan Montgomery Date: Wed, 17 Jun 2026 11:20:31 -0400 Subject: [PATCH 12/28] Fix issues with getting/setting partials in gitops --- server/service/appconfig.go | 42 +++++++++++-------- server/service/appconfig_test.go | 69 +++++++++++++++++++++++++++++--- 2 files changed, 88 insertions(+), 23 deletions(-) diff --git a/server/service/appconfig.go b/server/service/appconfig.go index 18753997e7e..e678fae2140 100644 --- a/server/service/appconfig.go +++ b/server/service/appconfig.go @@ -712,28 +712,29 @@ func (svc *Service) ModifyAppConfig(ctx context.Context, p []byte, applyOpts fle // Apple account provisioning (Platform SSO): the IdP client secret is never // persisted in the AppConfig JSON — it's stored encrypted in // mdm_config_assets. Capture the caller-supplied secret here, validate, then - // strip it from the config that gets saved. The actual asset - // store/preserve/soft-delete happens further down, after the dry-run guard. + // strip it from the config that gets saved. oldAAP := oldAppConfig.MDM.AppleAccountProvisioning incomingAAP := newAppConfig.MDM.AppleAccountProvisioning - // In Overwrite mode (GitOps) an omitted or empty section must clear the - // feature, so take the incoming section wholesale rather than merging it - // onto the stored one. + if applyOpts.Overwrite { appConfig.MDM.AppleAccountProvisioning = incomingAAP } - // A "real" secret is one the caller actually wants stored — not omitted, not - // the masked placeholder echoed back by the API/UI. + incomingSecret := incomingAAP.OAuthIdPClientSecret newAAPSecretProvided := incomingSecret.Valid && incomingSecret.Value != "" && incomingSecret.Value != fleet.MaskedPassword newAAPSecret := incomingSecret.Value mergedAAP := appConfig.MDM.AppleAccountProvisioning - // Never let the secret reach the saved JSON; masking for responses is applied - // separately in Obfuscate (keyed on the token URL being present). appConfig.MDM.AppleAccountProvisioning.OAuthIdPClientSecret = optjson.String{} - if mergedAAP.Configured() { + // Apple account provisioning is all-or-nothing: the token URL, client ID, and + // client secret are only meaningful together, so the config must have all three + // set or all three empty — never a partial state that reports as "configured" + // but can't run the sign-in flow. + tokenURLSet := mergedAAP.OAuthIdPTokenURL.Value != "" + clientIDSet := mergedAAP.OAuthIdPClientID.Value != "" + switch { + case mergedAAP.Configured(): // both public fields set aapProvided := incomingAAP.OAuthIdPTokenURL.Set || incomingAAP.OAuthIdPClientID.Set || incomingAAP.OAuthIdPClientSecret.Set if aapProvided && !lic.IsPremium() { invalid.Append("mdm.apple_account_provisioning", ErrMissingLicense.Error()) @@ -742,18 +743,25 @@ func (svc *Service) ModifyAppConfig(ctx context.Context, p []byte, applyOpts fle invalid.Append("mdm.apple_account_provisioning", "Missing required private key. Learn how to configure the private key here: https://fleetdm.com/learn-more-about/fleet-server-private-key") } - // Require https: the client secret is sent to this endpoint, so plaintext - // http would leak it. if u, err := url.Parse(mergedAAP.OAuthIdPTokenURL.Value); err != nil || u.Host == "" || u.Scheme != "https" { invalid.Append("mdm.apple_account_provisioning.oauth_idp_token_url", "must be a valid https URL") } - // Require a freshly-supplied secret whenever the token URL changes, so a - // caller can't repoint the IdP token endpoint while reusing the stored - // secret (which would leak it to the new, possibly hostile, URL). - if mergedAAP.OAuthIdPTokenURL.Value != oldAAP.OAuthIdPTokenURL.Value && !newAAPSecretProvided { + switch { + case !newAAPSecretProvided && (applyOpts.Overwrite || !oldAAP.Configured()): + invalid.Append("mdm.apple_account_provisioning.oauth_idp_client_secret", + "oauth_idp_client_secret must be set together with oauth_idp_token_url and oauth_idp_client_id") + case !newAAPSecretProvided && mergedAAP.OAuthIdPTokenURL.Value != oldAAP.OAuthIdPTokenURL.Value: + // Reusing a stored secret while repointing the IdP token endpoint would + // leak it to the new (possibly hostile) URL, so require it be provided. + // Similar to CAs and their secrets. invalid.Append("mdm.apple_account_provisioning.oauth_idp_client_secret", - "must be provided when changing oauth_idp_token_url") + "oauth_idp_client_secret must be provided when changing oauth_idp_token_url") } + case tokenURLSet || clientIDSet || newAAPSecretProvided: + // Not fully configured, but a field was supplied (one public field without + // the other, or a secret on its own) — a partial config. + invalid.Append("mdm.apple_account_provisioning", + "oauth_idp_token_url, oauth_idp_client_id, and oauth_idp_client_secret must all be set together, or all be empty") } // this is to handle the case where `apple_enable_release_device_manually: null` is diff --git a/server/service/appconfig_test.go b/server/service/appconfig_test.go index 6f165f90c40..5a2f20ae1bb 100644 --- a/server/service/appconfig_test.go +++ b/server/service/appconfig_test.go @@ -1770,11 +1770,12 @@ func TestModifyAppConfigAppleAccountProvisioning(t *testing.T) { } cases := []struct { - name string - tier string - stored fleet.AppleAccountProvisioning - body string - want asserts + name string + tier string + stored fleet.AppleAccountProvisioning + body string + overwrite bool // GitOps (overwrite) mode rather than a PATCH + want asserts }{ { name: "configure stores secret and masks response", @@ -1828,13 +1829,69 @@ func TestModifyAppConfigAppleAccountProvisioning(t *testing.T) { body: `{"mdm":{"apple_account_provisioning":{"oauth_idp_token_url":"","oauth_idp_client_id":"","oauth_idp_client_secret":""}}}`, want: asserts{deleted: true}, }, + { + name: "public fields without secret rejected", + tier: fleet.TierPremium, + body: fmt.Sprintf(`{"mdm":{"apple_account_provisioning":{"oauth_idp_token_url":%q,"oauth_idp_client_id":%q,"oauth_idp_client_secret":""}}}`, tokenURL, clientID), + want: asserts{wantErr: "must be set together with oauth_idp_token_url and oauth_idp_client_id"}, + }, + { + name: "only token url rejected", + tier: fleet.TierPremium, + body: fmt.Sprintf(`{"mdm":{"apple_account_provisioning":{"oauth_idp_token_url":%q,"oauth_idp_client_id":"","oauth_idp_client_secret":""}}}`, tokenURL), + want: asserts{wantErr: "must all be set together, or all be empty"}, + }, + { + name: "only client id rejected", + tier: fleet.TierPremium, + body: fmt.Sprintf(`{"mdm":{"apple_account_provisioning":{"oauth_idp_token_url":"","oauth_idp_client_id":%q,"oauth_idp_client_secret":""}}}`, clientID), + want: asserts{wantErr: "must all be set together, or all be empty"}, + }, + { + name: "only secret rejected", + tier: fleet.TierPremium, + body: fmt.Sprintf(`{"mdm":{"apple_account_provisioning":{"oauth_idp_token_url":"","oauth_idp_client_id":"","oauth_idp_client_secret":%q}}}`, secret), + want: asserts{wantErr: "must all be set together, or all be empty"}, + }, + { + name: "gitops with all fields stores secret", + tier: fleet.TierPremium, + overwrite: true, + body: fmt.Sprintf(`{"mdm":{"apple_account_provisioning":{"oauth_idp_token_url":%q,"oauth_idp_client_id":%q,"oauth_idp_client_secret":%q}}}`, tokenURL, clientID, secret), + want: asserts{insertedSecret: new(secret), wantMasked: true}, + }, + { + name: "gitops public fields without secret rejected", + tier: fleet.TierPremium, + overwrite: true, + body: fmt.Sprintf(`{"mdm":{"apple_account_provisioning":{"oauth_idp_token_url":%q,"oauth_idp_client_id":%q}}}`, tokenURL, clientID), + want: asserts{wantErr: "must be set together with oauth_idp_token_url and oauth_idp_client_id"}, + }, + { + // GitOps is declarative: an already-stored secret does NOT satisfy the + // requirement; the secret must be present in the config itself. + name: "gitops reapply without secret rejected", + tier: fleet.TierPremium, + overwrite: true, + stored: configuredAAP(), + body: fmt.Sprintf(`{"mdm":{"apple_account_provisioning":{"oauth_idp_token_url":%q,"oauth_idp_client_id":%q}}}`, tokenURL, clientID), + want: asserts{wantErr: "must be set together with oauth_idp_token_url and oauth_idp_client_id"}, + }, + { + name: "gitops omitting section clears secret", + tier: fleet.TierPremium, + overwrite: true, + stored: configuredAAP(), + body: `{"mdm":{}}`, + want: asserts{deleted: true}, + }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { svc, ctx, ds, tr := setup(t, tc.tier, tc.stored) - modified, err := svc.ModifyAppConfig(ctx, []byte(tc.body), fleet.ApplySpecOptions{}) + modified, err := svc.ModifyAppConfig(ctx, []byte(tc.body), fleet.ApplySpecOptions{Overwrite: tc.overwrite}) if tc.want.wantErr != "" { require.Error(t, err) require.Contains(t, err.Error(), tc.want.wantErr) From deae1c76d3ca8469a6e95d1122c68d4ad08e5fea Mon Sep 17 00:00:00 2001 From: Jordan Montgomery Date: Wed, 17 Jun 2026 13:15:37 -0400 Subject: [PATCH 13/28] Fix comment and failing tests --- .../fleetctl/testdata/expectedGetConfigAppConfigJson.json | 5 +++++ .../expectedGetConfigAppConfigTeamMaintainerJson.json | 5 +++++ .../expectedGetConfigAppConfigTeamMaintainerYaml.yml | 4 ++++ .../fleetctl/testdata/expectedGetConfigAppConfigYaml.yml | 4 ++++ .../testdata/expectedGetConfigIncludeServerConfigJson.json | 5 +++++ .../testdata/expectedGetConfigIncludeServerConfigYaml.yml | 4 ++++ ee/server/service/apple_psso.go | 5 +---- 7 files changed, 28 insertions(+), 4 deletions(-) diff --git a/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigJson.json b/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigJson.json index 77563ba9bad..6c99b7de868 100644 --- a/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigJson.json +++ b/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigJson.json @@ -122,6 +122,11 @@ "enabled_and_configured": false, "apple_business": null, "apple_business_manager": null, + "apple_account_provisioning": { + "oauth_idp_token_url": null, + "oauth_idp_client_id": null, + "oauth_idp_client_secret": null + }, "volume_purchasing_program": null, "windows_enabled_and_configured": false, "enable_disk_encryption": false, diff --git a/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigTeamMaintainerJson.json b/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigTeamMaintainerJson.json index 9e328844dbf..aac95a87b0b 100644 --- a/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigTeamMaintainerJson.json +++ b/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigTeamMaintainerJson.json @@ -94,6 +94,11 @@ "enabled_and_configured": false, "apple_business": null, "apple_business_manager": null, + "apple_account_provisioning": { + "oauth_idp_token_url": null, + "oauth_idp_client_id": null, + "oauth_idp_client_secret": null + }, "volume_purchasing_program": null, "windows_enabled_and_configured": false, "enable_disk_encryption": false, diff --git a/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigTeamMaintainerYaml.yml b/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigTeamMaintainerYaml.yml index 85be72a99b9..fe741533506 100644 --- a/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigTeamMaintainerYaml.yml +++ b/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigTeamMaintainerYaml.yml @@ -39,6 +39,10 @@ spec: enabled_and_configured: false apple_business: null apple_business_manager: null + apple_account_provisioning: + oauth_idp_token_url: null + oauth_idp_client_id: null + oauth_idp_client_secret: null volume_purchasing_program: null windows_enabled_and_configured: false enable_disk_encryption: false diff --git a/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml b/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml index a8e48b35a30..e644577e19a 100644 --- a/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml +++ b/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml @@ -39,6 +39,10 @@ spec: enabled_and_configured: false apple_business: null apple_business_manager: null + apple_account_provisioning: + oauth_idp_token_url: null + oauth_idp_client_id: null + oauth_idp_client_secret: null volume_purchasing_program: null windows_enabled_and_configured: false enable_disk_encryption: false diff --git a/cmd/fleetctl/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json b/cmd/fleetctl/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json index 4db02fb2f19..4b081f1172e 100644 --- a/cmd/fleetctl/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json +++ b/cmd/fleetctl/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json @@ -68,6 +68,11 @@ "android_enabled_and_configured": false, "apple_business": null, "apple_business_manager": null, + "apple_account_provisioning": { + "oauth_idp_token_url": null, + "oauth_idp_client_id": null, + "oauth_idp_client_secret": null + }, "apple_server_url": "", "volume_purchasing_program": null, "apple_bm_terms_expired": false, diff --git a/cmd/fleetctl/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml b/cmd/fleetctl/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml index 6bb8e09e0d8..7b7a1c20633 100644 --- a/cmd/fleetctl/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml +++ b/cmd/fleetctl/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml @@ -35,6 +35,10 @@ spec: android_enabled_and_configured: false apple_business: null apple_business_manager: null + apple_account_provisioning: + oauth_idp_token_url: null + oauth_idp_client_id: null + oauth_idp_client_secret: null apple_server_url: "" volume_purchasing_program: null apple_bm_enabled_and_configured: false diff --git a/ee/server/service/apple_psso.go b/ee/server/service/apple_psso.go index 088a0feb7e0..cde9e85b5bc 100644 --- a/ee/server/service/apple_psso.go +++ b/ee/server/service/apple_psso.go @@ -153,14 +153,11 @@ func computeKID(pub *ecdsa.PublicKey) (string, error) { } // isAssetNotFound reports whether err indicates that the requested -// mdm_config_assets row was absent. The datastore returns a partial-result -// error in that case. +// mdm_config_assets row was absent. func isAssetNotFound(err error) bool { if err == nil { return false } - // fleet.IsNotFound catches the typed not-found case; the partial-result - // error from GetAllMDMConfigAssetsByName matches via string content. if fleet.IsNotFound(err) { return true } From c950e66c800e0aab3fcadf279a207d2c4b61983d Mon Sep 17 00:00:00 2001 From: Jordan Montgomery Date: Wed, 17 Jun 2026 14:26:57 -0400 Subject: [PATCH 14/28] Add gitops secret redaction --- cmd/fleetctl/fleetctl/generate_gitops.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cmd/fleetctl/fleetctl/generate_gitops.go b/cmd/fleetctl/fleetctl/generate_gitops.go index 75f0b9d5c04..974645912aa 100644 --- a/cmd/fleetctl/fleetctl/generate_gitops.go +++ b/cmd/fleetctl/fleetctl/generate_gitops.go @@ -1409,6 +1409,8 @@ func (cmd *GenerateGitopsCommand) generateControls(teamId *uint, teamName string if aap := cmd.AppConfig.MDM.AppleAccountProvisioning; aap.Configured() { aapT := reflect.TypeFor[fleet.AppleAccountProvisioning]() controlsFile := "default.yml" + // This may look odd but ensures we put it in unassigned.yml if that's where + // we're putting the rest if teamId != nil { controlsFile = "fleets/" + teamName + ".yml" } @@ -1417,6 +1419,10 @@ func (cmd *GenerateGitopsCommand) generateControls(teamId *uint, teamName string jsonFieldName(aapT, "OAuthIdPClientID"): aap.OAuthIdPClientID.Value, jsonFieldName(aapT, "OAuthIdPClientSecret"): cmd.AddComment(controlsFile, "TODO: Add your IdP client secret here"), } + cmd.Messages.SecretWarnings = append(cmd.Messages.SecretWarnings, SecretWarning{ + Filename: controlsFile, + Key: "apple_account_provisioning.oauth_idp_client_secret", + }) } } if cmd.AppConfig.MDM.WindowsEnabledAndConfigured { From a6e709f1d89a3885879d11aea7b60aa9606fbfd0 Mon Sep 17 00:00:00 2001 From: Jordan Montgomery Date: Wed, 17 Jun 2026 15:16:34 -0400 Subject: [PATCH 15/28] Cleanup crypto --- docs/Contributing/research/mdm/psso.md | 10 +- ee/server/service/apple_psso.go | 161 ++++++++++---------- ee/server/service/apple_psso_crypto.go | 74 +++++++-- ee/server/service/apple_psso_crypto_test.go | 53 ++++++- server/fleet/mdm.go | 4 + server/service/appconfig.go | 10 ++ server/service/apple_psso.go | 121 +++++++++++++++ server/service/apple_psso_test.go | 127 ++++++++++++++- 8 files changed, 457 insertions(+), 103 deletions(-) diff --git a/docs/Contributing/research/mdm/psso.md b/docs/Contributing/research/mdm/psso.md index 72c9acfcb48..5d7d2c3385a 100644 --- a/docs/Contributing/research/mdm/psso.md +++ b/docs/Contributing/research/mdm/psso.md @@ -113,9 +113,9 @@ The nonce store mirrors `server/mdm/acme/internal/redis_nonces_store/` and expos - `mdm_apple_psso_devices` — primary key `host_id`, stores the device's signing and encryption public keys (PEM), the negotiated KeyExchangeKey, and registration/update timestamps. - `mdm_apple_psso_key_ids` — primary key `kid`, foreign key `host_id`, plus `key_type` and `pem`. The extension references keys by SHA-256 hash of the public key, so the server needs an index keyed by that hash to resolve incoming requests back to a device. -### JWKS signing key bootstrap timing (OPEN) +### JWKS signing key bootstrap timing (RESOLVED in #47122) -Current placeholder behavior: the JWKS signing key is lazily minted on the first `GET /api/mdm/apple/psso/jwks` and persisted (encrypted) in `mdm_config_assets` under `MDMAssetPSSOSigningKey`. Alternatives under consideration include minting on the first time a user enables the feature. The lazy-mint code carries a `TODO`; decision pending. +The signing key is no longer lazily minted. Both it (`MDMAssetPSSOSigningKey`) and the self-signed PSSO CA (`MDMAssetPSSOCACert`, backed by the same private key) are created once, the first time the feature is configured, via `bootstrapPSSOAssets` in `ModifyAppConfig` (covering the config API and GitOps). The bootstrap is idempotent and never regenerates existing assets, so the JWKS key and CA stay stable across reconfiguration and disable/re-enable; the device-facing service methods now only ever load them. Since the feature is still experimental, no upgrade path is provided for POC instances that minted a key under the old lazy path — re-saving the PSSO config generates the CA over the existing key. ### In-tree Swift extension at `apple-sso-extension/` @@ -234,6 +234,8 @@ Compounds the unauthenticated-registration limitation. `SetOrUpdatePSSODevice` ( `parsePSSOInboundJWT` (`ee/server/service/apple_psso_crypto.go`) calls `jwt.ParseWithClaims` without `jwt.WithValidMethods` and the keyfunc returns the EC key without asserting `token.Method`. Not exploitable as written (golang-jwt v4's type assertions reject HS/`none` against an `*ecdsa.PublicKey`), but it is one refactor away from an alg-confusion forgery. Fix: pass `jwt.WithValidMethods([]string{"ES256"})` and assert `*jwt.SigningMethodECDSA` in the keyfunc. Cheap hardening. +**Addressed in #47122.** `parsePSSOInboundJWT` now passes `jwt.WithValidMethods([]string{"ES256"})` and asserts `*jwt.SigningMethodECDSA` in the keyfunc; HS256 and `none` tokens are rejected. + ### MEDIUM [deploy] — ROPG client is an SSRF / credential-redirection sink `idp_token_url` comes from admin-controlled `AppConfig` and is POSTed to with the user's plaintext password. There is no scheme/host validation, so whoever can edit config (or a future settings UI lacking validation) can point it at an internal address and harvest passwords. Validate `https://` and a non-internal host at config-write time, and confirm `fleethttp.NewClient()` enforces TLS verification for this client. Pair this validation with the live-reload work under *Admin configuration*. @@ -242,10 +244,14 @@ Compounds the unauthenticated-registration limitation. `SetOrUpdatePSSODevice` ( The provisioned private key sealed into `key_context` (key request) is recoverable by any registered device replaying any captured `key_context`, and the blob carries no TTL (its payload `exp` is advisory and not re-checked on exchange). It is also sealed under a key HKDF-derived from the long-lived PSSO signing key, so signing-key rotation/re-mint silently invalidates all outstanding contexts, and a signing-key leak compromises every context ever issued (no forward secrecy). **Note:** the review rated this deferrable on the assumption the key-request/key-exchange path was not exercised by the Password flow — that is no longer true; the unlock-key exchange now runs during Password-mode registration, so treat this as active. Fix: bind `key_context` to the device `kid` and an expiry inside the sealed plaintext (e.g. as AAD), and reject on open if mismatched or expired. +**Device binding addressed in #47122.** `key_context` now seals a structured JSON plaintext — `{host_uuid, key_purpose, provisioned_key}` — instead of the bare private key, and key exchange rejects when the sealed `host_uuid` doesn't match the host resolved from the request's signing key (or when `key_purpose` isn't `user_unlock`). A captured context replayed by, or fetched onto, another device is rejected. An in-blob expiry was considered but deliberately left out for now to match the issue's specified shape; the forward-secrecy / signing-key-coupling concerns remain open. + ### MEDIUM [GA] — Ad-hoc CA certificate issuance is sloppy `issuePSSOProvisionedCertificate` (`ee/server/service/apple_psso.go`) regenerates a self-signed, unconstrained, 10-year signing CA on every key request with fixed serial `1`. Generate the CA once (persist alongside the signing key), use random serials for both CA and leaf, and add EKU/name constraints scoping its use. +**Addressed in #47122.** The CA is now minted once at first configuration and persisted (`MDMAssetPSSOCACert`); `issuePSSOProvisionedCertificate` loads it and signs each leaf with a random 128-bit serial. The CA keeps serial `1` (matching Fleet's other self-signed CA roots in `server/mdm/scep/depot`): a singular, persisted self-signed root produces exactly one certificate, so the serial is unique by construction — the random-serial recommendation applied to the per-request *leaf*, which it now uses. The CA carries `BasicConstraintsValid`, `IsCA`, `MaxPathLen: 0`, and a SubjectKeyId. + ### LOW [GA] — Hardcoded developer Team/bundle IDs in the AASA `teamID*`/`bundleID*` constants (`ee/server/service/apple_psso.go`) are baked into the public, always-served `apple-app-site-association`. They leak Fleet-developer identifiers and mis-bind for any deployer using their own signing identity. Make them config-driven before GA. diff --git a/ee/server/service/apple_psso.go b/ee/server/service/apple_psso.go index cde9e85b5bc..e5e49f2e759 100644 --- a/ee/server/service/apple_psso.go +++ b/ee/server/service/apple_psso.go @@ -27,21 +27,14 @@ import ( jose "github.com/go-jose/go-jose/v3" ) -// pssoServiceState holds the lazily-loaded PSSO signing key. -// -// TODO(psso bootstrap): the current lazy-mint behavior runs on the first call -// to any method that reaches getOrMintPSSOSigningKey — most commonly -// PSSOJWKS, which is an unauthenticated public endpoint. That means an -// unauthenticated GET triggers a write + KMS roundtrip if the key doesn't -// exist yet. Acceptable for POC but worth revisiting before GA. Alternatives -// to consider: -// - mint when an admin first configures AppConfig.MDM.AppleAccountProvisioning -// - mint on the first device registration request -// - explicit `fleetctl psso bootstrap` step +// pssoServiceState caches the PSSO signing key and CA certificate after first +// load. Both are created in mdm_config_assets when the feature is first +// configured (bootstrapPSSOAssets, core side); this layer only loads them. type pssoServiceState struct { mu sync.Mutex signingKey *ecdsa.PrivateKey kid string + caCert *x509.Certificate } const ( @@ -57,70 +50,90 @@ const ( teamID2 = "B34KW9D28L" ) -// getOrMintPSSOSigningKey returns Fleet's PSSO signing key, loading it from -// mdm_config_assets or minting+persisting a fresh one if not present. -func (svc *Service) getOrMintPSSOSigningKey(ctx context.Context) (*ecdsa.PrivateKey, string, error) { +// getPSSOSigningKey loads Fleet's PSSO signing key from mdm_config_assets, +// caching it after first use. The key (and CA) are created when the feature is +// first configured (bootstrapPSSOAssets); a missing key here means the feature +// isn't configured, so this never mints — it returns an error. +func (svc *Service) getPSSOSigningKey(ctx context.Context) (*ecdsa.PrivateKey, string, error) { svc.pssoState.mu.Lock() defer svc.pssoState.mu.Unlock() + return svc.loadPSSOSigningKeyLocked(ctx) +} +// loadPSSOSigningKeyLocked is the cache-populating load shared by +// getPSSOSigningKey and getPSSOCA. Callers must hold pssoState.mu. +func (svc *Service) loadPSSOSigningKeyLocked(ctx context.Context) (*ecdsa.PrivateKey, string, error) { if svc.pssoState.signingKey != nil { return svc.pssoState.signingKey, svc.pssoState.kid, nil } - - // Try load. assets, err := svc.ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{fleet.MDMAssetPSSOSigningKey}, nil, ) - if err == nil { - asset, ok := assets[fleet.MDMAssetPSSOSigningKey] - if ok && len(asset.Value) > 0 { - key, kid, err := parsePSSOSigningKeyPEM(asset.Value) - if err != nil { - return nil, "", ctxerr.Wrap(ctx, err, "parse stored psso signing key") - } - svc.pssoState.signingKey = key - svc.pssoState.kid = kid - return key, kid, nil + if err != nil { + if isAssetNotFound(err) { + return nil, "", ctxerr.Wrap(ctx, err, "psso signing key not found; configure the feature first") } - } else if !isAssetNotFound(err) { return nil, "", ctxerr.Wrap(ctx, err, "get psso signing key asset") } - - // Mint a fresh key and persist. - key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - if err != nil { - return nil, "", ctxerr.Wrap(ctx, err, "generate psso signing key") + asset, ok := assets[fleet.MDMAssetPSSOSigningKey] + if !ok || len(asset.Value) == 0 { + return nil, "", ctxerr.New(ctx, "psso signing key asset is empty") } - pemBytes, kid, err := encodePSSOSigningKeyPEM(key) + key, kid, err := parsePSSOSigningKeyPEM(asset.Value) if err != nil { - return nil, "", ctxerr.Wrap(ctx, err, "encode psso signing key") - } - if err := svc.ds.InsertOrReplaceMDMConfigAsset(ctx, fleet.MDMConfigAsset{ - Name: fleet.MDMAssetPSSOSigningKey, - Value: pemBytes, - }); err != nil { - return nil, "", ctxerr.Wrap(ctx, err, "persist psso signing key") + return nil, "", ctxerr.Wrap(ctx, err, "parse stored psso signing key") } svc.pssoState.signingKey = key svc.pssoState.kid = kid return key, kid, nil } -// encodePSSOSigningKeyPEM serializes a P-256 private key to PEM and returns -// the bytes plus the kid (base64url-nopad SHA-256 of the DER-encoded public -// key). -func encodePSSOSigningKeyPEM(key *ecdsa.PrivateKey) ([]byte, string, error) { - der, err := x509.MarshalECPrivateKey(key) +// getPSSOCA loads the PSSO CA: the signing key (which is also the CA's private +// key) and the self-signed CA certificate, caching the certificate after first +// use. Like the signing key, the CA is created at first configuration and is +// never minted here. +func (svc *Service) getPSSOCA(ctx context.Context) (*ecdsa.PrivateKey, *x509.Certificate, error) { + svc.pssoState.mu.Lock() + defer svc.pssoState.mu.Unlock() + + caKey, _, err := svc.loadPSSOSigningKeyLocked(ctx) if err != nil { - return nil, "", err + return nil, nil, err } - pemBytes := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der}) - kid, err := computeKID(&key.PublicKey) + if svc.pssoState.caCert != nil { + return caKey, svc.pssoState.caCert, nil + } + + assets, err := svc.ds.GetAllMDMConfigAssetsByName(ctx, + []fleet.MDMAssetName{fleet.MDMAssetPSSOCACert}, + nil, + ) if err != nil { - return nil, "", err + if isAssetNotFound(err) { + return nil, nil, ctxerr.Wrap(ctx, err, "psso ca certificate not found; configure the feature first") + } + return nil, nil, ctxerr.Wrap(ctx, err, "get psso ca cert asset") + } + asset, ok := assets[fleet.MDMAssetPSSOCACert] + if !ok || len(asset.Value) == 0 { + return nil, nil, ctxerr.New(ctx, "psso ca cert asset is empty") + } + caCert, err := parsePSSOCACertPEM(asset.Value) + if err != nil { + return nil, nil, ctxerr.Wrap(ctx, err, "parse stored psso ca cert") + } + svc.pssoState.caCert = caCert + return caKey, caCert, nil +} + +// parsePSSOCACertPEM decodes the stored PEM-wrapped PSSO CA certificate. +func parsePSSOCACertPEM(pemBytes []byte) (*x509.Certificate, error) { + block, _ := pem.Decode(pemBytes) + if block == nil { + return nil, errors.New("psso ca cert: pem decode returned nil block") } - return pemBytes, kid, nil + return x509.ParseCertificate(block.Bytes) } func parsePSSOSigningKeyPEM(pemBytes []byte) (*ecdsa.PrivateKey, string, error) { @@ -572,7 +585,7 @@ func (svc *Service) handlePSSOKeyRequest(ctx context.Context, hostUUID string, c return nil, ctxerr.Wrap(ctx, err, "issue psso provisioned certificate") } - signingKey, _, err := svc.getOrMintPSSOSigningKey(ctx) + signingKey, _, err := svc.getPSSOSigningKey(ctx) if err != nil { return nil, ctxerr.Wrap(ctx, err, "load psso signing key") } @@ -580,7 +593,7 @@ func (svc *Service) handlePSSOKeyRequest(ctx context.Context, hostUUID string, c if err != nil { return nil, ctxerr.Wrap(ctx, err, "derive key_context key") } - keyContext, err := sealKeyContext(provisioned, kcKey) + keyContext, err := sealKeyContext(provisioned, hostUUID, pssoKeyPurposeUserUnlock, kcKey) if err != nil { return nil, ctxerr.Wrap(ctx, err, "seal key_context") } @@ -604,38 +617,20 @@ func (svc *Service) handlePSSOKeyRequest(ctx context.Context, hostUUID string, c } // issuePSSOProvisionedCertificate issues an X.509 certificate over a -// server-provisioned public key, signed by Fleet's PSSO signing key acting as -// a CA. This is the certificate returned in a key-request response; the device -// uses its public key for its half of the unlock-key Diffie-Hellman. +// server-provisioned public key, signed by Fleet's persisted PSSO CA. This is +// the certificate returned in a key-request response; the device uses its public +// key for its half of the unlock-key Diffie-Hellman. func (svc *Service) issuePSSOProvisionedCertificate(ctx context.Context, provisionedKey *ecdsa.PublicKey) ([]byte, error) { - caKey, _, err := svc.getOrMintPSSOSigningKey(ctx) + caKey, caCert, err := svc.getPSSOCA(ctx) if err != nil { - return nil, ctxerr.Wrap(ctx, err, "load psso signing key for cert issuance") - } - - now := time.Now() - caTmpl := &x509.Certificate{ - SerialNumber: big.NewInt(1), - Subject: pkix.Name{CommonName: "Fleet PSSO CA"}, - NotBefore: now.Add(-time.Hour), - NotAfter: now.AddDate(10, 0, 0), - IsCA: true, - KeyUsage: x509.KeyUsageCertSign, - BasicConstraintsValid: true, - } - caDER, err := x509.CreateCertificate(rand.Reader, caTmpl, caTmpl, &caKey.PublicKey, caKey) - if err != nil { - return nil, ctxerr.Wrap(ctx, err, "create psso ca certificate") - } - caCert, err := x509.ParseCertificate(caDER) - if err != nil { - return nil, ctxerr.Wrap(ctx, err, "parse psso ca certificate") + return nil, ctxerr.Wrap(ctx, err, "load psso ca for cert issuance") } serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) if err != nil { return nil, ctxerr.Wrap(ctx, err, "generate psso cert serial") } + now := time.Now() devTmpl := &x509.Certificate{ SerialNumber: serial, Subject: pkix.Name{CommonName: "Fleet PSSO Device Key"}, @@ -664,7 +659,7 @@ func (svc *Service) handlePSSOKeyExchange(ctx context.Context, hostUUID string, return nil, &fleet.BadRequestError{Message: "psso key exchange: missing other_publickey or key_context"} } - signingKey, _, err := svc.getOrMintPSSOSigningKey(ctx) + signingKey, _, err := svc.getPSSOSigningKey(ctx) if err != nil { return nil, ctxerr.Wrap(ctx, err, "load psso signing key") } @@ -672,10 +667,18 @@ func (svc *Service) handlePSSOKeyExchange(ctx context.Context, hostUUID string, if err != nil { return nil, ctxerr.Wrap(ctx, err, "derive key_context key") } - provisioned, err := openKeyContext(claims.KeyContext, kcKey) + kc, provisioned, err := openKeyContext(claims.KeyContext, kcKey) if err != nil { return nil, ctxerr.Wrap(ctx, err, "open key_context") } + // Bind the sealed key_context to the device: reject a context replayed by, or + // fetched onto, any device other than the one it was issued to. + if kc.HostUUID != hostUUID { + return nil, &fleet.BadRequestError{Message: "psso key exchange: key_context host mismatch"} + } + if kc.KeyPurpose != pssoKeyPurposeUserUnlock { + return nil, &fleet.BadRequestError{Message: "psso key exchange: unsupported key_context purpose"} + } otherRaw, err := decodeBase64Flexible(claims.OtherPublicKey) if err != nil { @@ -724,7 +727,7 @@ func (svc *Service) PSSOJWKS(ctx context.Context) ([]byte, error) { return nil, ¬FoundError{} } - key, kid, err := svc.getOrMintPSSOSigningKey(ctx) + key, kid, err := svc.getPSSOSigningKey(ctx) if err != nil { return nil, ctxerr.Wrap(ctx, err, "load psso signing key") } diff --git a/ee/server/service/apple_psso_crypto.go b/ee/server/service/apple_psso_crypto.go index 3215d4bd442..2bd0d33f6ef 100644 --- a/ee/server/service/apple_psso_crypto.go +++ b/ee/server/service/apple_psso_crypto.go @@ -132,9 +132,16 @@ func (svc *Service) parsePSSOInboundJWT(ctx context.Context, jwtBytes []byte) (* return nil, nil, ctxerr.Wrap(ctx, err, "parse device signing pubkey") } - tok, err := jwt.ParseWithClaims(string(jwtBytes), &pssoTokenClaims{}, func(*jwt.Token) (any, error) { + // Pin the algorithm to ES256 (the only alg the Secure Enclave-backed + // extension signs with) and assert the ECDSA method in the keyfunc. Without + // this, a future refactor returning a non-EC key could open an alg-confusion + // forgery path even though golang-jwt's type assertions currently prevent it. + tok, err := jwt.ParseWithClaims(string(jwtBytes), &pssoTokenClaims{}, func(t *jwt.Token) (any, error) { + if _, ok := t.Method.(*jwt.SigningMethodECDSA); !ok { + return nil, fmt.Errorf("psso jwt: unexpected signing method %q", t.Method.Alg()) + } return pub, nil - }) + }, jwt.WithValidMethods([]string{pssoSigningAlg})) if err != nil { return nil, nil, ctxerr.Wrap(ctx, err, "verify psso jwt signature") } @@ -312,6 +319,7 @@ func encodeApplePartyInfo(fields ...[]byte) []byte { var b []byte var l [4]byte for _, f := range fields { + //nolint:gosec // dismiss G115, party-info fields are small (labels, 65-byte EC points, nonces), never near 2^32 binary.BigEndian.PutUint32(l[:], uint32(len(f))) b = append(b, l[:]...) b = append(b, f...) @@ -438,32 +446,70 @@ func deriveKeyContextKey(signingKey *ecdsa.PrivateKey) ([]byte, error) { return deriveSessionKey(ikm, []byte("fleetdm-psso-key-context-v1")) } -// sealKeyContext encrypts a provisioned EC private key into the opaque, -// base64 key_context returned in a key-request response. -func sealKeyContext(provisioned *ecdsa.PrivateKey, kcKey []byte) (string, error) { +// pssoKeyPurposeUserUnlock is the only key purpose Fleet provisions today: the +// offline FileVault/keychain unlock key. It's recorded in the sealed key_context +// so key exchange can validate it and future purposes can be distinguished. +const pssoKeyPurposeUserUnlock = "user_unlock" + +// pssoKeyContext is the plaintext sealed into the opaque key_context blob that +// rides between a key request and its matching key exchange. Binding the host +// UUID lets key exchange reject a context replayed by, or fetched onto, any +// device other than the one it was issued to; key_purpose leaves room to +// provision other key types later without reusing a context across purposes. +type pssoKeyContext struct { + HostUUID string `json:"host_uuid"` + KeyPurpose string `json:"key_purpose"` + ProvisionedKey string `json:"provisioned_key"` // base64 (std) DER of the EC private key +} + +// sealKeyContext seals the provisioned EC private key, bound to the device and +// key purpose, into the opaque base64 key_context returned in a key-request +// response. +func sealKeyContext(provisioned *ecdsa.PrivateKey, hostUUID, keyPurpose string, kcKey []byte) (string, error) { der, err := x509.MarshalECPrivateKey(provisioned) if err != nil { return "", err } - blob, err := buildSymmetricJWE(der, kcKey) + plaintext, err := json.Marshal(pssoKeyContext{ + HostUUID: hostUUID, + KeyPurpose: keyPurpose, + ProvisionedKey: base64.StdEncoding.EncodeToString(der), + }) + if err != nil { + return "", err + } + blob, err := buildSymmetricJWE(plaintext, kcKey) if err != nil { return "", err } return base64.StdEncoding.EncodeToString(blob), nil } -// openKeyContext reverses sealKeyContext, recovering the provisioned private -// key the device echoed back in a key-exchange request. -func openKeyContext(keyContext string, kcKey []byte) (*ecdsa.PrivateKey, error) { +// openKeyContext reverses sealKeyContext, returning the sealed context metadata +// (for the caller to validate device/purpose binding) and the recovered +// provisioned private key the device echoed back in a key-exchange request. +func openKeyContext(keyContext string, kcKey []byte) (*pssoKeyContext, *ecdsa.PrivateKey, error) { blob, err := base64.StdEncoding.DecodeString(keyContext) if err != nil { - return nil, fmt.Errorf("decode key_context: %w", err) + return nil, nil, fmt.Errorf("decode key_context: %w", err) + } + plaintext, err := decryptSymmetricBlob(blob, kcKey) + if err != nil { + return nil, nil, fmt.Errorf("decrypt key_context: %w", err) + } + var kc pssoKeyContext + if err := json.Unmarshal(plaintext, &kc); err != nil { + return nil, nil, fmt.Errorf("unmarshal key_context: %w", err) + } + der, err := base64.StdEncoding.DecodeString(kc.ProvisionedKey) + if err != nil { + return nil, nil, fmt.Errorf("decode key_context provisioned_key: %w", err) } - der, err := decryptSymmetricBlob(blob, kcKey) + key, err := x509.ParseECPrivateKey(der) if err != nil { - return nil, fmt.Errorf("decrypt key_context: %w", err) + return nil, nil, fmt.Errorf("parse key_context provisioned_key: %w", err) } - return x509.ParseECPrivateKey(der) + return &kc, key, nil } // computeECDHShared returns the raw ECDH shared secret (P-256 X coordinate, 32 @@ -577,7 +623,7 @@ func decryptSymmetricBlob(blob []byte, sessionKey []byte) ([]byte, error) { // Fleet's PSSO signing key. Used to wrap payloads that must be authenticated // as coming from Fleet (e.g. claims responses). func (svc *Service) signServerJWT(ctx context.Context, claims jwt.Claims) ([]byte, error) { - key, kid, err := svc.getOrMintPSSOSigningKey(ctx) + key, kid, err := svc.getPSSOSigningKey(ctx) if err != nil { return nil, ctxerr.Wrap(ctx, err, "load signing key for psso server jwt") } diff --git a/ee/server/service/apple_psso_crypto_test.go b/ee/server/service/apple_psso_crypto_test.go index de55c052868..a650a07ab8d 100644 --- a/ee/server/service/apple_psso_crypto_test.go +++ b/ee/server/service/apple_psso_crypto_test.go @@ -189,11 +189,14 @@ func TestPSSO_KeyContextRoundTrip(t *testing.T) { provisioned, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) require.NoError(t, err) - sealed, err := sealKeyContext(provisioned, kcKey) + const hostUUID = "ABCD-1234-host-uuid" + sealed, err := sealKeyContext(provisioned, hostUUID, pssoKeyPurposeUserUnlock, kcKey) require.NoError(t, err) - got, err := openKeyContext(sealed, kcKey) + kc, got, err := openKeyContext(sealed, kcKey) require.NoError(t, err) + assert.Equal(t, hostUUID, kc.HostUUID) + assert.Equal(t, pssoKeyPurposeUserUnlock, kc.KeyPurpose) want, err := x509.MarshalECPrivateKey(provisioned) require.NoError(t, err) gotDER, err := x509.MarshalECPrivateKey(got) @@ -205,7 +208,51 @@ func TestPSSO_KeyContextRoundTrip(t *testing.T) { require.NoError(t, err) otherKC, err := deriveKeyContextKey(other) require.NoError(t, err) - _, err = openKeyContext(sealed, otherKC) + _, _, err = openKeyContext(sealed, otherKC) + require.Error(t, err) +} + +// TestPSSO_InboundJWTAlgorithmPinned confirms the token endpoint accepts only +// ES256-signed device JWTs: an HS256 or "none" token presenting the same kid is +// rejected, closing the alg-confusion path. +func TestPSSO_InboundJWTAlgorithmPinned(t *testing.T) { + deviceKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + spki, err := x509.MarshalPKIXPublicKey(&deviceKey.PublicKey) + require.NoError(t, err) + pubPEM := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: spki}) + + const kid = "device-signing-kid" + ds := new(mock.DataStore) + svc := &Service{ds: ds, logger: slog.New(slog.NewTextHandler(io.Discard, nil))} + ds.GetPSSOKeyFunc = func(_ context.Context, _ string) (*fleet.PSSOKey, error) { + return &fleet.PSSOKey{KID: kid, HostUUID: "host", KeyType: fleet.PSSOKeyTypeSigning, PEM: string(pubPEM)}, nil + } + + signed := func(method jwt.SigningMethod, key any) string { + tok := jwt.NewWithClaims(method, &pssoTokenClaims{RequestType: pssoRequestKey}) + tok.Header["kid"] = kid + s, err := tok.SignedString(key) + require.NoError(t, err) + return s + } + + // A valid ES256 token from the registered device verifies. + claims, gotKey, err := svc.parsePSSOInboundJWT(t.Context(), []byte(signed(jwt.SigningMethodES256, deviceKey))) + require.NoError(t, err) + assert.Equal(t, pssoRequestKey, claims.RequestType) + assert.Equal(t, fleet.PSSOKeyTypeSigning, gotKey.KeyType) + + // An HS256 token sharing the same kid is rejected (alg confusion). + _, _, err = svc.parsePSSOInboundJWT(t.Context(), []byte(signed(jwt.SigningMethodHS256, []byte("attacker-secret")))) + require.Error(t, err) + + // An unsigned ("none") token is rejected. + none := jwt.NewWithClaims(jwt.SigningMethodNone, &pssoTokenClaims{RequestType: pssoRequestKey}) + none.Header["kid"] = kid + noneStr, err := none.SignedString(jwt.UnsafeAllowNoneSignatureType) + require.NoError(t, err) + _, _, err = svc.parsePSSOInboundJWT(t.Context(), []byte(noneStr)) require.Error(t, err) } diff --git a/server/fleet/mdm.go b/server/fleet/mdm.go index 8335514d12d..67a5cc2f319 100644 --- a/server/fleet/mdm.go +++ b/server/fleet/mdm.go @@ -1042,6 +1042,10 @@ const ( // MDMAssetPSSOSigningKey is the EC P-256 private key Fleet uses to sign Platform SSO responses // and publishes via the PSSO JWKS endpoint for the Mac extension to verify. MDMAssetPSSOSigningKey MDMAssetName = "psso_signing_key" //nolint:gosec // private key, not a credential string + // MDMAssetPSSOCACert is the self-signed Platform SSO CA certificate Fleet uses + // to certify the provisioned unlock-key during key exchange. Its private key is + // MDMAssetPSSOSigningKey; both are minted once when the feature is first configured. + MDMAssetPSSOCACert MDMAssetName = "psso_ca_cert" // MDMAssetAppleAccountProvisioningIdPClientSecret is the OAuth ROPG IdP client // secret for the macOS account provisioning / Platform SSO feature. Stored // here (encrypted) rather than in the AppConfig JSON so the API never returns it. diff --git a/server/service/appconfig.go b/server/service/appconfig.go index e678fae2140..8d5df9cb6f4 100644 --- a/server/service/appconfig.go +++ b/server/service/appconfig.go @@ -1075,6 +1075,16 @@ func (svc *Service) ModifyAppConfig(ctx context.Context, p []byte, applyOpts fle return nil, err } + // Mint the PSSO signing key and CA the first time the feature is configured. + // Idempotent: existing assets are preserved (never recreated on reconfigure), + // and they are deliberately kept when the feature is disabled so a later + // re-enable reuses the same JWKS key and unlock-key CA. + if mergedAAP.Configured() { + if err := bootstrapPSSOAssets(ctx, svc.ds); err != nil { + return nil, ctxerr.Wrap(ctx, err, "bootstrap psso assets") + } + } + if err := svc.ds.SaveAppConfig(ctx, appConfig); err != nil { return nil, err } diff --git a/server/service/apple_psso.go b/server/service/apple_psso.go index ea0126b1ee9..be5aad763a8 100644 --- a/server/service/apple_psso.go +++ b/server/service/apple_psso.go @@ -2,15 +2,24 @@ package service import ( "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "errors" "io" "log/slog" + "math/big" "net/http" "net/url" + "time" "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/fleetdm/fleet/v4/server/mdm/cryptoutil" ) // HTTP paths for the Apple Platform SSO endpoints. All but the AASA path live @@ -259,3 +268,115 @@ func (svc *Service) PSSOAASA(ctx context.Context) ([]byte, error) { svc.authz.SkipAuthorization(ctx) return nil, fleet.ErrMissingLicense } + +// ----- PSSO asset bootstrap ------------------------------------------------- +// +// The signing key and CA are pure crypto + datastore work, so they live here in +// core (callable from ModifyAppConfig) rather than in ee. The ee service only +// loads them back, using the standard PEM encodings written below. + +// pssoCAValidYears is the lifetime of the self-signed Platform SSO CA. It's +// minted once, when the feature is first configured, and reused for its whole +// life — long enough that it never needs rotation during normal operation. +const pssoCAValidYears = 10 + +// bootstrapPSSOAssets ensures the Platform SSO signing key and its CA +// certificate exist in mdm_config_assets, creating whichever is missing. It runs +// when the feature is configured (covering both the config API and GitOps, which +// both flow through ModifyAppConfig) and is idempotent: existing assets are never +// regenerated, so the signing key (published via JWKS) and the CA stay stable +// across reconfiguration and across disable/re-enable. The CA is self-signed by +// the signing key — they share one private key — so the CA certificate is the +// only new asset. +func bootstrapPSSOAssets(ctx context.Context, ds fleet.Datastore) error { + assets, err := ds.GetAllMDMConfigAssetsByName(ctx, + []fleet.MDMAssetName{fleet.MDMAssetPSSOSigningKey, fleet.MDMAssetPSSOCACert}, + nil, + ) + // A partial result (one asset present, the other missing) returns an error + // alongside the assets it did find; only a hard error with nothing usable is fatal. + if err != nil && !fleet.IsNotFound(err) && len(assets) == 0 { + return ctxerr.Wrap(ctx, err, "load psso assets") + } + + _, haveKey := assets[fleet.MDMAssetPSSOSigningKey] + _, haveCA := assets[fleet.MDMAssetPSSOCACert] + if haveKey && haveCA { + return nil + } + + signingKey, err := pssoSigningKeyFromAssets(assets) + if err != nil { + return ctxerr.Wrap(ctx, err, "parse existing psso signing key") + } + + var toInsert []fleet.MDMConfigAsset + if signingKey == nil { + signingKey, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return ctxerr.Wrap(ctx, err, "generate psso signing key") + } + der, err := x509.MarshalECPrivateKey(signingKey) + if err != nil { + return ctxerr.Wrap(ctx, err, "marshal psso signing key") + } + toInsert = append(toInsert, fleet.MDMConfigAsset{ + Name: fleet.MDMAssetPSSOSigningKey, + Value: pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der}), + }) + } + if !haveCA { + caDER, err := selfSignPSSOCACert(signingKey) + if err != nil { + return ctxerr.Wrap(ctx, err, "create psso ca certificate") + } + toInsert = append(toInsert, fleet.MDMConfigAsset{ + Name: fleet.MDMAssetPSSOCACert, + Value: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caDER}), + }) + } + + if err := ds.InsertMDMConfigAssets(ctx, toInsert, nil); err != nil { + return ctxerr.Wrap(ctx, err, "insert psso assets") + } + return nil +} + +// pssoSigningKeyFromAssets parses the PSSO signing key out of a loaded asset map, +// returning (nil, nil) when it isn't present so the caller can mint a fresh one. +func pssoSigningKeyFromAssets(assets map[fleet.MDMAssetName]fleet.MDMConfigAsset) (*ecdsa.PrivateKey, error) { + asset, ok := assets[fleet.MDMAssetPSSOSigningKey] + if !ok || len(asset.Value) == 0 { + return nil, nil + } + block, _ := pem.Decode(asset.Value) + if block == nil { + return nil, errors.New("psso signing key: pem decode returned nil block") + } + return x509.ParseECPrivateKey(block.Bytes) +} + +// selfSignPSSOCACert self-signs a Platform SSO CA certificate over signingKey. +// Serial 1 matches Fleet's other self-signed CA roots (server/mdm/scep/depot): +// the CA is the only self-signed certificate this key ever produces, so the +// serial is unique by construction. +func selfSignPSSOCACert(signingKey *ecdsa.PrivateKey) ([]byte, error) { + subjectKeyID, err := cryptoutil.GenerateSubjectKeyID(&signingKey.PublicKey) + if err != nil { + return nil, err + } + now := time.Now() + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "Fleet PSSO CA"}, + NotBefore: now.Add(-time.Hour), + NotAfter: now.AddDate(pssoCAValidYears, 0, 0), + IsCA: true, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign | x509.KeyUsageDigitalSignature, + BasicConstraintsValid: true, + MaxPathLen: 0, + MaxPathLenZero: true, + SubjectKeyId: subjectKeyID, + } + return x509.CreateCertificate(rand.Reader, tmpl, tmpl, &signingKey.PublicKey, signingKey) +} diff --git a/server/service/apple_psso_test.go b/server/service/apple_psso_test.go index cd6fd290081..691766a853c 100644 --- a/server/service/apple_psso_test.go +++ b/server/service/apple_psso_test.go @@ -1,11 +1,20 @@ package service import ( + "context" + "crypto/ecdsa" + "crypto/x509" + "encoding/pem" "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" ) @@ -25,11 +34,11 @@ func TestPSSONonceEndpointAcceptsFormBody(t *testing.T) { } func TestPSSORegistrationRequestDecodeBody(t *testing.T) { - pem := "-----BEGIN PUBLIC KEY-----\nMFkw+abc/def=\n-----END PUBLIC KEY-----" + 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", pem) - form.Set("device_encryption_key", pem) + 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") @@ -38,8 +47,8 @@ func TestPSSORegistrationRequestDecodeBody(t *testing.T) { 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, pem, req.DeviceSigningKey) - require.Equal(t, pem, req.DeviceEncryptionKey) + 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) } @@ -74,3 +83,111 @@ func TestPSSOTokenRequestDecodeBody(t *testing.T) { 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{} + } + 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 and CA when both 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) + + signingKey := parsePEMSigningKey(t, store[fleet.MDMAssetPSSOSigningKey].Value) + caCert := parsePEMCert(t, store[fleet.MDMAssetPSSOCACert].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) + }) + + t.Run("no-op when both 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 + + 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) + }) + + 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)) + }) +} From c4a4ee39e4ccccd99a2985b310b32c1daeb1c21e Mon Sep 17 00:00:00 2001 From: Jordan Montgomery Date: Wed, 17 Jun 2026 15:46:36 -0400 Subject: [PATCH 16/28] Add activity --- server/fleet/activities.go | 9 +++++ server/service/appconfig.go | 56 ++++++++++++++++++++++++++------ server/service/appconfig_test.go | 46 ++++++++++++++++++++++---- 3 files changed, 94 insertions(+), 17 deletions(-) diff --git a/server/fleet/activities.go b/server/fleet/activities.go index 0ca5689ab71..a84edb8d8d5 100644 --- a/server/fleet/activities.go +++ b/server/fleet/activities.go @@ -703,6 +703,15 @@ func (a ActivityTypeDisabledGitOpsMode) ActivityName() string { return "disabled_gitops_mode" } +// ActivityTypeEditedAccountProvisioning is emitted whenever the Apple account +// provisioning (Platform SSO) settings actually change. It carries no details: +// the settings are global-only and the IdP client secret must never be logged. +type ActivityTypeEditedAccountProvisioning struct{} + +func (a ActivityTypeEditedAccountProvisioning) ActivityName() string { + return "edited_account_provisioning" +} + type ActivityTypeEnabledGitOpsException struct { Exception string `json:"exception"` } diff --git a/server/service/appconfig.go b/server/service/appconfig.go index e678fae2140..c47fe50e5e0 100644 --- a/server/service/appconfig.go +++ b/server/service/appconfig.go @@ -476,29 +476,53 @@ func applyAndValidateConditionalAccessOktaFields( // persistAppleAccountProvisioningSecret stores, preserves, or soft-deletes the // Apple account provisioning IdP client secret in mdm_config_assets so it -// matches the (already-validated) incoming config. The secret is only -// soft-deleted when the feature is cleared or the secret genuinely changes: -// - configured + a new secret provided: insert, or soft-delete + insert when -// the value differs (no-op when unchanged). -// - configured + no new secret: preserve the existing secret. +// matches the (already-validated) incoming config. It reports whether the +// stored secret actually changed, so re-applying a GitOps config that resends an +// identical secret is a no-op and emits no activity. +// - configured + a new secret provided: store it (replacing any existing +// value), reporting changed only when the value actually differs. +// - configured + no new secret: preserve the existing secret (unchanged). // - feature cleared (was configured, now isn't): soft-delete the secret. -func (svc *Service) persistAppleAccountProvisioningSecret(ctx context.Context, configured, wasConfigured, newSecretProvided bool, secret string) error { +func (svc *Service) persistAppleAccountProvisioningSecret(ctx context.Context, configured, wasConfigured, newSecretProvided bool, secret string) (changed bool, err error) { switch { case configured && newSecretProvided: + current, err := svc.appleAccountProvisioningSecret(ctx) + if err != nil { + return false, err + } + if current == secret { + return false, nil + } if err := svc.ds.InsertOrReplaceMDMConfigAsset(ctx, fleet.MDMConfigAsset{ Name: fleet.MDMAssetAppleAccountProvisioningIdPClientSecret, Value: []byte(secret), }); err != nil { - return ctxerr.Wrap(ctx, err, "store apple account provisioning idp client secret") + return false, ctxerr.Wrap(ctx, err, "store apple account provisioning idp client secret") } + return true, nil case !configured && wasConfigured: if err := svc.ds.DeleteMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{ fleet.MDMAssetAppleAccountProvisioningIdPClientSecret, }); err != nil { - return ctxerr.Wrap(ctx, err, "delete apple account provisioning idp client secret") + return false, ctxerr.Wrap(ctx, err, "delete apple account provisioning idp client secret") } + return true, nil } - return nil + return false, nil +} + +// appleAccountProvisioningSecret returns the stored Apple account provisioning +// IdP client secret, or "" if none is stored. +func (svc *Service) appleAccountProvisioningSecret(ctx context.Context) (string, error) { + assets, err := svc.ds.GetAllMDMConfigAssetsByName(ctx, + []fleet.MDMAssetName{fleet.MDMAssetAppleAccountProvisioningIdPClientSecret}, nil) + if err != nil { + if fleet.IsNotFound(err) { + return "", nil + } + return "", ctxerr.Wrap(ctx, err, "get apple account provisioning idp client secret") + } + return string(assets[fleet.MDMAssetAppleAccountProvisioningIdPClientSecret].Value), nil } func (svc *Service) ModifyAppConfig(ctx context.Context, p []byte, applyOpts fleet.ApplySpecOptions) (*fleet.AppConfig, error) { @@ -1071,14 +1095,26 @@ func (svc *Service) ModifyAppConfig(ctx context.Context, p []byte, applyOpts fle appConfig.FleetDesktop.AlternativeBrowserHost = "" } - if err := svc.persistAppleAccountProvisioningSecret(ctx, mergedAAP.Configured(), oldAAP.Configured(), newAAPSecretProvided, newAAPSecret); err != nil { + aapSecretChanged, err := svc.persistAppleAccountProvisioningSecret(ctx, mergedAAP.Configured(), oldAAP.Configured(), newAAPSecretProvided, newAAPSecret) + if err != nil { return nil, err } + // The IdP client secret never reaches the AppConfig JSON, so a secret-only + // change isn't visible in the saved config diff — track it separately. + aapChanged := aapSecretChanged || + mergedAAP.OAuthIdPTokenURL.Value != oldAAP.OAuthIdPTokenURL.Value || + mergedAAP.OAuthIdPClientID.Value != oldAAP.OAuthIdPClientID.Value if err := svc.ds.SaveAppConfig(ctx, appConfig); err != nil { return nil, err } + if aapChanged { + if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), fleet.ActivityTypeEditedAccountProvisioning{}); err != nil { + return nil, ctxerr.Wrapf(ctx, err, "create activity %s", fleet.ActivityTypeEditedAccountProvisioning{}.ActivityName()) + } + } + // Best-effort: drop orphan blobs whose URL was just replaced with an // external or empty value. Mirrors the explicit DELETE /logo endpoint's // audit signal by emitting a deleted_org_logo activity per mode that diff --git a/server/service/appconfig_test.go b/server/service/appconfig_test.go index 5a2f20ae1bb..35ab35c0ab6 100644 --- a/server/service/appconfig_test.go +++ b/server/service/appconfig_test.go @@ -25,6 +25,7 @@ import ( nanodep_mock "github.com/fleetdm/fleet/v4/server/mock/nanodep" "github.com/fleetdm/fleet/v4/server/ptr" "github.com/fleetdm/fleet/v4/server/test" + "github.com/jmoiron/sqlx" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -1722,19 +1723,22 @@ func TestModifyAppConfigAppleAccountProvisioning(t *testing.T) { deleted bool // DeleteMDMConfigAssetsByName expected wantErr string // non-empty => ModifyAppConfig should fail containing this wantMasked bool // response secret should be the masked placeholder + wantActivity bool // edited_account_provisioning activity expected } type trackers struct { insertedSecret *string deleted bool saved *fleet.AppConfig + activityFired bool } setup := func(t *testing.T, tier string, stored fleet.AppleAccountProvisioning) (fleet.Service, context.Context, *mock.Store, *trackers) { ds := new(mock.Store) cfg := config.TestConfig() cfg.Server.PrivateKey = "test-private-key-not-used-by-mock" - svc, ctx := newTestServiceWithConfig(t, ds, cfg, nil, nil, &TestServerOpts{License: &fleet.LicenseInfo{Tier: tier}}) + opts := &TestServerOpts{License: &fleet.LicenseInfo{Tier: tier}} + svc, ctx := newTestServiceWithConfig(t, ds, cfg, nil, nil, opts) ctx = viewer.NewContext(ctx, viewer.Viewer{User: admin}) tr := &trackers{} @@ -1755,6 +1759,16 @@ func TestModifyAppConfigAppleAccountProvisioning(t *testing.T) { ds.ListABMTokensFunc = func(ctx context.Context) ([]*fleet.ABMToken, error) { return []*fleet.ABMToken{}, nil } ds.ListVPPTokensFunc = func(ctx context.Context) ([]*fleet.VPPTokenDB, error) { return []*fleet.VPPTokenDB{}, nil } + // A configured feature implies a stored secret; report it as `secret` so + // resending that value is detected as unchanged. + ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, names []fleet.MDMAssetName, _ sqlx.QueryerContext) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { + if !stored.Configured() { + return nil, newNotFoundError() + } + return map[fleet.MDMAssetName]fleet.MDMConfigAsset{ + fleet.MDMAssetAppleAccountProvisioningIdPClientSecret: {Name: fleet.MDMAssetAppleAccountProvisioningIdPClientSecret, Value: []byte(secret)}, + }, nil + } ds.InsertOrReplaceMDMConfigAssetFunc = func(ctx context.Context, asset fleet.MDMConfigAsset) error { require.Equal(t, fleet.MDMAssetAppleAccountProvisioningIdPClientSecret, asset.Name) v := string(asset.Value) @@ -1766,6 +1780,12 @@ func TestModifyAppConfigAppleAccountProvisioning(t *testing.T) { tr.deleted = true return nil } + opts.ActivityMock.NewActivityFunc = func(_ context.Context, _ *activity_api.User, act activity_api.ActivityDetails) error { + if _, ok := act.(fleet.ActivityTypeEditedAccountProvisioning); ok { + tr.activityFired = true + } + return nil + } return svc, ctx, ds, tr } @@ -1781,7 +1801,7 @@ func TestModifyAppConfigAppleAccountProvisioning(t *testing.T) { name: "configure stores secret and masks response", tier: fleet.TierPremium, body: fmt.Sprintf(`{"mdm":{"apple_account_provisioning":{"oauth_idp_token_url":%q,"oauth_idp_client_id":%q,"oauth_idp_client_secret":%q}}}`, tokenURL, clientID, secret), - want: asserts{insertedSecret: new(secret), wantMasked: true}, + want: asserts{insertedSecret: new(secret), wantMasked: true, wantActivity: true}, }, { name: "free tier rejected", @@ -1813,21 +1833,21 @@ func TestModifyAppConfigAppleAccountProvisioning(t *testing.T) { tier: fleet.TierPremium, stored: configuredAAP(), body: fmt.Sprintf(`{"mdm":{"apple_account_provisioning":{"oauth_idp_token_url":%q,"oauth_idp_client_id":%q,"oauth_idp_client_secret":%q}}}`, tokenURL2, clientID, "rotated-secret"), - want: asserts{insertedSecret: new("rotated-secret"), wantMasked: true}, + want: asserts{insertedSecret: new("rotated-secret"), wantMasked: true, wantActivity: true}, }, { name: "masked secret preserved on unrelated change", tier: fleet.TierPremium, stored: configuredAAP(), body: fmt.Sprintf(`{"mdm":{"apple_account_provisioning":{"oauth_idp_token_url":%q,"oauth_idp_client_id":"new-client-id","oauth_idp_client_secret":%q}}}`, tokenURL, fleet.MaskedPassword), - want: asserts{wantMasked: true}, // neither insert nor delete + want: asserts{wantMasked: true, wantActivity: true}, // client_id changed; secret preserved (neither insert nor delete) }, { name: "clearing config soft-deletes secret", tier: fleet.TierPremium, stored: configuredAAP(), body: `{"mdm":{"apple_account_provisioning":{"oauth_idp_token_url":"","oauth_idp_client_id":"","oauth_idp_client_secret":""}}}`, - want: asserts{deleted: true}, + want: asserts{deleted: true, wantActivity: true}, }, { name: "public fields without secret rejected", @@ -1858,7 +1878,7 @@ func TestModifyAppConfigAppleAccountProvisioning(t *testing.T) { tier: fleet.TierPremium, overwrite: true, body: fmt.Sprintf(`{"mdm":{"apple_account_provisioning":{"oauth_idp_token_url":%q,"oauth_idp_client_id":%q,"oauth_idp_client_secret":%q}}}`, tokenURL, clientID, secret), - want: asserts{insertedSecret: new(secret), wantMasked: true}, + want: asserts{insertedSecret: new(secret), wantMasked: true, wantActivity: true}, }, { name: "gitops public fields without secret rejected", @@ -1883,7 +1903,16 @@ func TestModifyAppConfigAppleAccountProvisioning(t *testing.T) { overwrite: true, stored: configuredAAP(), body: `{"mdm":{}}`, - want: asserts{deleted: true}, + want: asserts{deleted: true, wantActivity: true}, + }, + { + // No actual change: same public fields and same secret value. The + // stored secret is left untouched and no activity is emitted. + name: "reapply identical config emits no activity", + tier: fleet.TierPremium, + stored: configuredAAP(), + body: fmt.Sprintf(`{"mdm":{"apple_account_provisioning":{"oauth_idp_token_url":%q,"oauth_idp_client_id":%q,"oauth_idp_client_secret":%q}}}`, tokenURL, clientID, secret), + want: asserts{wantMasked: true}, // no insert, no delete, no activity }, } @@ -1897,6 +1926,7 @@ func TestModifyAppConfigAppleAccountProvisioning(t *testing.T) { require.Contains(t, err.Error(), tc.want.wantErr) require.False(t, ds.InsertOrReplaceMDMConfigAssetFuncInvoked) require.False(t, ds.DeleteMDMConfigAssetsByNameFuncInvoked) + require.False(t, tr.activityFired) return } require.NoError(t, err) @@ -1916,6 +1946,8 @@ func TestModifyAppConfigAppleAccountProvisioning(t *testing.T) { if tc.want.wantMasked { require.Equal(t, fleet.MaskedPassword, modified.MDM.AppleAccountProvisioning.OAuthIdPClientSecret.Value) } + + require.Equal(t, tc.want.wantActivity, tr.activityFired) }) } } From f34f6999f87d8665d9f765562e7d7b8789efae3e Mon Sep 17 00:00:00 2001 From: Jordan Montgomery Date: Wed, 17 Jun 2026 16:06:30 -0400 Subject: [PATCH 17/28] Update PSSO research doc, removed remediated findings --- docs/Contributing/research/mdm/psso.md | 40 +------------------------- 1 file changed, 1 insertion(+), 39 deletions(-) diff --git a/docs/Contributing/research/mdm/psso.md b/docs/Contributing/research/mdm/psso.md index 5d7d2c3385a..df78ed47575 100644 --- a/docs/Contributing/research/mdm/psso.md +++ b/docs/Contributing/research/mdm/psso.md @@ -115,7 +115,7 @@ The nonce store mirrors `server/mdm/acme/internal/redis_nonces_store/` and expos ### JWKS signing key bootstrap timing (RESOLVED in #47122) -The signing key is no longer lazily minted. Both it (`MDMAssetPSSOSigningKey`) and the self-signed PSSO CA (`MDMAssetPSSOCACert`, backed by the same private key) are created once, the first time the feature is configured, via `bootstrapPSSOAssets` in `ModifyAppConfig` (covering the config API and GitOps). The bootstrap is idempotent and never regenerates existing assets, so the JWKS key and CA stay stable across reconfiguration and disable/re-enable; the device-facing service methods now only ever load them. Since the feature is still experimental, no upgrade path is provided for POC instances that minted a key under the old lazy path — re-saving the PSSO config generates the CA over the existing key. +The signing key is no longer lazily minted. Both it (`MDMAssetPSSOSigningKey`) and the self-signed PSSO CA (`MDMAssetPSSOCACert`, backed by the same private key) are created once, the first time the feature is configured, via `bootstrapPSSOAssets` in `ModifyAppConfig` (covering the config API and GitOps). The bootstrap is idempotent and never regenerates existing assets, so the JWKS key and CA stay stable across reconfiguration and disable/re-enable; the device-facing service methods now only ever load them. ### In-tree Swift extension at `apple-sso-extension/` @@ -210,48 +210,10 @@ A security review of the POC (covering the implementation and these productioniz The crypto was otherwise found sound: no passwords/refresh-tokens/client-secrets in logs or errors; SQL fully parameterized; JWE GCM nonces random with the protected header as AAD; `canonicalizeKID` consistent across store and lookup (no key aliasing); attacker-supplied `other_publickey` is curve-validated via `crypto/ecdh`; clean-room provenance intact (JOSE primitives only, no third-party PSSO SDK). -### CRITICAL [deploy] — IdP client secret disclosed via the config API - -`AppConfig.Obfuscate()` (`server/fleet/app.go`) masks SMTP/Jira/Zendesk/etc. secrets but has no case for `PSSOSettings`, so `GET /api/v1/fleet/config` returns `psso_settings.idp_client_secret` in cleartext to any caller with config read. A low-privilege user could lift the upstream IdP OAuth client credentials and use them directly against the customer's tenant. This is a live disclosure on the existing endpoint, not the cosmetic "mask in the UI" task framed under *Admin configuration* above. Fix: add a `PSSOSettings` case to `Obfuscate()`, and mirror the SMTP "keep existing secret when the client submits the mask" logic on the config write path so a PATCH echoing `********` doesn't clobber the stored secret. - -### HIGH [deploy] — `PSSOSettings.Enabled` is never enforced - -No PSSO service method consults `cfg.PSSOSettings.Enabled`; the unauthenticated `/mdm/apple/psso/*` surface is live on every licensed instance even when an admin never enabled (or explicitly disabled) PSSO. Gate the device-facing methods (`PSSONonce`, `PSSORegisterComplete`, `PSSOToken`, and arguably JWKS/AASA) on `Enabled` in the service layer so all entry points are covered. - -**Addressed in #46942.** Every PSSO service method now checks the live `AppConfig.PSSOSettings` per request (`pssoSettingsIfConfigured`): nonce/registration/token return 400 and JWKS/AASA return 404 when the feature is disabled or incompletely configured. - -### HIGH [deploy] — Unbounded replay of token requests - -The verified JWT parse validates `exp`/`nbf` only if present, and inbound request JWTs are not required to carry an `exp` (nor is one enforced). The sole anti-replay control, `request_nonce`, is consumed best-effort — a miss is logged, not rejected (`ee/server/service/apple_psso.go`, `handlePSSOPasswordLogin`). A captured login-request JWS can therefore be replayed indefinitely to re-trigger IdP password validation and yield a fresh valid login-response JWE. This supersedes the milder "best-effort nonce, fix before GA" framing — the practical state is unbounded replay of a credential-validating request. Fix: require a short-lived `exp` (and an `iat` max-age) on inbound JWTs, and hard-enforce single-use `request_nonce` (reject when the store rejects). - -**Partially addressed in #46942.** `request_nonce` is now hard-enforced and consumed before dispatch for all token flows (password login, key request, key exchange) — a replayed JWS is rejected. Requiring `exp`/`iat` max-age on inbound JWTs remains open (#47122 covers JWT validation cleanup). - ### HIGH [deploy] — Key replacement on registration enables device takeover Compounds the unauthenticated-registration limitation. `SetOrUpdatePSSODevice` (`server/datastore/mysql/apple_psso.go`) deletes a host's existing `key_ids` and inserts the caller's on a plain upsert. The `IOPlatformUUID` is not secret (it appears in osquery results, MDM inventory, logs), so an unauthenticated attacker who knows it can *overwrite* a legitimate device's registered signing/encryption keys and then drive `/token` as that host. The planned per-device registration token closes the spoofing primitive, but the key-replacement semantics are a separate decision: once a host has a registration, require the per-host token to match before replacing keys, and log/emit an activity on key replacement (rotation vs. takeover). -### HIGH [GA] — Inbound JWT algorithm not pinned - -`parsePSSOInboundJWT` (`ee/server/service/apple_psso_crypto.go`) calls `jwt.ParseWithClaims` without `jwt.WithValidMethods` and the keyfunc returns the EC key without asserting `token.Method`. Not exploitable as written (golang-jwt v4's type assertions reject HS/`none` against an `*ecdsa.PublicKey`), but it is one refactor away from an alg-confusion forgery. Fix: pass `jwt.WithValidMethods([]string{"ES256"})` and assert `*jwt.SigningMethodECDSA` in the keyfunc. Cheap hardening. - -**Addressed in #47122.** `parsePSSOInboundJWT` now passes `jwt.WithValidMethods([]string{"ES256"})` and asserts `*jwt.SigningMethodECDSA` in the keyfunc; HS256 and `none` tokens are rejected. - -### MEDIUM [deploy] — ROPG client is an SSRF / credential-redirection sink - -`idp_token_url` comes from admin-controlled `AppConfig` and is POSTed to with the user's plaintext password. There is no scheme/host validation, so whoever can edit config (or a future settings UI lacking validation) can point it at an internal address and harvest passwords. Validate `https://` and a non-internal host at config-write time, and confirm `fleethttp.NewClient()` enforces TLS verification for this client. Pair this validation with the live-reload work under *Admin configuration*. - -### MEDIUM [GA → now-active] — `key_context` is not bound to device or expiry - -The provisioned private key sealed into `key_context` (key request) is recoverable by any registered device replaying any captured `key_context`, and the blob carries no TTL (its payload `exp` is advisory and not re-checked on exchange). It is also sealed under a key HKDF-derived from the long-lived PSSO signing key, so signing-key rotation/re-mint silently invalidates all outstanding contexts, and a signing-key leak compromises every context ever issued (no forward secrecy). **Note:** the review rated this deferrable on the assumption the key-request/key-exchange path was not exercised by the Password flow — that is no longer true; the unlock-key exchange now runs during Password-mode registration, so treat this as active. Fix: bind `key_context` to the device `kid` and an expiry inside the sealed plaintext (e.g. as AAD), and reject on open if mismatched or expired. - -**Device binding addressed in #47122.** `key_context` now seals a structured JSON plaintext — `{host_uuid, key_purpose, provisioned_key}` — instead of the bare private key, and key exchange rejects when the sealed `host_uuid` doesn't match the host resolved from the request's signing key (or when `key_purpose` isn't `user_unlock`). A captured context replayed by, or fetched onto, another device is rejected. An in-blob expiry was considered but deliberately left out for now to match the issue's specified shape; the forward-secrecy / signing-key-coupling concerns remain open. - -### MEDIUM [GA] — Ad-hoc CA certificate issuance is sloppy - -`issuePSSOProvisionedCertificate` (`ee/server/service/apple_psso.go`) regenerates a self-signed, unconstrained, 10-year signing CA on every key request with fixed serial `1`. Generate the CA once (persist alongside the signing key), use random serials for both CA and leaf, and add EKU/name constraints scoping its use. - -**Addressed in #47122.** The CA is now minted once at first configuration and persisted (`MDMAssetPSSOCACert`); `issuePSSOProvisionedCertificate` loads it and signs each leaf with a random 128-bit serial. The CA keeps serial `1` (matching Fleet's other self-signed CA roots in `server/mdm/scep/depot`): a singular, persisted self-signed root produces exactly one certificate, so the serial is unique by construction — the random-serial recommendation applied to the per-request *leaf*, which it now uses. The CA carries `BasicConstraintsValid`, `IsCA`, `MaxPathLen: 0`, and a SubjectKeyId. - ### LOW [GA] — Hardcoded developer Team/bundle IDs in the AASA `teamID*`/`bundleID*` constants (`ee/server/service/apple_psso.go`) are baked into the public, always-served `apple-app-site-association`. They leak Fleet-developer identifiers and mis-bind for any deployer using their own signing identity. Make them config-driven before GA. From 4d0162993bd57feeda3675f7005d4b57f9ffab20 Mon Sep 17 00:00:00 2001 From: Jordan Montgomery Date: Wed, 17 Jun 2026 16:26:36 -0400 Subject: [PATCH 18/28] Update doc --- docs/Contributing/research/mdm/psso.md | 4 ++-- server/service/apple_psso.go | 24 ++++++++---------------- 2 files changed, 10 insertions(+), 18 deletions(-) diff --git a/docs/Contributing/research/mdm/psso.md b/docs/Contributing/research/mdm/psso.md index df78ed47575..b9db36a9468 100644 --- a/docs/Contributing/research/mdm/psso.md +++ b/docs/Contributing/research/mdm/psso.md @@ -113,9 +113,9 @@ The nonce store mirrors `server/mdm/acme/internal/redis_nonces_store/` and expos - `mdm_apple_psso_devices` — primary key `host_id`, stores the device's signing and encryption public keys (PEM), the negotiated KeyExchangeKey, and registration/update timestamps. - `mdm_apple_psso_key_ids` — primary key `kid`, foreign key `host_id`, plus `key_type` and `pem`. The extension references keys by SHA-256 hash of the public key, so the server needs an index keyed by that hash to resolve incoming requests back to a device. -### JWKS signing key bootstrap timing (RESOLVED in #47122) +### JWKS signing key bootstrap -The signing key is no longer lazily minted. Both it (`MDMAssetPSSOSigningKey`) and the self-signed PSSO CA (`MDMAssetPSSOCACert`, backed by the same private key) are created once, the first time the feature is configured, via `bootstrapPSSOAssets` in `ModifyAppConfig` (covering the config API and GitOps). The bootstrap is idempotent and never regenerates existing assets, so the JWKS key and CA stay stable across reconfiguration and disable/re-enable; the device-facing service methods now only ever load them. +The signing key(`MDMAssetPSSOSigningKey`) and the self-signed PSSO CA (`MDMAssetPSSOCACert`, backed by the same private key) are created once, the first time the feature is configured, via `bootstrapPSSOAssets` in `ModifyAppConfig` (covering the config API and GitOps). The bootstrap is idempotent and never regenerates existing assets, so the JWKS key and CA stay stable across reconfiguration and disable/re-enable. ### In-tree Swift extension at `apple-sso-extension/` diff --git a/server/service/apple_psso.go b/server/service/apple_psso.go index be5aad763a8..847fd882ba8 100644 --- a/server/service/apple_psso.go +++ b/server/service/apple_psso.go @@ -48,11 +48,8 @@ type pssoNonceRequest struct{} // DecodeBody drains and discards the request body. Apple's AppSSOAgent POSTs a // urlencoded grant_type=srv_challenge form to the nonce endpoint, but Fleet -// needs nothing from it — it just mints a nonce. Draining (rather than leaving -// it unread) keeps the connection reusable; the reader is already size-limited -// by the endpointer. The method must exist so the endpoint framework routes the -// form body here instead of falling through to JSON decoding, which rejects the -// form as malformed. +// needs nothing from it — it just mints a nonce. This method must exist so the +// endpoint framework routes the form body here instead of trying to decode as JSON. func (pssoNonceRequest) DecodeBody(_ context.Context, r io.Reader, _ url.Values, _ []*x509.Certificate) error { _, _ = io.Copy(io.Discard, r) return nil @@ -275,19 +272,14 @@ func (svc *Service) PSSOAASA(ctx context.Context) ([]byte, error) { // core (callable from ModifyAppConfig) rather than in ee. The ee service only // loads them back, using the standard PEM encodings written below. -// pssoCAValidYears is the lifetime of the self-signed Platform SSO CA. It's -// minted once, when the feature is first configured, and reused for its whole -// life — long enough that it never needs rotation during normal operation. +// pssoCAValidYears is the lifetime of the self-signed Platform SSO CA, matching +// other CAs in fleet and minted once, when the feature is first configured. const pssoCAValidYears = 10 -// bootstrapPSSOAssets ensures the Platform SSO signing key and its CA -// certificate exist in mdm_config_assets, creating whichever is missing. It runs -// when the feature is configured (covering both the config API and GitOps, which -// both flow through ModifyAppConfig) and is idempotent: existing assets are never -// regenerated, so the signing key (published via JWKS) and the CA stay stable -// across reconfiguration and across disable/re-enable. The CA is self-signed by -// the signing key — they share one private key — so the CA certificate is the -// only new asset. +// bootstrapPSSOAssets ensures the Platform SSO signing key and its CA certificate +// (which is signed by the signing key) exist in mdm_config_assets. It runs when the +// feature is configured and is idempotent: existing assets are never regenerated, so +// the signing key (published via JWKS) and the CA remain stable. func bootstrapPSSOAssets(ctx context.Context, ds fleet.Datastore) error { assets, err := ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{fleet.MDMAssetPSSOSigningKey, fleet.MDMAssetPSSOCACert}, From 6f04ba807378e5a9385b639a57e00024cf522f45 Mon Sep 17 00:00:00 2001 From: Jordan Montgomery Date: Wed, 17 Jun 2026 16:56:58 -0400 Subject: [PATCH 19/28] Fix tests --- server/service/appconfig_test.go | 6 ++++++ server/service/apple_psso.go | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/server/service/appconfig_test.go b/server/service/appconfig_test.go index 35ab35c0ab6..99f61aee1a1 100644 --- a/server/service/appconfig_test.go +++ b/server/service/appconfig_test.go @@ -1775,6 +1775,12 @@ func TestModifyAppConfigAppleAccountProvisioning(t *testing.T) { tr.insertedSecret = &v return nil } + // Configuring the feature triggers bootstrapPSSOAssets, which mints and + // inserts the PSSO signing key + CA. The secret assertions don't touch + // these, so a no-op stub is enough to keep the bootstrap from panicking. + ds.InsertMDMConfigAssetsFunc = func(ctx context.Context, _ []fleet.MDMConfigAsset, _ sqlx.ExtContext) error { + return nil + } ds.DeleteMDMConfigAssetsByNameFunc = func(ctx context.Context, names []fleet.MDMAssetName) error { require.Equal(t, []fleet.MDMAssetName{fleet.MDMAssetAppleAccountProvisioningIdPClientSecret}, names) tr.deleted = true diff --git a/server/service/apple_psso.go b/server/service/apple_psso.go index 847fd882ba8..bb9ff78dadb 100644 --- a/server/service/apple_psso.go +++ b/server/service/apple_psso.go @@ -269,7 +269,7 @@ func (svc *Service) PSSOAASA(ctx context.Context) ([]byte, error) { // ----- PSSO asset bootstrap ------------------------------------------------- // // The signing key and CA are pure crypto + datastore work, so they live here in -// core (callable from ModifyAppConfig) rather than in ee. The ee service only +// core (callable from ModifyAppConfig) rather than in ee/. The ee service only // loads them back, using the standard PEM encodings written below. // pssoCAValidYears is the lifetime of the self-signed Platform SSO CA, matching From 20376c9feec8ad4c8d2bb7b0228fc33dc3bd002f Mon Sep 17 00:00:00 2001 From: Jordan Montgomery Date: Thu, 18 Jun 2026 09:09:09 -0400 Subject: [PATCH 20/28] Fix potential secret unmasking --- server/fleet/app.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/fleet/app.go b/server/fleet/app.go index bd0208a4d5c..4c3c0f302cd 100644 --- a/server/fleet/app.go +++ b/server/fleet/app.go @@ -809,7 +809,7 @@ func (c *AppConfig) Obfuscate() { // mdm_config_assets, never in the AppConfig JSON. Surface the masked value // whenever the feature is configured (token URL present implies a stored // secret), so the API never leaks it but still signals it's set. - if c.MDM.AppleAccountProvisioning.Configured() { + if c.MDM.AppleAccountProvisioning.Configured() || c.MDM.AppleAccountProvisioning.OAuthIdPClientSecret.Value != "" { c.MDM.AppleAccountProvisioning.OAuthIdPClientSecret = optjson.SetString(MaskedPassword) } // // TODO(hca): confirm that we're properly masking credentials in the new endpoints From fb768387119a6d37597b3c6208d6169f3665f8e4 Mon Sep 17 00:00:00 2001 From: Jordan Montgomery Date: Thu, 18 Jun 2026 15:34:34 -0400 Subject: [PATCH 21/28] Cleanup bootstrap a bit --- server/service/apple_psso.go | 13 +++++++++++-- server/service/apple_psso_test.go | 4 ++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/server/service/apple_psso.go b/server/service/apple_psso.go index bb9ff78dadb..e8ff07297e2 100644 --- a/server/service/apple_psso.go +++ b/server/service/apple_psso.go @@ -291,12 +291,21 @@ func bootstrapPSSOAssets(ctx context.Context, ds fleet.Datastore) error { return ctxerr.Wrap(ctx, err, "load psso assets") } - _, haveKey := assets[fleet.MDMAssetPSSOSigningKey] - _, haveCA := assets[fleet.MDMAssetPSSOCACert] + haveKey := false + haveCA := false + if assets != nil { + _, haveKey = assets[fleet.MDMAssetPSSOSigningKey] + _, haveCA = assets[fleet.MDMAssetPSSOCACert] + } if haveKey && haveCA { return nil } + // Throw an error because this is an inconsistent state - the CA was created apparently with a different signing key? + if haveCA && !haveKey { + return ctxerr.New(ctx, "psso ca certificate exists but signing key is missing") + } + signingKey, err := pssoSigningKeyFromAssets(assets) if err != nil { return ctxerr.Wrap(ctx, err, "parse existing psso signing key") diff --git a/server/service/apple_psso_test.go b/server/service/apple_psso_test.go index 691766a853c..09fa407c7fe 100644 --- a/server/service/apple_psso_test.go +++ b/server/service/apple_psso_test.go @@ -5,6 +5,7 @@ import ( "crypto/ecdsa" "crypto/x509" "encoding/pem" + "errors" "net/http/httptest" "net/url" "strings" @@ -104,6 +105,9 @@ func pssoBootstrapMock(store map[fleet.MDMAssetName]fleet.MDMConfigAsset) *mock. 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 { From 1eb6cac715f8920b860c67d7ae76dcd8e0b13260 Mon Sep 17 00:00:00 2001 From: Jordan Montgomery Date: Thu, 18 Jun 2026 16:06:13 -0400 Subject: [PATCH 22/28] Combine fleet desktop and SSO ext --- .../workflows/fleet-desktop-macos-build.yml | 68 ++- .../AppIcon.appiconset/Contents.json | 68 --- .../AppIcon.appiconset/icon_128x128.png | Bin 5172 -> 0 bytes .../AppIcon.appiconset/icon_128x128@2x.png | Bin 9811 -> 0 bytes .../AppIcon.appiconset/icon_16x16.png | Bin 1355 -> 0 bytes .../AppIcon.appiconset/icon_16x16@2x.png | Bin 1844 -> 0 bytes .../AppIcon.appiconset/icon_256x256.png | Bin 9811 -> 0 bytes .../AppIcon.appiconset/icon_256x256@2x.png | Bin 22211 -> 0 bytes .../AppIcon.appiconset/icon_32x32.png | Bin 1844 -> 0 bytes .../AppIcon.appiconset/icon_32x32@2x.png | Bin 2837 -> 0 bytes .../AppIcon.appiconset/icon_512x512.png | Bin 22211 -> 0 bytes .../AppIcon.appiconset/icon_512x512@2x.png | Bin 51356 -> 0 bytes .../Assets.xcassets/Contents.json | 6 - .../Fleet PSSO.xcodeproj/project.pbxproj | 423 ------------------ .../FleetPSSO/AppDelegate.swift | 27 -- .../FleetPSSO/FleetPSSO.entitlements | 18 - apple-sso-extension/FleetPSSO/Info.plist | 26 -- .../FleetPSSOExtension.entitlements | 20 - .../FleetPSSOExtension/Info.plist | 28 -- apple-sso-extension/README.md | 144 ------ apple-sso-extension/build.sh | 88 ---- ...henticationViewController+Networking.swift | 0 .../AuthenticationViewController+PSSO.swift | 0 .../AuthenticationViewController+Shared.swift | 0 .../AuthenticationViewController.swift | 0 apps/fleet-desktop-macos/README.md | 126 +++++- apps/fleet-desktop-macos/build.sh | 53 ++- .../fleet-sso-extension-example.mobileconfig | 4 +- docs/Contributing/research/mdm/psso.md | 4 +- ee/server/service/apple_psso.go | 27 +- 30 files changed, 225 insertions(+), 905 deletions(-) delete mode 100644 apple-sso-extension/Assets.xcassets/AppIcon.appiconset/Contents.json delete mode 100644 apple-sso-extension/Assets.xcassets/AppIcon.appiconset/icon_128x128.png delete mode 100644 apple-sso-extension/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png delete mode 100644 apple-sso-extension/Assets.xcassets/AppIcon.appiconset/icon_16x16.png delete mode 100644 apple-sso-extension/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png delete mode 100644 apple-sso-extension/Assets.xcassets/AppIcon.appiconset/icon_256x256.png delete mode 100644 apple-sso-extension/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png delete mode 100644 apple-sso-extension/Assets.xcassets/AppIcon.appiconset/icon_32x32.png delete mode 100644 apple-sso-extension/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png delete mode 100644 apple-sso-extension/Assets.xcassets/AppIcon.appiconset/icon_512x512.png delete mode 100644 apple-sso-extension/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png delete mode 100644 apple-sso-extension/Assets.xcassets/Contents.json delete mode 100644 apple-sso-extension/Fleet PSSO.xcodeproj/project.pbxproj delete mode 100644 apple-sso-extension/FleetPSSO/AppDelegate.swift delete mode 100644 apple-sso-extension/FleetPSSO/FleetPSSO.entitlements delete mode 100644 apple-sso-extension/FleetPSSO/Info.plist delete mode 100644 apple-sso-extension/FleetPSSOExtension/FleetPSSOExtension.entitlements delete mode 100644 apple-sso-extension/FleetPSSOExtension/Info.plist delete mode 100644 apple-sso-extension/README.md delete mode 100755 apple-sso-extension/build.sh rename {apple-sso-extension => apps/fleet-desktop-macos}/FleetPSSOExtension/AuthenticationViewController+Networking.swift (100%) rename {apple-sso-extension => apps/fleet-desktop-macos}/FleetPSSOExtension/AuthenticationViewController+PSSO.swift (100%) rename {apple-sso-extension => apps/fleet-desktop-macos}/FleetPSSOExtension/AuthenticationViewController+Shared.swift (100%) rename {apple-sso-extension => apps/fleet-desktop-macos}/FleetPSSOExtension/AuthenticationViewController.swift (100%) rename {apple-sso-extension => apps/fleet-desktop-macos}/fleet-sso-extension-example.mobileconfig (94%) diff --git a/.github/workflows/fleet-desktop-macos-build.yml b/.github/workflows/fleet-desktop-macos-build.yml index 8c04d8a554c..f932f7d91ea 100644 --- a/.github/workflows/fleet-desktop-macos-build.yml +++ b/.github/workflows/fleet-desktop-macos-build.yml @@ -1,8 +1,20 @@ name: Build Fleet Desktop (macOS) -# Builds the native macOS Fleet Desktop app (apps/fleet-desktop-macos/), code signs -# and notarizes it with Fleet's Developer ID certificates, and uploads the signed +# Builds the native macOS Fleet Desktop app (apps/fleet-desktop-macos/) and its +# embedded Platform SSO extension (FleetPSSOExtension.appex), code signs and +# notarizes them with Fleet's Developer ID certificates, and uploads the signed # .pkg as a workflow artifact. No GitHub Release is created. +# +# The app and extension carry managed Associated Domains entitlements +# (com.apple.developer.associated-domains{,.mdm-managed}). Those are restricted +# entitlements: codesign only honors them when a Developer ID provisioning +# profile that grants them is embedded in the bundle. The profiles are provided +# as base64 repo secrets (never committed) and embedded at sign time — see the +# README's "Signing secrets" section. +# +# This workflow always signs and notarizes. If the certs or provisioning +# profiles are unavailable (e.g. a fork PR that can't read secrets), it fails +# loudly rather than producing an unsigned artifact. on: push: @@ -36,6 +48,8 @@ env: # of Fleet's macOS artifacts (orbit Fleet Desktop, fleetd-base.pkg). APPLICATION_SIGNING_IDENTITY_SHA1: 604D877399AAEB7630A78B84F288E2D28A2EDE42 INSTALLER_SIGNING_IDENTITY_SHA1: 4608F71FB42E1845C7FC9B2D2B6A7A8D11BBD940 + # Embedded SSO extension bundle (relative to Fleet Desktop.app/Contents). + APPEX_REL_PATH: PlugIns/FleetPSSOExtension.appex jobs: build: @@ -52,7 +66,7 @@ jobs: with: persist-credentials: false - - name: Build app and create pkg + - name: Build app (with embedded extension) and create pkg run: | chmod +x build.sh build-pkg.sh ./build-pkg.sh @@ -69,7 +83,7 @@ jobs: security default-keychain -s build.keychain security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain - # Developer ID Application certificate — signs the .app (codesign). + # Developer ID Application certificate — signs the .app/.appex (codesign). echo "$APPLE_APPLICATION_CERTIFICATE" | base64 --decode > application.p12 security import application.p12 -k build.keychain -P "$APPLE_APPLICATION_CERTIFICATE_PASSWORD" -T /usr/bin/codesign rm application.p12 @@ -82,24 +96,51 @@ jobs: security set-key-partition-list -S apple-tool:,apple:,codesign:,productsign: -s -k "$KEYCHAIN_PASSWORD" build.keychain security find-identity -vv - - name: Code sign app + - name: Embed provisioning profiles + env: + APPLE_FLEET_DESKTOP_APP_PROFILE_B64: ${{ secrets.APPLE_FLEET_DESKTOP_APP_PROFILE_B64 }} + APPLE_PSSO_EXT_PROFILE_B64: ${{ secrets.APPLE_PSSO_EXT_PROFILE_B64 }} run: | - BINARY_PATH="build/Fleet Desktop.app/Contents/MacOS/FleetDesktop" + APP="build/Fleet Desktop.app" + APPEX="$APP/Contents/$APPEX_REL_PATH" + + if [ -z "$APPLE_FLEET_DESKTOP_APP_PROFILE_B64" ] || [ -z "$APPLE_PSSO_EXT_PROFILE_B64" ]; then + echo "::error::Missing provisioning profile secrets (APPLE_FLEET_DESKTOP_APP_PROFILE_B64 / APPLE_PSSO_EXT_PROFILE_B64). The app and extension carry restricted Associated Domains entitlements that codesign cannot honor without them." + exit 1 + fi + + # Developer ID profiles authorizing the restricted entitlements. codesign + # validates the profile against the entitlements file at sign time. + echo "$APPLE_PSSO_EXT_PROFILE_B64" | base64 --decode > "$APPEX/Contents/embedded.provisionprofile" + echo "$APPLE_FLEET_DESKTOP_APP_PROFILE_B64" | base64 --decode > "$APP/Contents/embedded.provisionprofile" - # Sign the universal binary first, then the bundle (no --deep). + - name: Code sign app and extension + run: | + APP="build/Fleet Desktop.app" + APPEX="$APP/Contents/$APPEX_REL_PATH" + + # Sign inside-out: the embedded extension first, then the host app. + # Each bundle is sealed with its own entitlements + embedded profile. codesign --force --sign "$APPLICATION_SIGNING_IDENTITY_SHA1" \ - --options runtime --timestamp "$BINARY_PATH" - codesign --verify --verbose "$BINARY_PATH" + --options runtime --timestamp "$APPEX/Contents/MacOS/FleetPSSOExtension" + codesign --force --sign "$APPLICATION_SIGNING_IDENTITY_SHA1" \ + --options runtime --timestamp \ + --entitlements FleetPSSOExtension/FleetPSSOExtension.entitlements "$APPEX" codesign --force --sign "$APPLICATION_SIGNING_IDENTITY_SHA1" \ - --options runtime --timestamp "build/Fleet Desktop.app" + --options runtime --timestamp "$APP/Contents/MacOS/FleetDesktop" + codesign --force --sign "$APPLICATION_SIGNING_IDENTITY_SHA1" \ + --options runtime --timestamp \ + --entitlements FleetDesktop/FleetDesktop.entitlements "$APP" - codesign --verify --deep --strict --verbose=2 "build/Fleet Desktop.app" - codesign --display --verbose=4 "build/Fleet Desktop.app" + codesign --verify --deep --strict --verbose=2 "$APP" + codesign --display --verbose=4 "$APP" + codesign --display --entitlements - "$APPEX" - name: Rebuild pkg with signed app run: | - # build-pkg.sh reuses the already-signed app (ditto preserves the signature). + # build-pkg.sh reuses the already-signed app (ditto preserves the + # signature and the embedded, signed appex). ./build-pkg.sh - name: Sign pkg @@ -156,6 +197,7 @@ jobs: spctl --assess --type install --verbose "$PKG_PATH" - name: Cleanup keychain + if: always() run: security delete-keychain build.keychain || true - name: Upload pkg artifact diff --git a/apple-sso-extension/Assets.xcassets/AppIcon.appiconset/Contents.json b/apple-sso-extension/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index 64dc11ee743..00000000000 --- a/apple-sso-extension/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "images" : [ - { - "filename" : "icon_16x16.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "16x16" - }, - { - "filename" : "icon_16x16@2x.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "16x16" - }, - { - "filename" : "icon_32x32.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "32x32" - }, - { - "filename" : "icon_32x32@2x.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "32x32" - }, - { - "filename" : "icon_128x128.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "128x128" - }, - { - "filename" : "icon_128x128@2x.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "128x128" - }, - { - "filename" : "icon_256x256.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "256x256" - }, - { - "filename" : "icon_256x256@2x.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "256x256" - }, - { - "filename" : "icon_512x512.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "512x512" - }, - { - "filename" : "icon_512x512@2x.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "512x512" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/apple-sso-extension/Assets.xcassets/AppIcon.appiconset/icon_128x128.png b/apple-sso-extension/Assets.xcassets/AppIcon.appiconset/icon_128x128.png deleted file mode 100644 index 92eb8c79b34cee026e9007e1d807163ce83eb09b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5172 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 diff --git a/apple-sso-extension/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png b/apple-sso-extension/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png deleted file mode 100644 index 0025cb9c9e93536d659ff9a4002e64408fc9e1ed..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9811 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 M!lvI6-E$sR$z z3=CCj3=9n|3=F@3LJcn%7)lKo7+xhXFj&oCU=S~uvn$XBDAAG{;hE;^%b*2hb1<+n z3NbJPS&Tr)z$nE4G7ZRL@M4sPvx68lplX;H7}_%#SfFa6fHVk90Ai3H2+h2J5nO>_%)lU~3c`$@K`I{@7?{pwhD4M^`1)8S=jZArrsOB3 z>Q&?xfOIj~R9FF-xv3?I3Kh9IdBs*0wn|_XRzNmLSYJs2tfVB{Rw=?aK*2e`C{@8s z&p^*W$&O1wLBXadCCw_x#SN+*$g@?-C@Cqh($_C9FV`zK*2^zS*Eh7ZwA42+(l;{F z1**_3uFNY*tkBIXR)!b?Gsh*hIJqdZpd>RtPXT0ZVp4u-iLH_n$Rap^xU(cP4PjGW zG1OZ?59)(t^bPe4^s#A6t;oco4I~562KE=kIvbE-R*^xe#rZjLcv&7B_ss&9LT~`D`9?2eLQFI;tMVYCorIjjdTcN(aKz0;m*?P*4kbg*&ff{;dlw6oo!BMzy7lxO z>*8}e`x6-@g(UnB{9X2M{ek4&Er0%6omw0H!{o+0U)iI(*=kmOjjTFQoXGC?)i*P$ zW%J_cxgw{e3!2TIoe_Pae_zq0?c+n9Ifs+?$jxb3UNVFGvg|A+gVb|}xS~$X{4DW; zQOuo9(ZYHExrFCxf*i6pJ1_D@vibaE&AR=YVT;{Qr@UaL=8|dWGJ>c5D9L-6v$^fm zO6Mn1clL(v_?^wM#?54Qo+zW=XRq{GM}kv*8w%$t-SpeCQrV+NXPV-4nbn@lWfo4K zlo4#TjdPMX^Vxg*HCSmJr^ruXm?gX+sK+;#rHdpfm|# p>GJmqV)y>8c(dU5&-b&BFvU;wOgAsQGY4Eoc)I$ztaD0e0sw7It%Lvo diff --git a/apple-sso-extension/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png b/apple-sso-extension/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png deleted file mode 100644 index 5c65259f389e744b9acf784d83c83e2ec8616725..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1844 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=fdzmUKs7M+SzC{oH>NSwWJ?9znhg z3{`3j3=J&|48MRv4KElNN(~qoUL`OvSj}Ky5HFasE6@fg(UKbBnda-upao=eFt9QT zF)#yJj6lf1D8&FW4aj2fVw8rngBUfSYM2-p+A|qgplYIkGzd%pVvrsP&AfmSVd4TN zxN3z3%m_9}+rE9xX+Vmzz$3Dlfk8|agc&`9R6Z~;FrCW`i71Ki^|4CM&(%vz$xlkv ztH>0+>{umUo3Q%e#RDspr3imfVamB1>jfNYSkzLEl1NlCV?QiN}Sf^&XRs)CuG zfu4bq9hZWFf=y9MnpKdC8&o@xXRDM^Qc_^0uU}qXu2*iXmtT~wZ)j<0sc&GUZ)Btk zRH0j3nOBlnp_^B%3^4>|j!SBBa#3bMNoIbY0?6FNr2NtnTO}osMQ{LdXGvxn!lt}p zsJDO~)CbAv8|oS8W7C#ek%>baNCu(}>@SFQHXy^SB7;(k^K(i;&ayK!F|h$#fg*}< zAVdd3Lug)RiJcKt3z{&xt_XxYl0C?x=sNt1GE;#;32_s=*dT;Ere?BMC*7!twxHq_fc*iq!)yVY;kiaZGv&=d?zbY#lt z(dk*FBXai#e~+|q?2H$ujx6nz-oC5+<*~Ki@r8*;W(XKCEflOY`IOx5_8CAE^39>8Sa3#W^hr z)V6d1~f;YOi$yQixSbvOBo-4cI;S5dolku!8cJ(|9mb#lOxocwE zW+T_sH4g9On65k$o#Gj?Rm5t-{St%UYP~Usp0(X#|NBCg-zh0Hc}YTT1!KAGsY<^T zj#p2spS0}Px=^H;D0nR0=gA{8k@-j5o8E4pQy8|a`+=0J@8MqG!;$m#w?}`Cdclxd z*1lqKg!ji6H#M_27cEknT&6k2c<*&5_FHT<9HlD@xb$8~IvkIzVvp)NP;^~E#mib} za@5h5nj)5;k3R2H4*Tpi zqjCE)t9_BPKLo$r-2P?j#wJ(mXyb&Xj0>49Z!FDNc{1T*{`n{M$GwENzHQ};xqhhQ zgq2YUd!$tOR_u<#1j ht5PdM*MU;=e@52z0`*HPe#wLC0#8>zmvv4FO#pi8mW}`b diff --git a/apple-sso-extension/Assets.xcassets/AppIcon.appiconset/icon_256x256.png b/apple-sso-extension/Assets.xcassets/AppIcon.appiconset/icon_256x256.png deleted file mode 100644 index 0025cb9c9e93536d659ff9a4002e64408fc9e1ed..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9811 zcmeHtcTiMYv+tf^lpH0fAPiCRz@Wq-gX9dNNE8r8a*`nrNRlWBg5(?}qeKakK?Dhc zAW@KN3>R)%Sz1HmR)w5n{sw)r^+$I13fLKXU zP8$F~SP}%_UB@C0N}|60O&vfIK{Hi%K+fU z2LP+403ezO05py%^;!sQN1){+rDtkt04J8l1FnH6030j@y0QYg4P0rvvZoB9{THnb zV*g793;-cW0P>fN0k*xmUSsbo%%3ez2Iy~%8Q}kv1_2p3f74emx1qD2*aqKG@v#d4 zP_tg$ASG>XG^FcFCQ--lLP@23Pm_uJQLNHd+-+=`y|d} z?ds|%3WK@3yYspW@H#kK!T3c)L|}Yy7#z-nmEdvluy-{>@z}dC|Ec6Z^~hPem^&jK zU6Bs<&?~)WPaWJ`#hI9{4E^i+(@$6Av%f9byZkjQ>;Pd`JurS=KG=T+vqT~P8`xFP zpRhm1^`|++l`_%C+LkU3wr*E$N$}shhxo(9|H1v+!9Njy1N7{Xt`hLSApgexchsZ5 z`~P9*pWJ^(XgDJ+v9Iz^&;Byg8YN^@6vx5di>uT`bXDaPz3C1#{aPb{#;&vaIx!4f&c;g*IJbz zNKEVP1^^}#B{^wb6llW;`Pxw5P2!XK(YipAeA2DRoEQtGY0FzG#yi16E!XLYuH*7; zGpn%1>#~yV-n}f4VwIFBidDWQYxaeLpd2)buGk5PwCm}7Yw@`9@SEt_guthxK9RDi zp?&M*#ik?s=CeBu_b=*AX1oU6r!O{r)mxr2;*kO12TWx5v~AdlA!u|Y_V-T;CgZya zB15-!hO9~f5VRG5cQXROYcc&L>jnkDEdl_9=m7vRE#Ju$B?CaSw*c^UW&q4Leyn*1 z4-IS+0ic)U0O+QzcL51R3P4>)qg%0jB0&jVTrdc!86 zG;M5e1%cxU$6P;3b#)53nzG3LDnuNxR#M`TQE_LzUFEyqC0m`vuRL{LR7Rk;ZyuAZ zsChcBqFb z`U!DNJ0Vt)dVQ!1DVhKcza-rD6Ofl6-1QrsGk6~EHgtL#>1woQRGsx~l#a}-<7I@nOYU&KC2y2noKJlB=crZ^a*WS#Za(xo)bUf z&a>vC!X$JxDV(vnkH<^UML4#RiDu6abjpT89TquEy5YA*1jr4Qj$ZhrJsdNFpFQL$ z&5f0;@(W0l6jtiGTWRQ7`tv7PnHj|xLoc0n88cHo8n|*w^;8?o+<(KmR`Z*=aR ze<8fL^IN@_C>mbD9~qL^^E>0c9{cO~fjx%+-O+Ck)P(Hw@@NHhq!I_o_yYinI(*Ob z<(idT%v}y6MJ<+_pGgM30L z&2b03eUIi0SQ@+&V9sye$^Z!Q&hM|nX4CXHxDhhHWLaj1Dk$+I_m7%l{ZXwYXLXnxSafxA7 z$_DwHQOIM)<779Je6FNY@kyR2A2tEu8BaSjW-vJeg_<*(t)tfLekN-Z(ey$`tb_UM zl2Dc5Ng;CeBHBS`cbk8S^D=^nu&`PjHn>wKnY>fjJY8=iolq{;J%v#q#UyjZo9M>& z!dSMaa4wY<@{byFEcuhu8wHfh=-)Z#?y5vpIHIS2c?2s=(|t@Jo6`Q?ySUtLE}3t! z<)qKqyv!==U!gyf(Q0xz&2!&qpvxV?*KxylW{$tv^(@u?#L7TL(Ue6%Zsx}&anjej z&wLlh&q})TmT4NgJWkZ7P=35Yk}#e`>f-G_9#=*4F2l$&T5`w=rsKYXXB znD$Id!&Tv!^C^B&guzeYMGH_KDP3NsmGS;F?yr4e)EY&Hm+Q#hyLld{ zF9yRenzO9C8>WoDX;O(#9_fgFVOhvQw8oc7wz`lKSe-5D823DUL4!dp;lZeSDKiD0 zT2?iV91%T!XM3{AYE>y_Q&b6q`eei~pEbmdyP(134iE1rd-e>PuP>Iw z&57gsQp}K{-|uZD|J+m>^E<>LpS!re72%NmYdGYYU+%(bM;pHKu5Dw@Xmdf>dl`hE z;zw;UXd|W2;cD{XrUb5bx=Ad8vU`WK{dmoxf6F~-W`h=t@|Z=Yc_;5yAHaSXctUmR zoF+uy`yL9%a15>~>vQMR*3}9n$YN9>5Bz@Pd#jcbP1i zV@=v=Ag`+`_vyFxNcU75d6|mATM>4Px$x=*v0Xw z7BO!ll@fj|?dtWGI&kIHf*B9n!yx|Z>$ASK=F!sfHVwc|b z!{6CRt|$9<(DZ1yIWr^F@j4h&U}9B}JBi!03<}LAHIlddk+Zh7TbC}<#@PnMPsUm< znMhHLYKHN4d26{@Wkf5eoP!S@;Cr^IA;7gN@T5i^_pUI z4x6^d)P0+{TLzx(Yd>hC?IF;#PJKc7Lgl%+98-jP#p0chW5$+1kz!~MZ;>QH5}3I;BC^&t39r^D5z?ZzaU>nY1F*vqo)vq zPFFeaWFTV$o<$FR){OBvqNrvbumZcrcrz*_2pYH996o26UHAC8#}=Fmci$v+@4 z&9!Rcppo}aN=6ZA;p;KoG8LO7M)Xzk39=i*b<6YI2!Gm%&AcSdPtX84LmpP%?A<%00_~3&tJZwLmvcEzuP^Q=~|-$zJ?R~-$s zFJOq+{YlQQLqT)c<|b-z=RGZI>S9k{a58>i!;?|mnK4bnctUQ9PH7zZs?1QG=jf;M z5J3YE_1Y~wg@TJW3Fkf@qx?uJ>q@_ifJ=DGcasVu68(*;p7_o7Y-W=1hnY*W=Z%}I zL6l`#zQ4S{WHyTLo9;&Yq^GT&y}MH_dwvKT@yp=m?}cQih}W9`z;V^+vNELI8(8cS z<%|6&8?<#>(J7a8TCmZPsoSN41~*5%ap1C*D`|ybdOQ1#>gKrGg~oZ(P}k}DLxaXc zUfuQn2S57@>rgx&{Lbyq=swE~7v;>{k_K2`Dvr$NM_-~}zG#%zdA!Ms+jfX(4eFNS z{ItH2u<_|bW7}3`Xa9c8*AP^xnH>kR1fv{&0mfZJ9kxKk*1n8~gyh&Oa``ff&Zeg& z>8{UOb|%;L`Qd6D*4-iSE=7m!4aUqg%a}iz$x^-4bTSU`f>gd*Zv3or7v2V&_6Zbz z+K0^{f7^Iu4|Eb<9ya3Ze@}QoF4c3dE01(STI@ySr&ftGPmWgdAi|%y>EbDIi}zpg z8Qv((w;Y{ntnm|Y*!mPidh(u|HTPhKdw|$>soaP{9yR00pC%P1S>^t{-vW$Ll#_|< zar%|^o3p;YDyj6h-gP4`--`D9ODd+spH1N=4L=+%ohBX9YqxoOZ7oI6PxRuL`XhCx zssdI8jeD!kZR%*|`WriuJU{)cSU_>6Z!;g%eX5UqX^{K*Dt)ty`kWCK_ zz7`WW&uh+O^oqJU{pGeq;Ay$|hujFhc+1akHrlE0ceH&Jh*mcq-kKG^sBF!TWlc}a z+wbvJJ|fk&w+-kq4QP;je`D?TT&nlnsc6`a$V=Q{GsNg$l` z{=IppVId3z0LTHmtwx1QI^%-Vm;mrLrLP4Eq){G#%<^Em@8F@ru$y$9;}{XLZEEcH zz2=zN9XiW_-C1kQDg2gdd4k<@PtQmH^hf7ocjJg4hIcssFBIZ=C4Z8ra{+Gl=C(6< zSEz#WIvu&YNth2fvSVY*Y47z~3XebAy;xw`r@}oVa#{bBF%zd3kXUwu|1IFm5uSIt zd(Q)*@TSW-E|pi4mm}?I_~A!9mq+G_*y8?GqA9hxmsgb~;q^)BAcr5lc(P>q)j z@Oza)D(`y04MEQmYS+bP4)MOYHRyh_Q>dGl(w{CrTLy3^3W`Xz_v-OJ7*ph;!he@o zN~i?gXecPZWUahT>iCGEbUQ1&diP_jfP&u^*K;Q!#m!f6#(O;Zvq`iZ<8IeX^v@QY zLT*=)^92JrmFXxROMQ%Y{iDp>`1EMSEh#)^NbgKb=9)o*nfs}H>z8y1}1v)h4Lu&(f{6rfAjo{rQ7-5o-#6Fqvx z*xS6@Y(c!fg#U@1M%Bp-HP1I#9w#&A^DT|JmQ3)t2~M`n1EPde-#PBTN4(WTeYb{c zEJWw`2d_Tr55@-a#hV2WhnCZny`mi6w_e0~El$e4G9$5EFm(u&&DP3El1XNmrw0W? zT<%4GujzN8eE)$rpRg;w@~DG__){{+L&bc~=7nx#U-?s0g`_BFhc-KokLSF)zFMlm zl<+U8jc@bqS(Cj?l8bo9v4Fb0iq7Q|B!PVD`;*0wuN&3v)wC`rJLVpo)Q}kMI2&Uu zHxI?8b_ckd-ZL!>{rrUre9y7zB*l3fG(+C9u!FdBW3$If0C*5%n zs`QevxvlPWbOhTG-k6Q9cXk$&Szc`^0%=yp4||&TD7Bo=Bg(d=Eupit;Md^XBaqiV@f-&QjjRRk=nI?V3uf6&o ziTgZoMw$A2ul<-Tdzz3Q_Sp&6a$C%dK~Ot}Zpe`aWI~3vTY|8O_E@jOq=^VWhlb{y zd6CNx%A%F*oO5Xan`(veevA|6%PJ`Cb*^(Y2+<3!Yre624J7wNKfUyT>9e}L-m8sI z33pJd`P}-xhn#c;MS8iMLr(Z0H;sIiYZ6Zczlr*chJNQCUGat*s!6ir zVw!1|5L{qStfkwySCs5%Qmtpf<(R`(@D`}m_5T>YPYc>EY?sIaCq zRaP~dQL$yBk(xW@M)wsTnxCk+g~-m;@seXCsjY8A}AM@*@SqYpN0Fu__o^@4rKXW z<zPy4uhsrcTU^VsyH^Qj|= zNZ_v^$HfFEKgX#O$6(bfxrfM;7x01)H5rrF0+7i{z|NK#*@9q|nm%uQj%(sPP zOx^?6Fgi76E`l^Cyd&9HX3YEhG)VXtZDR=^#9ILfxZI`vHR7!`qDS$%>XL^>p>WS2 zihMa1G9O7j+TrEPFl4*_Z03yGBtN)A zA^o|KXXQ0xwu_0<(4nv=zvwa)HuBO>5XRJE1o4hW_e5q@%gN~XRB9v}PX;&!eMY^* zg1zoPXc>b*YgMw{oS%e6HNSl(!#?hh&>NRE@OkXHUN51pQa1m6tfAgd`Hq<8GOgr% zsJK{IRm^;iUj-AeODCJ*c0c+=vTX`O6|P3!Jcb9Fxgmvb^J?twH?J;|oxoc{Q_`E{ zUmAv-!%b%I`>rT;e{CzJeaf;#R!vb_9&`cInM=(Vv0gU)=5s6)+ERLq7-n$DXgQta zXonH3)l6!0t#g(0b&{dSAJng#aEd+xnDGha&ieU$sN_fX;I}n0{p*cLJ7W zO1rfY{XK<8-upK*B!U(FPLMKTo}z_WpvSCcT2hN$Hjv$GZw^|^vUKXhyuv}Tl=VVN zV`Ufb*Uo}*Mi`eEo#%1q7Z*JUgik-mk%4TR5#00>cUD$RniV>Wht2;KOy#qrGT_+% zYIK8BHI@HAWzxyuz|ATgqO`pf90mF2Q*-&-(<8c_T9_~1~HB`n%_P(opZJJ;ZEz+mpJBI!e07*hk z<1|{h^*5K`FZXrx(lPB+l08($oSuoP=DjmNN+p8`0?C}$g>^6ljE4Oz+jB)P>hBN9 zWD5Asg(!eU69duw5NOH8WsWEE$%~QFb@I*mfzoXncq3T=5&6Q_Tg4j}^0wEEdc|?@ z$O?2J-WtGT`-bK>qRP=bTs>RGwWr0e@7<6lKEk7qavPSd;3XJHR@aRKBLV*o##tNi zO>C*~V8v&j+x@J#wz8=3G#`}EgeGehN3a|#dlQeES8m@EM(LB|9lr%m36Q5Dg+|q4 z2^oojCr+B$s$jU%aeq>HA#3Qd2Z>B%4i)pPZNH}Bcpcl_suPp+X_c+tY+X6=v-zL`Cvc^xy>p5ggRjDSb?^rIXm zwHy#6MGl)4ZHpVPip8cMx+?L0wjnpI}uA%7G256?lAo zM9-Bsxim4;aFY@1-ZDwYhWPPjtKe7)_bNf>lBd8X4!HEn@kgK4gZ)zDa`xJckiM=|eD~HIFJrAL+L`~# z=FZoKs3!(HGiBk5kJ~je+9}Wg$mmKNUM)k|M`Xt{XX;r&5_&KQEXs_Xgj&WSRdKV; zYu0>3uOC4uWY%%$5D=`H2iWKh1xap}89eYH4hCrl;ueXrXKi5QQW=!u`qd!bvH*CZ zE341iJjZ{5EJ~lrUxvkYJQcy>4}hk~x2Zsi;b-nU^kNnXG?Y+*%Y#p4rm;>CNovmNR6^C2bXS-$b!A;D7w0!3bX{V?X z6Qr(!kKIGDzzLu7^qX}9MH`eHI(r@5!~%de%gl?HZy-3>1$Na*aC1fnoJi824xKpb z)zAvc(o0F*0yhzp7|1NgjF87BaFZ6L#nU*y+GSsbnRBgF(J^6PDkm{ELZNTUA+D=b zr{U0*XNTPh;XRD-<={S+Z~P8>69r!*1%cy3(c}?w3Co)RYn%S+c(nTl!0hfT3t-gyS_xgbemrKwMoW zHyOJE0MU%XEk&cXutp-_LUGr?i&(06r-h1)j{sYq(E1%*DV0G7B9juKuLXl2Fk(*- z^#0VO#*4$AC1CHu(U)pr$5t#Jyrb;}`#WQ)Mbs?8|2p@7|K>0Z)pVo^c&9f0S4X9k Mf+F4BiqfT&ba%|4AfnRU9ZGknNJ@7j4MTU$%zKdE zeP7pmz0dRh0nc*}A3o>ob9Sx0*IIk6@7ntWsj0{k;614TOUQ zT#nsGFM$h&xwx`82vi!1fBhB{NYfk3y;25&JXt^>m_G<~lLfW`0=YZ^fwqi5Ab~g# zh{8Up?xhg$!rxR&-b`5;#17o!fH1+tAavjk4E%w>4?s7y0e2t;Fx7wGzXCt{ClAn* zKnoDYKY4V3>&-6=_`fN0dqqzJ|EtC{wExPD21-N!*ZoZ~%9htezy;S{PTL6tB4@t& z2g|==+6SoZw|K4Ptfj0ZU~Fdtd23?#&J^NqV}DZ$B;+mt+}fBrzomD#v9@&*a2ICy zn?nG&zj@5XK>s(3vz0J|ma-bXq@ANFJul=5V+`{?c{ZoX|~W0@|-ko$Rb#Zn`D%^w~3^zfJtVp8l(Yw-3kJC)@xz z|I1hZSya-_+Rjnk{;jd8$kTs{{M)1d%>B0^?f-p4|Mu#iqC#9ZGyZQJaLZnQp91_P zf+xiF-@Gb<7njo01py{X4z?JA}C9LTE-wpUT^ zbRb;fGauduK6Ea7XQr9*Y58^aKj1yfsl+(W8X6i+3z{ughmEm&0eE$|RA@N#Fc2CJ zh5|ix-ta3e3?eZQ7=y?jLk@J#@b?Wpkr!Oz%XEYH+2FPgGuSZ zI7WkP+_y<4kT>>iS8&omxMHA>D&hD3RzVM56#Ux{m>39C4up2!`OCN4DsVvyxBb9C zdnATN)RLxda!UX5xC4-3G2yHq39{*DhIf={W&FiR!C+9UL z_>?}(&SYv$VUYwjGp0_Rl@VMk**sNB*cgkIdPsU-syGAL;$xJH1oiNgTbGVB0jl zIuNDQn4`;HV%P^HS8jAOLgWRQ2vO%R`7-DeL&OBCQd-bd+Obe?#-qHzV(#&bVDUs& zn(opc6P~XWT>~nn_}mda-Ei~~MN&040IY5 z4wGc$^iXURr!cPe5P5WnRMv{ul(wPY^=OpFekQlaxj#~X{k!@)lMC|0d$DA~Dk-BT zXouv3NwpQn+2gbJp4Eui!96Cl!o&&1ih4zcLe3LTkhxnS2p480seXlDA5}+R94y4j zrSGZ0fkbe}(1ZtV=e0`!L(`^H+I<@a8_g4NteOa<#eg#lz~e>YnEbq|V`Y8Z6#~imv3Ub9?OPeQP@Yp+qD6 za4P(1cS!KSY-8YwT>{e6iDM|^XwB*rJNVDGM%Vf;-yq}{*9ygoa!H*k>!_kcH~HQ* z0%8>wGs4v~5Kb8AT&N4vCZ{=0fVG+WJm~tm?I(90w2ECYBidJs1M!RfQo*A@mtM0= zAoXn_sJ`AV_N)+@Wo^5_)-7k1ox6PQfzdJI!OzFXNw`NBjz|DyIboK@%#~;G8ZZc?%-HW`ca<$|hWD(}HV-8+T4QE^@=?V$yZg2X?eLl%$ayhDP zvoA)XFH?Urdy-AT?a8TE=t1DYs&877`v(bus_Tmkt*o;8C2@ z*Taqt#luXVg&0ZWVoN$yiN>9m24E&}%dc~Du^yLYcN2FMHdJI*>Y_OhTS6dHgEU<5 zM(Wo(^gg~~9Ee<#?P#BO^02wezzhQFjBI&>hp}U{&0!%_8xbM!;^>sKdA5U9;cTwB z#wH?q@V@l@Vo1B1rsLS9Mf2X5*Vx?U7v+T3pG8nNMU5jp8>uFA5i%OrPLwu6;xf9lb(Lg`%YyZ*MZr76 z3QL0MdP`J;Wyu{X8zB_BqHw{MXRpQWEEx)IpDz@R$aadcXruR%&+#ecl82fQH6zuiH8AA-V5D9` z+G$M9)m=rtO!SMUMsvB83144$U+`F6aC6Vn01H~8=h9*PoUr4vC02B_q=3sxP14mB zUDs$h$++9mxKX3y{4C5ksWl+sPQ~6NJBKI|EN{DQ*OGg9FBq_#-e|b!NR2!ndU~;&|8sga)y7>N zn%y%_^H!C3ws;v`#MP#{V&s8Z#Nh5F7vfse@t`wQ(bsa?L#TMg4>G0QWx_lg5!H0H z7oOs^XOB3k^Y0^x6-QePdcx@)UTkHEst&mhPm1HqL*Lon&5tMeiEjvxF4l>K>2IPV z48zWfkx>;glvGdPJVMb!fO;gY={s`+z-{YI_MGIh4i@*3X9zCmYCdDC3$`UFXE#g4YE4!c8> zQBBusQnsQ+}>o`HeFIr4;^CZ1N8$Xc|F$U z0~rA~7iVj0FCfI2LO3;hnVnC2*uM(bY9X%G>aZQH7B zSMiELC2|o6pd*$}>6-(qlR`}FhK9bRHa5xb&SwoI)mv{%{Cq$5_OQ%q!*3orl zFwLA{pagl@G|gDKk_K`*FzYBs-{l-bW$F=Np3BHj>^tQ+MjozXBgG6Fjx)*=(W&c-!-;ixhNsgUL{AkizilcM-rZ7NDAEgIDlN}EXeXD=LyEL(m{f@@*b@+Gv%Fb29fU$)TU zw|j5DvoO<32=2>_@WlAgDtT4) ze?Tc%OP7`25f$WS;Yx}>HUzJ;4Xq#<9S??B5!x5J@to;HCzueCLw_Y2(dVIP^>dHK z&=%J=e%DrYRvw~KPA>)?S1kWbSaS3zG+mxN2VV?ap)@J`?)1Ifp9n=4a6V6Y|zm@mUtM>n~a_w%gIoE@k_2^gSx+`pB+3tIxb; zjScXcLjA2!8@-1UvM4QcoMi2(KE~L_mELaw%ZFS0<%e_2(5e-u8bt88D6(>$t0!HN z%}^6r?ocuwY_WrPuE=>5;E?P(o~vtXEz$3NRL_HsG}Bx21{X z1)KbILlbzaATP7d%zK2JEV$kNw9;+9YFd7WzWkxC?;H002q)-SiQGK4!-)s|$1CL% zF(VuK1^l`nsfiS)7NnV#7(%YQ;-%RkjSb0T(Fod!&OftN8HPMceT$#(!md|?g3P?V zl|`MFPCjxs?ZM1*&kLDl_{gIVgo}IIsRAuMbo$?51DZh~ZG0)pZUaq}FcoR#v%+QY zb;JWn9fOgaD!`1gKjaeDjY*nk_FeuZPC3+S=j%NWW%I-3Xm>li+wI{)J)EC7can;V^I|f0P0sYJMHGL_v)a%*r zxbhaz7cYV|8K>33y!?Y%5XS9V<=mgi?mI2N3*1xc&pi$8)zc{Ck-iDB&f zTPNeWSiVcYVH8>B$ll6|tS%j}40mjg&sUC+`X?#j>y;5-uS}lC>14=Mbf33L$)pIc zHd3Oop3+5h8?!POxHVX7;!lncsmrQ%wfkjiHn*r2-2?QY@*^`yy9`p)7x@qi*+)V_ zb;sRyD@(}fAoqy+Pyd`K|Fa*;bH|7Q3L4!f8|Hzs$?<6Gf*m$jHqs5Pslv<`-$D%2 zv~&4{5++xj-(~&DO&JS7=WMgKB?0QzmO9-!uKxJl$uHrcc$i2_6UG^k#A(9rW2GKa zowF0`yhN2+u&+xYxgG>duSmLo+MiZ>e1XrM?S#ZWtwnJ+;qsCjd86JYQy67^%jC}* z0+5(sa>K^RQpS;}Ov#aHlq%1`yO)*J7E;&>Dfb#yHv8+}RhS9=&P?Cf4+Z>rbSC#9* zmzUc^jyR65;MBfBc1jfR>Mx94?}rcDPg@2pg&)xD zN);g9yqp=Gca}F7dg>t8ubR4a(2RF{7!fHvZMihgSv(*zljo{~>J1I+!+XyOl2orf zTo_$swDG;_{>fT#kqQ;*&93eQvmQH$S>YNvg@P80V%y)X^{4tSDYU)SdFyaC4S#Ei zQjpl9wn7)uM-WDKM3rbzkH>FD`01kQA?BC1n$1r*s;>HlqR)^KdBK_l@;PC|iK4B+ ziNY7LV=St}bv+rz!6-J0vtnI*Soxu$>93{t`kbX=_^|vYY$gx{xAW*@sFi7qE3KVV zH*+nL`<;Mco8OS83Exit&@b~!uB!?aw01_=Dl~c<%Yh*|Rv3~(^#0ufIC;u;lyc6% zxBG)nmP>5b2^*}NvGXIrpR(adPz@7(6~+_YY&3zonlL9bm4f5sV7~bd!KZ5$7Q@;U z12l#uIW!c83&ZVH`w*TH4G`}c;OEsak&o|nSCN8eV=~rG&uRpI6gBZx%f`T+A_47} zn9*8!i!2V(1G*MV{OIW|;#ngPOs4YCqRuUb`92K5Fh8bIT-~Soox}K*T=3h(I?f9I z<@<2OxumD6{?01xq2)zoua#mQXIk%szbHS-uCsUCQnc=ss;RaAvx zyk07z)X!>s^UkL+y5Xxa91trt^F>G1=0f9Y9)c3xJ+od;S;o*_cxGh2F0X=OR$(}u z?x{KFNKbP$=MP*^A80j$^35v}Sn%AgGDv5<*oD0u&4Kep!L(5Zu9$3KyXGmdLftKB z2B<^?_g?xdi7Zc3?)0#WMc(b%`XFXISKvE12Ag6hEpdTuzx1lM#Z1l~B*%D{uhjC? zm=@MExaIu}S1j4DSJ=nAL3cWXrf6pXQcWE!3}Nbx%%{2vC-a^DqTyEjM(wS`mT%42 z)wts@vD2QYa=Mn(yE%Pl@asdH!BT;P6bQN6kM;s^XWf~dHagy3PN>#I-J8n@#I)Y; zWa(N5+`zW7{41MP+WGxu>xXG@&B`Yi+L(vhjfjQu#IT|)FO&ryEAeod2YMlhti3X< zPk!c<^Ibk#S*>hKBeZwcc(jD0wg^tHFPMUnMs2Sc74z%LQquE*4&@)DgYC4Yrn`ao-@u6qYRy-tRsm;>bc%QKs2 zd?&$*-R0pashBy#(l9oeqP~OE*)M0%;*V1;C$5Zds-eYNkZ}LIuTAKUD0S+r-MAkf z7`UPIIrHg71V3Z6NQ39*9X>k$iX9?QlTj+jHqkt@A;z!2vVHp9XR2PaN^bgB?@UbV zT$rSzPCUdexW|>S0yMd7-R)wM@;~Lj)y6!>beT{?u4oG#7niO!WF(NLBuwwKbx})W6k{$?KYn)&*rLm~O8{oPWtA zh2OFi{w6bq0IntC;qd(vojBT{{o#e^6H1G4_;FEHTHg$?DooBn4c}F6Z`67GT@{L= z<#VZ8#>!`ETY7&WW-fJIMDCoif;z@r=bke>-6=9k2)t+(*0~5IdAiwfFntmFCprf( z&YlG~9b+$4Y^wwXMhe~4O6Z(Q}8~OZ*rVYsggg2huYaW%gvs`!x zU^o-FCbNRA%HQF(qaulHLuSZ&t6#DYPQu?nRpbI)=c_9vGiYne3+q}G`NU;7z6efK z_m*Y3(URb^Mh+wTbzT0T>1?mz*D}lY?$*4Kc)Hi_(o}ZtAc_m?&3JHEdZp=k>aYe3 zqY%XWE^>^L#LZ<6lKA}P;jrt-md4ajY}hwtr%u;sm`kSHMkMjx{Om)6-tfoZ5>_-h zv?9FC--H6X8On$mkE2~;uCiVNV0E|s+?HfDIa(#D!8D-|lJkq7`!aK!sQPq%ryx=7 zI>YNs>gU5+Q{}{vU3zdFRdzuY%5K~*0=3qns2#y7 zg^K*6!sP4jH$d%0c%8q0F;&`Kg)|XvjDRR94D^p6-Q$XrX*?|Rvrd{vkc=;?zMXZv z+VjL??IBD?kY&V-fUw({s4kWjCQfHOiP8Aa3;lZOHcokM*E7MQ={~|;V!+VR6q;{H z@Y3{AN_8@S-17MI38M34s5!J4RaQivt61u3U(UZPT51)q;XHqTbslG_!Zm2NvQ*!8 zkS(&YuF_`VvGAU6?&Q&~H4mDH4RX(8fF?q(9*>v;AJFE{_;?3*jNwi6;c8K(pa+@G zBFqz9UiGsz+jnDS3K{^rB*%5CR4APUeF)Ep;9=oGiBL(oZo${~-kHO*1 z(Y3DG0{$UIC+p+47w=K)rpmooG=xO6Y_j-uQ5)Ff-Wt}07^2oQv8=0&x&qF9SBvAM zl$`a#N6>|;yQ2Q1Y?2c*cHtpZMJ~HAAN4#u{8wEj*+(&R!!B?FvZlzNi4Ot$>dpzC zNcoG>S0a@M`Tc?gbVBbFpCb$>eDY`Ow=$d9FN-&|V1)vQ!k<_#eOl$2;EE(i z%yH>HB93{}eEN{)WIkOH6Wh)f_TltO)~haik8$rvQO?KkF6w&c$RW>mvsRaO1NGtb zqv};n)Gz7s?l1sr4_}zS+;btOp)uXbN5HGk>h}di>#7}%f)wC+A&sEIcBZ7df+5uxbK{R>(1c4eYgHQICms6 zlTWW^NQfHu>J@dWZTGFTe6%4qzWG=R-g&YOlwMHK?iBH?)nn(VS{CW+1#fnVpbhnZ zFCXhs-0yo9kZ4X!-8@0o&j`IFZKpWi6$!a~;wk;gJ;^?FqqxwfmD~(Yofxk920dHd85VU) zlrnkJuo;S0e`Wn_zNt-{+~eh)RB9p>7Ly$c(H*5vk*TK>3vcjWibO}m5FpAfUx>01 z2c0unwzA9QrRkP@*$y4vrM!&x-dmYi0pOA#3#ZzN7x1HMGnAKxuaZb9lB4O7diK-! z(H>N>VH1-oEuGsM&b}l^?cBxQV5@Jli+ZuO&KhFRBKv9O^2j>&%JrG=Bje)6KSGSn zQBE~m!sF}3ACUq@3vBr!C$M(s716{OH*hTFi}fF!Wj_xM=g+GTx4N9B83wne*4*(oI zE|)R|wAK3K1>Dn@=S~w`jE}2rV^rBIP*W|O`}K3r36U!fl-}EH-NDN58Hn^3-=8&_s9(#tbo`tLU{?d5 z9>2aTKn2f-rmKITOSz!(I3G3ac6XIZ9G*jYSN2)QP0UCouB>y+ARC=KuP^cppBolC zEmK}Z=3=kx8}-CM^F4 zov)Ic3nShvj6rWWOm^&i6kZrV121qU4=zju|&wa1gxVoCKuRyroJdo zpN9#~2E;Iy)(N=r(KNySaygVEG}%iH2%-(IP54`NU}+gb^@`2U%4SxV;Z57W4>h^4 zo0HFi{VXn!aK^Ir*{E`XEZq*Q_lZgi_`YS{No z)}-)bTS_XmF8FZAgIRK)Z>4)5&*ZLllY zrk~Ct2PgJV|tWP4lYxS}}E&1ne8J8vTlj9Yhh}vh{LTp4|c#(TQ zYIQ}S;>J{an-M_tgjv|FajvqEMEojcG_!#TbfV@m#2@;t8N#J^Su5mr@RUE76Kx!3 z-7VHg6=5Uxt}1Limt@++?QCXc&2%qfCT4*Hm++ga{mXL&gC2iZ%xeicGH+>J^aAOY z%eg|Uw0UC;Ym;W-adUklc>VJ9>;n6q)&j)J`2$_m7ACjkPwvC*Uhb-Omv{lk+3%l! z@~V{pB_L-9fydfNjh%(PnzymebFLqy>IL(;NX_jlf#oy*r+WQYDtW>lo{yp3_#Z=i z54t~c$J4kWA6A#5o#s#bx75)dUW(?Q8>hz|Gf#9qPkzy(&msC)ce*(L^13o|e;~d< z+Grub!sB`)uED`U=TnYj|JH}PN68~}hBgvLWWN3E*oZlH#Ho~4)d@=y?pJFFDkkm) zovG8+3;I-*-t+vcWD}!TBw#8oqtJyZ%a1A);+435SfeqMuKnLb8D#`#T#Od5a)=R$9MaykSo1QSOTGHg@#f%4-m~PEwE0bsTYg?_CU; z`H>lEj+}Bo5_XMkW~kK|udsGZIKLvcTB$42HFbew>1U?(Qm&k>wO*&Qq4Ay+y+QH@ zwtO{bHGOP)BE~f)gG5Hz^Dm*E~f7^NJf0Xcsw7S5?CE-+a<2^Fwk=vy*?n)A2 z<#)Fcia@5!P{NSLvVqOwYRR0C#wm5Xxc=+l9QWJg|EDC{;t@z)=(ZvSz{ayj{l>K@#@b7jG*Z{EndD9wwxlM`z zK?T?=_HD_(d#qa4fvh5gPk!Ad)1m+;osI1D;onVEi%x)jnTj2^bemLD0KC2^ysKNf z_GQatAQf~$8n4}&VLcJrWj!zE(G97iAo z!>eNCEP>BUj2~2}9bkT9B;L}y&=AA5F1_bPv>$&n&S*OF4h0zlDikK$uQgb(9l0GJ zXOh9jDfq*`w|WqRd{he<9|whmt(??IxoOq$wr3f;NG&D54Qf|>4T;tx?bP53i;roW z^kBO~8+*Me%<%mn{)^9225HXDfRk~SyaeA5+?K8Q<>QmVIx6PbMh%HZs^^P^%=799 zQ%5WeqW48&fbr(U<)>19r-LIz)IKs}d-;7%sXo5tf z41R$gItJ`wC~$au?VRmFamOJzjN(%fwt*|pyM}7y8s4<|=t|XHF*mqY``3;C-fq6; z^#!Y`{h`MfN&3@u&nbTIi{g`;<=i{O=tnm*& za(m3pKTs+2&0+j;c_H^6u1{>K?>e@+kB)}pegn+v9{kv_ER?7+LnvZ%b2~p`jCUwZ zV$|XK?dE1UG({mYDN=9LEyl-017{d6e-06zXlJrD#-98^{7C7J01(SwdQ4~+nV|A{BB>X*zN&|PgteM|l= z;CA&VZ|+{q*}M*&X(3w1iyU5=G*q(u=1iem#0of?XPrA@;q3FusVECKf0o;&fWPe3 zZep3X;zxtf_xWP(2u|XrO~XNk!6>o)bezZfFbtzj*COxSlY>+bGaanc9A+MEd)qsS zZw>}9%LIN_g&^|^n)9WnCpPpsbhXg})V-L_wg|KA5ArlFV}94R_ivtcE94c*wh1n< z{@K~HHF)(Zh6(#2tfc^i+fv|9=u0OY9=#l@++A~bw_FhUqaFq~i5MVdin!DL1x}*! zRg7sUD4Y;&Jqn-Ki<@BT9`!_NUS54WBuxy{X@U0r)WF*8D%Gog%Nsd5rWeJkh<1l zGDvdRELRgtz|awo+n8dJJe6!#t9PNhxss2Irn z&N+H znRgFsou6>B(oR@dqgSk^Aahh!;Xiv8-sP!6NS!U0uGI}=+s=((8454&+27zN^~+9U zW7(aDj!JY#92@#&=JlV7*;?+d?N_a?fi1`h3;>!zdfFp8%5uWLkB~QSG3;8~JG;u61IyASNBC)uG(jwo zm2~pE%vAdnu;wr0#P)~G-N-{K9;XNA0%fFk!Dsjwi-t63*l$ZYDqbF%r^t7o$A9y$ z3=$$rBvY4dPxbKB$q6KF2~0dnLN&>n%Uso))^&Ia!tp)^s29s2_tjOr;RuH$Ixxl> zu$IRybUZKCn~jYrA5g)}J#2n#$4R3ed+50Fc1Lvl!qmGmnIpqgJ3kKNzebPyUNH=?nx~j_N=pMNCC^gf>YHm^p+dMTj<>*T_ z^mpTRT0a0{e81DEAHl5Gc;s#@?pQ38w+#O9e9{S5rvcNi4zM#ugR3q{D4b&&B2|?X z6tGd4TPzNcWQecR=>?!*#;5N3l&q@mg6b8usxnG-f;^b=$;11*I?{P%`zQp<#2jlee$fq*C=KAqnC& z3BEGy5E}~NCI3JG#(2O5Op*IhnaER~mtpFvN}FPAXGL3@8se7DVTHDQj*l5B43AFL z!52QFL(D#*d;=X5pF8SvOEDfL4i~}p1e?98>Gk;0=5O|%Sj_e}7bwrKJ14t{_8n^m zpKPmx2b8Rm{|uSIg2cObP}(h6fYx*NdNx=mrLw2LB4!{1^3ws#s8O8?to2?M3TK9- zD3J%Wzx|*`_XJci@2s`wNSrRkh+bB7tq|e1%r08csm>E;_U7aC`^2Z-6H5~TEU6QR z!mWa3bEgQ}v3k7>^|q%>l7T$0x;zTV#MXZfqJ_KKjUTs&D52_A!evbZ7UiWTAOGCy*8H39vfP_ooPuoLcScEvfy!g6w z3D|F*ZdJPswk_2X4KxhF@12Y<0OFQSx*NF~{nH%ptRCte50kb|S0oFb`{NB}^XyH~ z6UR2FkmwCM5L@HW58emtzz)7x1Y7Lgtdm{C#Om)HYtPF|L7BOZ5y^)#FT~zji{UZm zp6sSJ+a6)RY2Kg4Q{zK*Gz9LWq2Y{bz@XulaP6@v$ITBau9-fd*GPPU=?2i|LBqF{ z{f_IF0H>~C68jn zr%*HI;viUlwUWZ2c_pS;s++m4lvtuG-txx?I=+T3zXm{oR|1;_^8(%l_dj(IXGMM= zs`Z9G2ce_ecYM?}Rd#ok9$;t{fT0DNryF(Qr4BR* z)g!1!7Vq7b97PoZtAIaCedG;8W;BH;c~B32q)U9#pyXWgWV9xEh(ycW&4dIE)SQ~D z5?GkZNcDusiECm#5ZGSR2IC9-+M(qn6z3GoyQj~*e=W|YbT<}3epd?QmyNs6H8MPF zi7aXm0f!Bc*1Rl`(y$}uiG(z%S7hVoS|E#=vc})Ml2iS%((rsp-PCmDJ;p46(k_PZ z{hnnSR?u^JHruEbffZ$r;t2U3iK$CR2%O*;eFJMYby?*^tz@#Egmli$O|4Soh|B8G zJbfM(nWU(R{a6Xuog1Dq{>B!jxkesF7dJUa1$VfVT{0@CAU}FY+ng7*_L$3z-2V$#cE*{vN zDT>A}SQGV?@ODVZ5K0lJp!Ps@gm}>%AB_xmzq)_{a`*PYM++S=aLCV$*|*0*(ii{^ z{Zhks|JKKW-UM8kL%yM$6q#!7YU1ouw2{4CWboz$1|VWzW{G#do0(!cytjm%fx7bG z9V7r>_89Gi!bRJ3;Sm)drTptt+7Lj-zX0A4J4^lmm1j|q;-jVEF9jmfMVAFtO!l4A zdF?uLo8()1%<28l1)4un#Bde~P*?K%^`+bg?5{1jrIW~wopA)1oWB)ECMn#vEG*`Q zuxg&YaBEg15kN}R2ukTCa{*&EODy9y-(Dwej)dFSQyqbR)71wUIC$w%)VUf+wlLl> z2=42dAD=6ZrJQ-iV)eqXAwO-2y{dlAd1ZV00aoVL$-U9Ck5L11?^j)MD^H{Ti$%gf zkNXPO_P(@B3=dP72`qA(7UuI26Dm5!*EhGN@A}rO(cpB+wUkNZEBz^|xq72 zdA-7%MHH{UQd}K6b2;sN4}SE*Rp({zO9sX#Gp<%F1Y6_K`l#;wN8fLLfi$<@43V^iU9K#q1rBXL7eV!x(0WOrwS>oNoI&9 zFpV<`nSgQHV1x;H5Z|ylV6~?0wnzJj4Y=BHyTX6FMw4aR%C0{b-_oK9ch7y%)RbQI zT;Sm#I}3)+K2>`6%g5hIKo(`~p8#6#?RPY%5v1F9209mgRVeFB!Lsi>sX1UTn1IGJ zD?s*;(2@QQ@UdYrE=b&G#AKJw(erGf`VC2Tw3FcM;rO+31W%3q8Smx7xbkPFM#n2?Eh%R*@$Q7YpJV*1UwEe?Hk7z71!4Qs z`mL^M$2iY!BeJXBdIirP>%%keU?qJt{n&17^LBG;v#-8=|C6pc7W$!7%UF12D>TtI zHU7!wmgYGwpjmgj6rF;~rPLp;U z+|En|#-Z(;-L7_~gKOtA5$$!sgNE}=3>bNEm*NEueBI!xKT9jSGe>`Y{H$4s!D>F{ z7!qja`X|v>xjp43Vy8s?{Acj&{>yNaT({+(XvW2tH!&>iqP^*wio$Ojj)zR(@Y-x^rys+BWgsQzqXxFSO!BK_jC6-nRW1436?qxl}g>C3D_yTGG>&DCl)h2{) z%#%jHsb)DbV~eANJx6p3w3Y1^#aP~-JDHbLVyO1fz|S$ov(yyh<@^H|eVkr2w)=%Z z^CLjA97u?0`?;N>+{CB6B363f{9BqmoIc65IRyu}pgerI1Y8oq$5r1XRy4 z_h z5fN17-u`|bikpG1_GE-u$1O4h8>hvwb$aeC2SR%HJmM6^v(@~q7(0+&O%ssSy2J6) zmAORYG9)cZ^uc>KWED%>+SLpb@zLZrX!IvN>^A9zi-36keIH@PMWwShD7CN=^p=~?*XrTc-0PcIVIWs^Eozr+hwAvQ6l zE?7WahHi`yPK+KEW3L1AfrlZ1ImgbIBB@WG7Ma^!nyS*%l2Q>8F|s=1;}~fj4gFf= zU$gqNaJY%xJ@nJ+K;y&OT;wYg%h$QTPUJ7RL|2Jwkzm}t%QSrCsXZ9iS$8ayHokC~{&lcR9t4+=6Q9FdMvw5?6@0~aQg(*j zJp~hmA3;0UnJX;K)g%>a@7e!zc4a<*U%rJgG9vldjMymX+Z2jkZ!)(62eqHSKLmdfRMZfgd~0TKu{U=f+ol z9T2w+RWZ7_2mZSUiIx0~zPyYs2-1)g!84!)_ZQcRt*cRNsfACR5C~jl_TPchTng%1 z__a+f}I<$+fkHMBOK`F4MWqetqaWv)bi1FPNDk7OSA8=M=9q>B z+Vnu`M9f<+>mimiZ67NM9nG4WptkIxY7N+IN??|~XDIK%juH`6MoiPZRi~^j z{L;mqJnlB75j)rD$vHdy+ko$)Y~>WCg!j`t3H(t--RmbYxNpgNTtZnyBAJ7!;lPG? z+B4Q)CvIxM%8+O3~QJ4-j^fLNwluqVbbY!C;P+%7{VE zadKCzvnhS(R~1t|_yC^R#u`_~ybr}E6$Rwt05kNE`Q8qTJ#X*6<=oyf##n;Gafm2+ ztlEM`5U@x{`m@wxPXC@B7$~HNUEw|t% zs>(8QtzQZ)uP?gv$q8{IFXPyNiS)8qr`7c>nfF}@&lnHa@@`#wk6oRouc7g}iHWn` zD=YGA+qYo*MrcWHhB&-@+3znA`>|FF%S@TKJdtNC3z4>GvlF8R=f;I6U}|g%DxTNb zP`7Qn90{V~MBPjyZ*=qBip#g2e5{e^{FI`F&4$AZE}-ypt!Yi!Y#-%z?Xqe~4j-ePH@S~>rxH&YmT7V;5s z_y98-e14T&G{k;gUTbk->MD14mBqx)_BF?K7>kASF$hHb^X6LsD|nc-VX77<1`XQT z3-`XrYw~R|;c!UcPSNay&1B4#J)C7ggp(`dJ`TP?iGfr44{>u!K${>xnn(JF~FYlF|hVGqr(|Ci#U%FgvC?^Xz zG4?(~A`>(9hiMr)ozu<<0S;f}1}wH%%q-g2$PvrSso!DVtS%k3y>#8?$Ho%x$b6ms zzBCP6m735OcEgylah0(&0!a?T&)GfbOJ8|P9w?}G9{d#}7XYWpW3(+-70yz=UQkUQ zLta0eilvH-(&&5ISZya2+Q^I^<37pv!R1fZY+fOlE~Ge0w=(|*4FLR$3t-s#LdE-v zTi6#2EwWLv>VYe9yhpkLcFMKZI7!?TB?h#( zLWPih!N~z?{X65KlIvtGda)@9wDMPVJ5y#MkyVBC;@6;)!-qQ$>_<-C=!(n(HV%&a z&8}U=A)Q5?5uH-x6CVR4eDrDUYdOWXU&aTVxFzGC1&tdI?IIFQ8%6kA0IveTShRqq zIy@&(#;GdKevC#vMr=>QRIP~jxgx2um9g>5{zmaef%PP`~ z{Y>X{GsV%tf5z+XJHGlGwM;yS?o&ro-C__nOchBlKM(NHywIg5)+s{NL0VIlXir}2 z)!ckXqQwfBK-w>pv^4u?P!(Edc3)BKqBotOYi=)j-!r4-D(}8CE?0 z3zFoyy#~k}{Ff{u=A8)%rnXy4wM$xNp+VQ*8B)^)W@y@TeAOD7D#93=gPlZnx^)PK z2!F41p8Oz#ddbRPwlMWqlUuR^E1zI*T@vthk=IS>eT$sw@AdolP9r;9lD6da7Z$D5 zJLi1A>)pg_7jI5(Q3UoTAW`mzpOr|<{CxF()}hh%B*k_*|1bvNQs4s_(|`*y+EEHI z_mJQaqPn2NcRt~ozDE>M!6LaZ91~`d%=!xoG{CtEkT+_gZg9CmCP>7he~Tvk?LOl)`-q ze_SX#W`zc<)5W?yDg^gd(P-5HQJvwPt;2r#!b$7sW{JGCvwCYM%y&QDh02IzTQQXU z<-C8wN-iGjU#ITzr+xh+g)VXkeK@O^P9JK5#Z=6}c*~)Wm4Wb+g{_O%gs3?dbAD)@ z!(%p)+@db}i4)>(m!Y+c{ zF#%E1Qi3IzJx#}I@)gHM5^N=%VVF7`C-I`_eg}`>mhh3u-qgg5nP;U?_RM^pmS)yf zJ`$vh7i;qt?Db%=cDp1ij?(_^BZC_`TLaGU#9`-9)*E(zARl&A zFjx9VVu0{|nqzrg17oJ~G~pVn`)RY1{?$^*7Gq>?JXwj-)4Ml7Q3*EruqXo0D6LM&V#v$|N$eB-s?L86o+q)|j87sRJq{$i2>_rq>A zC!oGzJq=r~$FRF*#+EZZ?mP0Zd)-@^Fz4jgES*EZ;KBw!Y{hVaKcZsDaTU?tKi5v6 z*^>bI8Q~A25pAEVd;aBK7yyD{JliLwx>3jUlK?ZdhKOe0a3K*+9$@k)q78srvN79c z@1xZ`6PynKp|t{J+sj|a29i8Rd;Rut!QtLrY36i%^wPiV@tOtT-FVHpkS30C#;1i{ zHHn<5lWWS8WSdc@>FyVHOL8~Of@J|E%xvi2V1OMBU2T2x;#QP;Za}>k`MrWm(_kWF zoljqEbOBiHzuuisl;6yWv1WxNuau4!Q6Ab{i7R4(OxK`QkdO4H|3*OY!bl^dyLm53=r?Fj$j`-&EQ55tPBmbp%e4!pvlheZ*1!*SAR*KlpA;RYHX zor|+A{ldAqGS0_DL$WNzK~+4~j&E6R8IBC7EH>wW%XuS<_k~owGc|~?nlsBzVEh`I zTLt<@s{UJA8V?MDEX#SF(0;2XK~`2ecH0?u08L0uGv_mc zCl&CeDlbq?QihupQ(teEE43JahqRS~1@rXl!6LDG`GlC<%|2`=->QPQ$Lmpo8N%5H zW`DP%h{Xd#VJh+buw;g@*F^eJdLhUelTi$YxFAYw?7l)q>7{5FZ+L1;KlA5C;oPdzsj{MXXglmn%It@RpNzP-uLNE z2eyhPaaDvj*z#Z068srdWLx`R@87it~(*2Pj7l!AbUvF~F(8Q@aq zznkfrdz6aogmVnD85I{N%h!3&Z`{vVPVfYU213%1QGwVu7U`v3mzvD&K-9h{+X z;@!XfQ|>72U$!1}h)SzU%sWDm>ck)s6aP zF6(ZKcLBr3VF56g*oghxT_SbozHeU1tmAL>Zsy;fa;~NzcZ>7Cja zqx}NSBc9*(F1=3IT`x2wVxpN2&CBNAz*;9%b$9>FH|1=9FFuY}I`Ib7G-R_}RGS<3|M zuRfUk!T)tc!~9gXC+C;-oxFKy%e~Ou<$r42wCl3h?l9jLvu_c%+*n>z3G@+bYv`A$!s>Ah=8L0nSQ#a*eJ_x91e*=)86J z&l{VL&G%R{@$JNIs#9CdAoiGxaJU`fLf@#g%K<1NUt!a9MZpW!L)4GymKZ zeLHpT#j?)b89Yr>d%@LA0&tCk!h!oO3!U8jGMN&DxnzM$%DHbQF0WQtEcz$Xm+zCR zTIUw)zwZjpM9#bZ?%P|#eY3v`?wdQ2@A>)YEuH(tbh#M$xLrZ@iIfs>URWS4zvjdg zy+g^eOqZ?X_xDO}*vj@S?3tzRsi?Wj%9EFb7R^#PVr%ri!CdJ;s1h(-A+zJA^IJeA zTSG%O%bm{?c4T|(5CB&rEPsF#iVyxv1cQ16Ks&mC1!R}U4dARgNb!GQM)@P3?9~D0 zI!pwXVuGC3V1)u7fcw}Q{wMozLQWjh0ImH~N@NFTZ>E2sMSAr;nu-vvE3jB_Re1xl z0NCRF59IypH&g}H?!Yp`1DJO^T4X`R0u#rNcwoKu-*8zAgewBfN+J_CfD$86^MBCl zKKo-+1i)PAP*)jPpTdKBU`h7>_zWSCpMfQ=3NVMb9O8rcYSfdXF*%xCNAu*s7d-#! Y52pFQll|eJ4?3FF)78&qol`;+07hp1^#A|> diff --git a/apple-sso-extension/Assets.xcassets/AppIcon.appiconset/icon_32x32.png b/apple-sso-extension/Assets.xcassets/AppIcon.appiconset/icon_32x32.png deleted file mode 100644 index 5c65259f389e744b9acf784d83c83e2ec8616725..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1844 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=fdzmUKs7M+SzC{oH>NSwWJ?9znhg z3{`3j3=J&|48MRv4KElNN(~qoUL`OvSj}Ky5HFasE6@fg(UKbBnda-upao=eFt9QT zF)#yJj6lf1D8&FW4aj2fVw8rngBUfSYM2-p+A|qgplYIkGzd%pVvrsP&AfmSVd4TN zxN3z3%m_9}+rE9xX+Vmzz$3Dlfk8|agc&`9R6Z~;FrCW`i71Ki^|4CM&(%vz$xlkv ztH>0+>{umUo3Q%e#RDspr3imfVamB1>jfNYSkzLEl1NlCV?QiN}Sf^&XRs)CuG zfu4bq9hZWFf=y9MnpKdC8&o@xXRDM^Qc_^0uU}qXu2*iXmtT~wZ)j<0sc&GUZ)Btk zRH0j3nOBlnp_^B%3^4>|j!SBBa#3bMNoIbY0?6FNr2NtnTO}osMQ{LdXGvxn!lt}p zsJDO~)CbAv8|oS8W7C#ek%>baNCu(}>@SFQHXy^SB7;(k^K(i;&ayK!F|h$#fg*}< zAVdd3Lug)RiJcKt3z{&xt_XxYl0C?x=sNt1GE;#;32_s=*dT;Ere?BMC*7!twxHq_fc*iq!)yVY;kiaZGv&=d?zbY#lt z(dk*FBXai#e~+|q?2H$ujx6nz-oC5+<*~Ki@r8*;W(XKCEflOY`IOx5_8CAE^39>8Sa3#W^hr z)V6d1~f;YOi$yQixSbvOBo-4cI;S5dolku!8cJ(|9mb#lOxocwE zW+T_sH4g9On65k$o#Gj?Rm5t-{St%UYP~Usp0(X#|NBCg-zh0Hc}YTT1!KAGsY<^T zj#p2spS0}Px=^H;D0nR0=gA{8k@-j5o8E4pQy8|a`+=0J@8MqG!;$m#w?}`Cdclxd z*1lqKg!ji6H#M_27cEknT&6k2c<*&5_FHT<9HlD@xb$8~IvkIzVvp)NP;^~E#mib} za@5h5nj)5;k3R2H4*Tpi zqjCE)t9_BPKLo$r-2P?j#wJ(mXyb&Xj0>49Z!FDNc{1T*{`n{M$GwENzHQ};xqhhQ zgq2YUd!$tOR_u<#1j ht5PdM*MU;=e@52z0`*HPe#wLC0#8>zmvv4FO#pi8mW}`b diff --git a/apple-sso-extension/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png b/apple-sso-extension/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png deleted file mode 100644 index 8e2ea66a4a1b4172f7c35e4667e2a0308643f412..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2837 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 Y52pFQll|eJ4?3FF)78&qol`;+07hp1^#A|> diff --git a/apple-sso-extension/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png b/apple-sso-extension/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png deleted file mode 100644 index 099cbd00b660ce71cc35622ca513cb0a4bbd6af5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 51356 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 diff --git a/apple-sso-extension/Assets.xcassets/Contents.json b/apple-sso-extension/Assets.xcassets/Contents.json deleted file mode 100644 index 73c00596a7f..00000000000 --- a/apple-sso-extension/Assets.xcassets/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/apple-sso-extension/Fleet PSSO.xcodeproj/project.pbxproj b/apple-sso-extension/Fleet PSSO.xcodeproj/project.pbxproj deleted file mode 100644 index 1ef75ca05d8..00000000000 --- a/apple-sso-extension/Fleet PSSO.xcodeproj/project.pbxproj +++ /dev/null @@ -1,423 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 56; - objects = { - -/* Begin PBXBuildFile section */ - A1000001000000000000A001 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1000001000000000000B001 /* AppDelegate.swift */; }; - A1000001000000000000A002 /* AuthenticationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1000001000000000000B002 /* AuthenticationViewController.swift */; }; - A1000001000000000000A003 /* AuthenticationViewController+PSSO.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1000001000000000000B003 /* AuthenticationViewController+PSSO.swift */; }; - A1000001000000000000A004 /* AuthenticationViewController+Shared.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1000001000000000000B004 /* AuthenticationViewController+Shared.swift */; }; - A1000001000000000000A005 /* FleetPSSOExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = C1000001000000000000C002 /* FleetPSSOExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; - A1000001000000000000A006 /* AuthenticationViewController+Networking.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1000001000000000000B009 /* AuthenticationViewController+Networking.swift */; }; - A1000001000000000000A007 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B1000001000000000000B010 /* Assets.xcassets */; }; - A1000001000000000000A008 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B1000001000000000000B010 /* Assets.xcassets */; }; -/* End PBXBuildFile section */ - -/* Begin PBXContainerItemProxy section */ - 61000001000000000000A001 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 51000001000000000000A001 /* Project object */; - proxyType = 1; - remoteGlobalIDString = 11000001000000000000A002; - remoteInfo = FleetPSSOExtension; - }; -/* End PBXContainerItemProxy section */ - -/* Begin PBXCopyFilesBuildPhase section */ - D1000001000000000000D001 /* Embed Foundation Extensions */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = ""; - dstSubfolderSpec = 13; - files = ( - A1000001000000000000A005 /* FleetPSSOExtension.appex in Embed Foundation Extensions */, - ); - name = "Embed Foundation Extensions"; - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXCopyFilesBuildPhase section */ - -/* Begin PBXFileReference section */ - B1000001000000000000B001 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - B1000001000000000000B002 /* AuthenticationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationViewController.swift; sourceTree = ""; }; - B1000001000000000000B003 /* AuthenticationViewController+PSSO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AuthenticationViewController+PSSO.swift"; sourceTree = ""; }; - B1000001000000000000B004 /* AuthenticationViewController+Shared.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AuthenticationViewController+Shared.swift"; sourceTree = ""; }; - B1000001000000000000B005 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - B1000001000000000000B006 /* FleetPSSO.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = FleetPSSO.entitlements; sourceTree = ""; }; - B1000001000000000000B007 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - B1000001000000000000B008 /* FleetPSSOExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = FleetPSSOExtension.entitlements; sourceTree = ""; }; - B1000001000000000000B009 /* AuthenticationViewController+Networking.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AuthenticationViewController+Networking.swift"; sourceTree = ""; }; - C1000001000000000000C001 /* FleetPSSO.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = FleetPSSO.app; sourceTree = BUILT_PRODUCTS_DIR; }; - C1000001000000000000C002 /* FleetPSSOExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = FleetPSSOExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; - B1000001000000000000B010 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; -/* End PBXFileReference section */ - -/* Begin PBXFrameworksBuildPhase section */ - E1000001000000000000E001 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - E1000001000000000000E002 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXResourcesBuildPhase section */ - 91000001000000000000A001 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - A1000001000000000000A007 /* Assets.xcassets in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 91000001000000000000A002 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - A1000001000000000000A008 /* Assets.xcassets in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - -/* Begin PBXGroup section */ - F1000001000000000000F001 = { - isa = PBXGroup; - children = ( - F1000001000000000000F002 /* FleetPSSO */, - F1000001000000000000F003 /* FleetPSSOExtension */, - B1000001000000000000B010 /* Assets.xcassets */, - F1000001000000000000F004 /* Products */, - ); - sourceTree = ""; - }; - F1000001000000000000F002 /* FleetPSSO */ = { - isa = PBXGroup; - children = ( - B1000001000000000000B001 /* AppDelegate.swift */, - B1000001000000000000B005 /* Info.plist */, - B1000001000000000000B006 /* FleetPSSO.entitlements */, - ); - path = FleetPSSO; - sourceTree = ""; - }; - F1000001000000000000F003 /* FleetPSSOExtension */ = { - isa = PBXGroup; - children = ( - B1000001000000000000B002 /* AuthenticationViewController.swift */, - B1000001000000000000B003 /* AuthenticationViewController+PSSO.swift */, - B1000001000000000000B004 /* AuthenticationViewController+Shared.swift */, - B1000001000000000000B009 /* AuthenticationViewController+Networking.swift */, - B1000001000000000000B007 /* Info.plist */, - B1000001000000000000B008 /* FleetPSSOExtension.entitlements */, - ); - path = FleetPSSOExtension; - sourceTree = ""; - }; - F1000001000000000000F004 /* Products */ = { - isa = PBXGroup; - children = ( - C1000001000000000000C001 /* FleetPSSO.app */, - C1000001000000000000C002 /* FleetPSSOExtension.appex */, - ); - name = Products; - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - 11000001000000000000A001 /* FleetPSSO */ = { - isa = PBXNativeTarget; - buildConfigurationList = 21000001000000000000A001 /* Build configuration list for PBXNativeTarget "FleetPSSO" */; - buildPhases = ( - 31000001000000000000A001 /* Sources */, - E1000001000000000000E001 /* Frameworks */, - 91000001000000000000A001 /* Resources */, - D1000001000000000000D001 /* Embed Foundation Extensions */, - ); - buildRules = ( - ); - dependencies = ( - 41000001000000000000A001 /* PBXTargetDependency */, - ); - name = FleetPSSO; - productName = FleetPSSO; - productReference = C1000001000000000000C001 /* FleetPSSO.app */; - productType = "com.apple.product-type.application"; - }; - 11000001000000000000A002 /* FleetPSSOExtension */ = { - isa = PBXNativeTarget; - buildConfigurationList = 21000001000000000000A002 /* Build configuration list for PBXNativeTarget "FleetPSSOExtension" */; - buildPhases = ( - 31000001000000000000A002 /* Sources */, - E1000001000000000000E002 /* Frameworks */, - 91000001000000000000A002 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = FleetPSSOExtension; - productName = FleetPSSOExtension; - productReference = C1000001000000000000C002 /* FleetPSSOExtension.appex */; - productType = "com.apple.product-type.app-extension"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - 51000001000000000000A001 /* Project object */ = { - isa = PBXProject; - attributes = { - BuildIndependentTargetsInParallel = YES; - LastSwiftUpdateCheck = 1500; - LastUpgradeCheck = 1500; - TargetAttributes = { - 11000001000000000000A001 = { - CreatedOnToolsVersion = 15.0; - }; - 11000001000000000000A002 = { - CreatedOnToolsVersion = 15.0; - }; - }; - }; - buildConfigurationList = 21000001000000000000A000 /* Build configuration list for PBXProject "Fleet PSSO" */; - compatibilityVersion = "Xcode 14.0"; - developmentRegion = en; - hasScannedForEncodings = 0; - knownRegions = ( - en, - Base, - ); - mainGroup = F1000001000000000000F001; - productRefGroup = F1000001000000000000F004 /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - 11000001000000000000A001 /* FleetPSSO */, - 11000001000000000000A002 /* FleetPSSOExtension */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXSourcesBuildPhase section */ - 31000001000000000000A001 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - A1000001000000000000A001 /* AppDelegate.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 31000001000000000000A002 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - A1000001000000000000A002 /* AuthenticationViewController.swift in Sources */, - A1000001000000000000A003 /* AuthenticationViewController+PSSO.swift in Sources */, - A1000001000000000000A004 /* AuthenticationViewController+Shared.swift in Sources */, - A1000001000000000000A006 /* AuthenticationViewController+Networking.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin PBXTargetDependency section */ - 41000001000000000000A001 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 11000001000000000000A002 /* FleetPSSOExtension */; - targetProxy = 61000001000000000000A001 /* PBXContainerItemProxy */; - }; -/* End PBXTargetDependency section */ - -/* Begin XCBuildConfiguration section */ - 71000001000000000000A001 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = ""; - ENABLE_HARDENED_RUNTIME = YES; - MACOSX_DEPLOYMENT_TARGET = 14.0; - ONLY_ACTIVE_ARCH = YES; - SDKROOT = macosx; - SWIFT_VERSION = 5.0; - }; - name = Debug; - }; - 71000001000000000000A002 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = ""; - ENABLE_HARDENED_RUNTIME = YES; - MACOSX_DEPLOYMENT_TARGET = 14.0; - SDKROOT = macosx; - SWIFT_VERSION = 5.0; - }; - name = Release; - }; - 71000001000000000000A003 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CODE_SIGN_ENTITLEMENTS = FleetPSSO/FleetPSSO.entitlements; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Developer ID Application"; - CODE_SIGN_STYLE = Manual; - COMBINE_HIDPI_IMAGES = YES; - DEVELOPMENT_TEAM = ""; - "DEVELOPMENT_TEAM[sdk=macosx*]" = 5K28R5ZUK5; - ENABLE_APP_SANDBOX = YES; - ENABLE_INCOMING_NETWORK_CONNECTIONS = YES; - ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; - ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; - ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO; - ENABLE_RESOURCE_ACCESS_CALENDARS = NO; - ENABLE_RESOURCE_ACCESS_CAMERA = NO; - ENABLE_RESOURCE_ACCESS_CONTACTS = NO; - ENABLE_RESOURCE_ACCESS_LOCATION = NO; - ENABLE_RESOURCE_ACCESS_PRINTING = NO; - ENABLE_RESOURCE_ACCESS_USB = NO; - INFOPLIST_FILE = FleetPSSO/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = com.fleetdm.pssotesting; - PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = ""; - "PROVISIONING_PROFILE_SPECIFIER[sdk=macosx*]" = "PSSO Testing App"; - SWIFT_VERSION = 5.0; - }; - name = Debug; - }; - 71000001000000000000A004 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CODE_SIGN_ENTITLEMENTS = FleetPSSO/FleetPSSO.entitlements; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Developer ID Application"; - CODE_SIGN_STYLE = Manual; - COMBINE_HIDPI_IMAGES = YES; - DEVELOPMENT_TEAM = ""; - "DEVELOPMENT_TEAM[sdk=macosx*]" = 5K28R5ZUK5; - ENABLE_APP_SANDBOX = YES; - ENABLE_INCOMING_NETWORK_CONNECTIONS = YES; - ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; - ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; - ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO; - ENABLE_RESOURCE_ACCESS_CALENDARS = NO; - ENABLE_RESOURCE_ACCESS_CAMERA = NO; - ENABLE_RESOURCE_ACCESS_CONTACTS = NO; - ENABLE_RESOURCE_ACCESS_LOCATION = NO; - ENABLE_RESOURCE_ACCESS_PRINTING = NO; - ENABLE_RESOURCE_ACCESS_USB = NO; - INFOPLIST_FILE = FleetPSSO/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = com.fleetdm.pssotesting; - PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = ""; - "PROVISIONING_PROFILE_SPECIFIER[sdk=macosx*]" = "PSSO Testing App"; - SWIFT_VERSION = 5.0; - }; - name = Release; - }; - 71000001000000000000A005 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CODE_SIGN_ENTITLEMENTS = FleetPSSOExtension/FleetPSSOExtension.entitlements; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Developer ID Application"; - CODE_SIGN_STYLE = Manual; - DEVELOPMENT_TEAM = ""; - "DEVELOPMENT_TEAM[sdk=macosx*]" = 5K28R5ZUK5; - INFOPLIST_FILE = FleetPSSOExtension/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - "@executable_path/../../../../Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = com.fleetdm.pssotesting.extension; - PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = ""; - "PROVISIONING_PROFILE_SPECIFIER[sdk=macosx*]" = "PSSO Testing Extension"; - SWIFT_VERSION = 5.0; - }; - name = Debug; - }; - 71000001000000000000A006 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CODE_SIGN_ENTITLEMENTS = FleetPSSOExtension/FleetPSSOExtension.entitlements; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Developer ID Application"; - CODE_SIGN_STYLE = Manual; - DEVELOPMENT_TEAM = ""; - "DEVELOPMENT_TEAM[sdk=macosx*]" = 5K28R5ZUK5; - INFOPLIST_FILE = FleetPSSOExtension/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - "@executable_path/../../../../Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = com.fleetdm.pssotesting.extension; - PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = ""; - "PROVISIONING_PROFILE_SPECIFIER[sdk=macosx*]" = "PSSO Testing Extension"; - SWIFT_VERSION = 5.0; - }; - name = Release; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - 21000001000000000000A000 /* Build configuration list for PBXProject "Fleet PSSO" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 71000001000000000000A001 /* Debug */, - 71000001000000000000A002 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 21000001000000000000A001 /* Build configuration list for PBXNativeTarget "FleetPSSO" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 71000001000000000000A003 /* Debug */, - 71000001000000000000A004 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 21000001000000000000A002 /* Build configuration list for PBXNativeTarget "FleetPSSOExtension" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 71000001000000000000A005 /* Debug */, - 71000001000000000000A006 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; -/* End XCConfigurationList section */ - }; - rootObject = 51000001000000000000A001 /* Project object */; -} diff --git a/apple-sso-extension/FleetPSSO/AppDelegate.swift b/apple-sso-extension/FleetPSSO/AppDelegate.swift deleted file mode 100644 index 46203cae109..00000000000 --- a/apple-sso-extension/FleetPSSO/AppDelegate.swift +++ /dev/null @@ -1,27 +0,0 @@ -// AppDelegate.swift -// FleetPSSO host app -// -// Empty Cocoa shell whose only job is to be installable so macOS picks up -// the bundled FleetPSSOExtension. Launching the app once after install is -// enough; the user can quit immediately afterwards. - -import Cocoa - -@main -final class AppDelegate: NSObject, NSApplicationDelegate { - private var window: NSWindow? - - func applicationDidFinishLaunching(_ note: Notification) { - let rect = NSRect(x: 0, y: 0, width: 480, height: 240) - let style: NSWindow.StyleMask = [.titled, .closable, .miniaturizable] - window = NSWindow(contentRect: rect, styleMask: style, - backing: .buffered, defer: false) - window?.title = "Fleet PSSO" - window?.center() - window?.makeKeyAndOrderFront(nil) - } - - func applicationShouldTerminateAfterLastWindowClosed(_ s: NSApplication) -> Bool { - true - } -} diff --git a/apple-sso-extension/FleetPSSO/FleetPSSO.entitlements b/apple-sso-extension/FleetPSSO/FleetPSSO.entitlements deleted file mode 100644 index c72741ff64e..00000000000 --- a/apple-sso-extension/FleetPSSO/FleetPSSO.entitlements +++ /dev/null @@ -1,18 +0,0 @@ - - - - - com.apple.application-identifier - 5K28R5ZUK5.com.fleetdm.pssotesting - com.apple.developer.team-identifier - 5K28R5ZUK5 - com.apple.developer.associated-domains - - authsrv:jordan-fleetdm.ngrok.app - - com.apple.security.app-sandbox - - com.apple.security.network.client - - - diff --git a/apple-sso-extension/FleetPSSO/Info.plist b/apple-sso-extension/FleetPSSO/Info.plist deleted file mode 100644 index bf3bbf16599..00000000000 --- a/apple-sso-extension/FleetPSSO/Info.plist +++ /dev/null @@ -1,26 +0,0 @@ - - - - - CFBundleDevelopmentRegion - en - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - APPL - CFBundleShortVersionString - 0.1.0 - CFBundleVersion - 1 - LSMinimumSystemVersion - 13.0 - NSPrincipalClass - NSApplication - - diff --git a/apple-sso-extension/FleetPSSOExtension/FleetPSSOExtension.entitlements b/apple-sso-extension/FleetPSSOExtension/FleetPSSOExtension.entitlements deleted file mode 100644 index 3d7c28f5647..00000000000 --- a/apple-sso-extension/FleetPSSOExtension/FleetPSSOExtension.entitlements +++ /dev/null @@ -1,20 +0,0 @@ - - - - - com.apple.application-identifier - 5K28R5ZUK5.com.fleetdm.pssotesting.extension - com.apple.developer.team-identifier - 5K28R5ZUK5 - com.apple.developer.associated-domains - - authsrv:jordan-fleetdm.ngrok.app - - com.apple.security.app-sandbox - - com.apple.security.network.client - - com.apple.security.network.server - - - diff --git a/apple-sso-extension/FleetPSSOExtension/Info.plist b/apple-sso-extension/FleetPSSOExtension/Info.plist deleted file mode 100644 index c0274760a3c..00000000000 --- a/apple-sso-extension/FleetPSSOExtension/Info.plist +++ /dev/null @@ -1,28 +0,0 @@ - - - - - CFBundleDevelopmentRegionen - CFBundleDisplayNameFleetPSSOExtension - CFBundleExecutable$(EXECUTABLE_NAME) - CFBundleIdentifier$(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion6.0 - CFBundleName$(PRODUCT_NAME) - CFBundlePackageTypeXPC! - CFBundleShortVersionString0.1.0 - CFBundleVersion1 - LSMinimumSystemVersion13.0 - NSExtension - - NSExtensionPointIdentifier - com.apple.AppSSO.idp-extension - NSExtensionPrincipalClass - $(PRODUCT_MODULE_NAME).AuthenticationViewController - NSExtensionAttributes - - ASAuthorizationProviderExtensionAuthorizationProtocolVersion - 2 - - - - diff --git a/apple-sso-extension/README.md b/apple-sso-extension/README.md deleted file mode 100644 index 779df694676..00000000000 --- a/apple-sso-extension/README.md +++ /dev/null @@ -1,144 +0,0 @@ -# Fleet PSSO (Platform Single Sign-On) Extension — POC - -This is the macOS-side scaffolding for Fleet's Platform Single Sign-On -v2 + Password Mode proof of concept. The Fleet server provides the -IdP endpoints (nonce, JWKS, token, registration); this Xcode project -is what gets installed on a Mac, signed with a Developer ID, notarized, -and bound to Fleet via a `com.apple.extensiblesso` configuration profile. - -> Status: POC scaffolding. The framework conformances compile and the -> registration request flow is wired end-to-end, but the password -> sign-in path is intentionally stubbed. Production hardening (token -> caching, error UX, retries, telemetry) is out of scope here. - -## Layout - -``` -apple-sso-extension/ -├── Fleet PSSO.xcodeproj/ -├── FleetPSSO/ # Cocoa host app (empty shell) -│ ├── AppDelegate.swift -│ ├── Info.plist -│ └── FleetPSSO.entitlements -├── FleetPSSOExtension/ # The actual SSO extension -│ ├── AuthenticationViewController.swift -│ ├── AuthenticationViewController+PSSO.swift -│ ├── AuthenticationViewController+Shared.swift -│ ├── AuthenticationViewController+Networking.swift -│ ├── Info.plist -│ └── FleetPSSOExtension.entitlements -└── README.md -``` - -The host app exists solely so the extension bundle is installable — -launching it once is enough for macOS to discover the bundled -`.appex`. After that the host app does nothing. - -## How it fits with Fleet's server - -The Fleet server exposes the Platform SSO endpoints under -`/api/mdm/apple/psso/`: - -- `POST /api/mdm/apple/psso/nonce` — single-use nonces for token requests -- `POST /api/mdm/apple/psso/registration` — device key registration -- `POST /api/mdm/apple/psso/token` — password login / key request / key exchange -- `GET /api/mdm/apple/psso/jwks` — Fleet's PSSO signing public key - -The extension derives all of them from the single `BaseURL` value in -`loginManager.extensionData`, i.e. the arbitrary dictionary in the -extensible-SSO profile. The issuer/audience is the BaseURL's bare -hostname. - -## Configuration profile - -Install a `com.apple.extensiblesso` profile referencing the extension -bundle ID and Team ID, with an `ExtensionData` dict that includes only -the Fleet server URL (see `fleet-sso-extension-example.mobileconfig` -for a complete profile): - -```xml -BaseURL https://fleet.example.com -``` - -The hostname must also be served as an Apple App Site Association -file at: - -``` -https:///.well-known/apple-app-site-association -``` - -containing an `authsrv` entry that names the extension bundle's -`.`. - -## Placeholders to fill in - -| Placeholder | Where | -|-----------------------|------------------------------------------------| -| `fleet.example.com` | both `.entitlements` files; AASA hosting | -| `com.fleetdm.psso` | `Fleet PSSO.xcodeproj/project.pbxproj` | -| `com.fleetdm.psso.extension` | same | -| Development Team ID | Xcode → target → Signing & Capabilities | - -## Build / sign / package / notarize - -`build.sh` runs the whole pipeline end-to-end: - -```bash -# Apple Developer credentials for notarytool. AC_PASSWORD must be an -# app-specific password — use @keychain: if you've stored one. -export AC_USERNAME="you@example.com" -export AC_TEAM_ID="TEAMID" -export AC_PASSWORD="@keychain:notary" - -./build.sh -``` - -This produces `./FleetPSSO.pkg`, a Developer ID-signed and notarized -installer. The pkg drops `FleetPSSO.app` into `/Applications` (which also -registers the bundled `.appex` with the system). - -Install it: - -```bash -sudo installer -pkg FleetPSSO.pkg -target / -``` - -Behind the scenes, the script: - -1. Builds the app unsigned with `xcodebuild`. -2. Signs the `.appex` and `.app` with **Developer ID Application** (the - hardened runtime + secure timestamp options that notarization - requires). -3. Wraps the `.app` in a flat installer with `pkgbuild`, signs it with - **Developer ID Installer**, and sets the install location to - `/Applications`. -4. Submits the pkg to `notarytool` and waits for the verdict. -5. Staples the notarization ticket to the pkg so it installs offline. - -You'll need both certificates in your login keychain: -- *Developer ID Application: Your Name (TEAMID)* -- *Developer ID Installer: Your Name (TEAMID)* - -## Out of scope (intentional) - -- Real password sign-in UI / token caching -- Keychain persistence (the framework owns key material via - `ASAuthorizationProviderExtensionLoginManager`) -- Refresh, revocation, multi-account -- Pretty error / progress UX - -## Open Apple API questions for the implementer - -- `ASAuthorizationProviderExtensionLoginManager.userDeviceKey(forKeyType:)` - was the throwing variant on macOS 14; double-check the signature on - the macOS SDK you build against — Apple has both throwing and - completion-handler variants depending on release. -- `ASAuthorizationProviderExtensionLoginConfiguration.supportedGrantTypes` - on the password path: confirm whether `.password` is the correct - case name on your SDK. -- AASA `authsrv:` entry format vs. `webcredentials:` — for PSSO it is - `authsrv:` per WWDC 2022. - -## Debug notes - -authsrv: links and swcutil/swcd were the biggest stumbling block I ran into diff --git a/apple-sso-extension/build.sh b/apple-sso-extension/build.sh deleted file mode 100755 index ab593a8d31d..00000000000 --- a/apple-sso-extension/build.sh +++ /dev/null @@ -1,88 +0,0 @@ -#!/bin/bash -# Build, sign, package, and notarize Fleet PSSO. -# -# Output: ./FleetPSSO.pkg — a Developer ID-signed, notarized installer that -# drops FleetPSSO.app into /Applications and brings the bundled SSO appex -# along for the ride. -# -# Required env for notarization: -# AC_USERNAME Apple ID email -# AC_TEAM_ID Apple Developer Team ID -# AC_PASSWORD App-specific password (use @keychain:... for keychain refs) - -set -euo pipefail - -# Not checked in -APP_PROFILE="./profiles/PSSO_Testing_App.provisionprofile" -APPEX_PROFILE="./profiles/PSSO_Testing_Extension.provisionprofile" - -# TODO Update -TEAM_ID='5K28R5ZUK5' -NAME_WITH_TEAM="Elijah Montgomery (${TEAM_ID})" -APP_SIGN_ID="Developer ID Application: ${NAME_WITH_TEAM}" -PKG_SIGN_ID="Developer ID Installer: ${NAME_WITH_TEAM}" - -APP_PATH="./build/Build/Products/Release/FleetPSSO.app" -APPEX_PATH="${APP_PATH}/Contents/PlugIns/FleetPSSOExtension.appex" -PKG_PATH="./FleetPSSO.pkg" - -# Developer ID provisioning profiles. Required to make codesign honor the -# `com.apple.developer.*` entitlements (associated-domains, etc.). Download -# these from the Apple Developer portal under Profiles → Developer ID for -# each App ID and place them at the paths below. - -for p in "${APP_PROFILE}" "${APPEX_PROFILE}"; do - if [[ ! -f "${p}" ]]; then - echo "ERROR: missing provisioning profile ${p}" >&2 - echo " Download Developer ID profiles for the host app and extension" >&2 - echo " from https://developer.apple.com/account/resources/profiles/list" >&2 - exit 1 - fi -done - -echo "==> Clean build (unsigned; we re-sign with Developer ID below)" -xcodebuild -project "Fleet PSSO.xcodeproj" -scheme FleetPSSO -configuration Release -derivedDataPath ./build \ - CODE_SIGN_IDENTITY="" \ - CODE_SIGNING_REQUIRED=NO \ - CODE_SIGNING_ALLOWED=NO \ - clean build - -echo "==> Embedding extension provisioning profile" -cp "${APPEX_PROFILE}" "${APPEX_PATH}/Contents/embedded.provisionprofile" - -echo "==> Signing SSO extension" -codesign --force --options runtime --timestamp \ - --sign "${APP_SIGN_ID}" \ - --entitlements FleetPSSOExtension/FleetPSSOExtension.entitlements \ - "${APPEX_PATH}" - -echo "==> Embedding host app provisioning profile" -cp "${APP_PROFILE}" "${APP_PATH}/Contents/embedded.provisionprofile" - -echo "==> Signing host app" -codesign --force --options runtime --timestamp \ - --sign "${APP_SIGN_ID}" \ - --entitlements FleetPSSO/FleetPSSO.entitlements \ - "${APP_PATH}" - -echo "==> Building installer pkg (installs to /Applications)" -pkgbuild \ - --component "${APP_PATH}" \ - --install-location /Applications \ - --sign "${PKG_SIGN_ID}" \ - --timestamp \ - "${PKG_PATH}" - -echo "==> Notarizing pkg" -xcrun notarytool submit "${PKG_PATH}" \ - --apple-id "${AC_USERNAME}" \ - --team-id "${AC_TEAM_ID}" \ - --password "${AC_PASSWORD}" \ - --wait - -echo "==> Stapling notarization ticket to pkg" -xcrun stapler staple "${PKG_PATH}" - -echo "" -echo "Done. Install with:" -echo " sudo installer -pkg ${PKG_PATH} -target /" diff --git a/apple-sso-extension/FleetPSSOExtension/AuthenticationViewController+Networking.swift b/apps/fleet-desktop-macos/FleetPSSOExtension/AuthenticationViewController+Networking.swift similarity index 100% rename from apple-sso-extension/FleetPSSOExtension/AuthenticationViewController+Networking.swift rename to apps/fleet-desktop-macos/FleetPSSOExtension/AuthenticationViewController+Networking.swift diff --git a/apple-sso-extension/FleetPSSOExtension/AuthenticationViewController+PSSO.swift b/apps/fleet-desktop-macos/FleetPSSOExtension/AuthenticationViewController+PSSO.swift similarity index 100% rename from apple-sso-extension/FleetPSSOExtension/AuthenticationViewController+PSSO.swift rename to apps/fleet-desktop-macos/FleetPSSOExtension/AuthenticationViewController+PSSO.swift diff --git a/apple-sso-extension/FleetPSSOExtension/AuthenticationViewController+Shared.swift b/apps/fleet-desktop-macos/FleetPSSOExtension/AuthenticationViewController+Shared.swift similarity index 100% rename from apple-sso-extension/FleetPSSOExtension/AuthenticationViewController+Shared.swift rename to apps/fleet-desktop-macos/FleetPSSOExtension/AuthenticationViewController+Shared.swift diff --git a/apple-sso-extension/FleetPSSOExtension/AuthenticationViewController.swift b/apps/fleet-desktop-macos/FleetPSSOExtension/AuthenticationViewController.swift similarity index 100% rename from apple-sso-extension/FleetPSSOExtension/AuthenticationViewController.swift rename to apps/fleet-desktop-macos/FleetPSSOExtension/AuthenticationViewController.swift diff --git a/apps/fleet-desktop-macos/README.md b/apps/fleet-desktop-macos/README.md index 54deceb2215..6d674b13018 100644 --- a/apps/fleet-desktop-macos/README.md +++ b/apps/fleet-desktop-macos/README.md @@ -2,6 +2,8 @@ A native macOS application that provides end users with a self-service portal for [Fleet](https://fleetdm.com). It integrates with Fleet's [orbit](https://fleetdm.com/docs/get-started/anatomy#orbit) agent to give users direct access to device management features in a native window instead of a browser. +It also embeds the **Fleet Platform SSO (PSSO) extension** (`FleetPSSOExtension.appex`), which implements Apple's Platform Single Sign-On v2 + Password Mode so Fleet can create a Mac's local account and keep its password in sync with the user's IdP credentials. See [Platform SSO extension](#platform-sso-extension) below. + > **Heads up — two things named "Fleet Desktop":** Fleet's agent already ships a tray/menu-bar component called Fleet Desktop (bundle ID `com.fleetdm.desktop`, built from `orbit/cmd/desktop`). This is a separate, standalone native app (bundle ID `com.fleetdm.fleet-desktop`) distributed as its own `.pkg`. They use different bundle IDs and can coexist. ## Features @@ -9,6 +11,7 @@ A native macOS application that provides end users with a self-service portal fo - **Native macOS app** built with Swift and AppKit - **Universal binary** supporting Apple Silicon (arm64) and Intel (x86_64) - **Self-service portal** embedded in a native window via WKWebView +- **Embedded Platform SSO extension** for IdP-based local account creation and password sync - **Automatic token refresh** handles hourly token rotation transparently - **Loading screen** with Fleet logo while the portal loads - **File download support** for `.mobileconfig` profiles and other files served by Fleet @@ -19,7 +22,7 @@ A native macOS application that provides end users with a self-service portal fo ## Requirements -- macOS 13.0 (Ventura) or later +- macOS 13.0 (Ventura) or later for the app; the PSSO extension requires macOS 14.0+ (the password-sync feature targets macOS 26+) - MDM-enabled Mac with Fleet's managed preferences profile installed - Fleet's orbit agent installed and enrolled - The orbit identifier file must exist at `/opt/orbit/identifier` @@ -33,6 +36,8 @@ The signed, notarized `.pkg` is produced by CI (see [CI/CD](#cicd)) and uploaded The installer requires an MDM-enabled Mac. It checks for the Fleet managed preferences profile before proceeding — if the profile is not found, the installer displays an error and aborts. The app is placed in `/Applications` with `root:admin` ownership and `755` permissions. On upgrades, the installer gracefully quits Fleet Desktop before installing and automatically relaunches it afterward. +Installing the app into `/Applications` is also what registers the bundled `FleetPSSOExtension.appex` with the system so it becomes selectable by a `com.apple.extensiblesso` configuration profile. + ## How It Works 1. **Reads the Fleet URL** from MDM managed preferences (see [Configuration Sources](#configuration-sources)) @@ -63,6 +68,47 @@ When Fleet serves downloadable content (e.g., MDM enrollment profiles): - The WebView uses a non-persistent data store (no cookies or cache persist between sessions) - Mutable state is protected by a serial dispatch queue for thread safety +## Platform SSO extension + +`FleetPSSOExtension.appex` is an Apple `com.apple.AppSSO.idp-extension` that implements Platform SSO v2 in Password Mode. The Fleet server provides the IdP endpoints; the extension registers the device's keys with Fleet and proxies password sign-in / key exchange through it. + +The extension is bundled inside the app at `Fleet Desktop.app/Contents/PlugIns/FleetPSSOExtension.appex` and ships in the same `.pkg`. + +### How it binds to a Fleet server + +The extension derives all of its endpoints from a single `BaseURL` value supplied in the `ExtensionData` dictionary of a `com.apple.extensiblesso` configuration profile (see [`fleet-sso-extension-example.mobileconfig`](./fleet-sso-extension-example.mobileconfig)): + +```xml +BaseURL https://fleet.example.com +``` + +From that it derives, under `/api/mdm/apple/psso/`: + +- `POST /nonce` — single-use nonces for token requests +- `POST /registration` — device key registration +- `POST /token` — password login / key request / key exchange +- `GET /jwks` — Fleet's PSSO signing public key + +The server fleet server also serves an Apple App Site Association file at `https:///.well-known/apple-app-site-association` containing an `authsrv` entry naming the extension's `.` — i.e. `8VBZ3948LU.com.fleetdm.fleet-desktop.pssoextension`. + +Because the same generic, CI-built extension must work against *any* Fleet server, the associated domain is **not** baked into the binary. Instead: + +- The entitlement `com.apple.developer.associated-domains` ships as an **empty array**, with `com.apple.developer.associated-domains.mdm-managed` set to `true`. +- The actual `authsrv:` domain (the configured Fleet server) is delivered at runtime by an MDM **AssociatedDomains** payload targeting the extension's bundle ID. + +### Entitlements + +Both the host app and the extension carry restricted (Apple-managed) entitlements. These are not freely assertable — `codesign` only honors them when a Developer ID **provisioning profile** that grants them is embedded in the bundle (see [Signing secrets](#signing-secrets)). + +| Bundle | Entitlement | Value | +|--------|-------------|-------| +| App + extension | `com.apple.developer.associated-domains` | empty array (must exist) | +| App + extension | `com.apple.developer.associated-domains.mdm-managed` | `true` | +| Extension only | `com.apple.security.app-sandbox` | `true` | +| Extension only | `com.apple.security.network.client` | `true` | + +The host app is deliberately **not** sandboxed — it reads `/opt/orbit/identifier` and the managed-preferences plist outside any container. App extensions are always sandboxed. + ## Development ### Project Structure @@ -70,22 +116,33 @@ When Fleet serves downloadable content (e.g., MDM enrollment profiles): ``` apps/fleet-desktop-macos/ ├── FleetDesktop/ -│ ├── FleetDesktopApp.swift # App delegate, main menu, entry point -│ ├── FleetService.swift # Config reading, token management, refresh timer -│ ├── BrowserWindow.swift # WKWebView window, loading overlay, downloads -│ ├── Info.plist # App bundle metadata -│ ├── AppIcon.icns # App icon -│ └── fleet-logo.png # Fleet logo for loading screen -├── build.sh # Compiles universal binary -└── build-pkg.sh # Creates the .pkg installer +│ ├── FleetDesktopApp.swift # App delegate, main menu, entry point +│ ├── FleetService.swift # Config reading, token management, refresh timer +│ ├── BrowserWindow.swift # WKWebView window, loading overlay, downloads +│ ├── Info.plist # App bundle metadata +│ ├── FleetDesktop.entitlements # Host-app entitlements (managed associated domains) +│ ├── AppIcon.icns # App icon +│ └── fleet-logo.png # Fleet logo for loading screen +├── FleetPSSOExtension/ +│ ├── AuthenticationViewController.swift # Principal class (SSO request handler) +│ ├── AuthenticationViewController+PSSO.swift # Registration handler +│ ├── AuthenticationViewController+Shared.swift # Payload / key-ID / config helpers +│ ├── AuthenticationViewController+Networking.swift # URLSession against Fleet +│ ├── Info.plist # appex metadata (NSExtension dict) +│ └── FleetPSSOExtension.entitlements # Extension entitlements +├── fleet-sso-extension-example.mobileconfig # Example com.apple.extensiblesso profile +├── build.sh # Compiles the universal app + appex +└── build-pkg.sh # Creates the .pkg installer ``` The CI workflow lives at [`.github/workflows/fleet-desktop-macos-build.yml`](../../.github/workflows/fleet-desktop-macos-build.yml). +The PSSO extension is built as a Foundation app extension with `swiftc`: there is no `main()`; the entry point is `NSExtensionMain` and the principal class is loaded from the appex `Info.plist`. `swiftc`'s `-module-name` must match the module prefix in `NSExtensionPrincipalClass` (`FleetPSSOExtension`). + ### Building Locally ```bash -# Build the app +# Build the app (with the embedded extension) ./build.sh # Run @@ -95,7 +152,16 @@ open "build/Fleet Desktop.app" ./build-pkg.sh ``` -Local builds are unsigned. Signing and notarization happen in CI with Fleet's Developer ID certificates. +Local builds are unsigned. Signing and notarization happen in CI with Fleet's Developer ID certificates and provisioning profiles. You can ad-hoc sign locally to sanity-check the bundle layout (the restricted entitlements won't be honored without a real profile): + +```bash +codesign --force --options runtime --sign - \ + --entitlements FleetPSSOExtension/FleetPSSOExtension.entitlements \ + "build/Fleet Desktop.app/Contents/PlugIns/FleetPSSOExtension.appex" +codesign --force --options runtime --sign - \ + --entitlements FleetDesktop/FleetDesktop.entitlements "build/Fleet Desktop.app" +codesign --verify --deep --strict "build/Fleet Desktop.app" +``` ### Environment Variables @@ -136,18 +202,19 @@ open fleet://refetch [`.github/workflows/fleet-desktop-macos-build.yml`](../../.github/workflows/fleet-desktop-macos-build.yml) runs on pull requests touching `apps/fleet-desktop-macos/**`, on push to `main`, and via manual dispatch. It: -1. Compiles a universal binary (arm64 + x86_64) -2. Code signs the app with Fleet's Developer ID Application certificate -3. Packages into a `.pkg` installer with a custom distribution XML -4. Signs the `.pkg` with Fleet's Developer ID Installer certificate -5. Notarizes with Apple and staples the ticket -6. Uploads the signed `.pkg` as a workflow artifact (retained for 30 days) +1. Compiles a universal binary (arm64 + x86_64) for the app and the extension, and assembles the `.appex` inside the `.app` +2. Embeds the Developer ID provisioning profiles into the app and extension bundles +3. Code signs **inside-out** — the extension first, then the host app — each with its own entitlements +4. Packages into a `.pkg` installer with a custom distribution XML +5. Signs the `.pkg` with Fleet's Developer ID Installer certificate +6. Notarizes with Apple and staples the ticket +7. Uploads the signed `.pkg` as a workflow artifact (retained for 30 days) -Pull requests (including from forks) only run step 1 — they verify the app compiles and packages, but skip signing/notarization, which require secrets unavailable to forks. +The workflow always signs and notarizes. Runs without access to the signing secrets — fork pull requests, or any run before the provisioning profiles have been added — **fail** rather than producing an unsigned artifact. ### Signing secrets -The workflow reuses the same repository secrets already used by Fleet's other macOS build workflows — **no new secrets are required**: +The workflow reuses the Developer ID certificate secrets already used by Fleet's other macOS build workflows, plus **two new provisioning-profile secrets** required for the extension's restricted entitlements: | Secret | Purpose | |--------|---------| @@ -156,9 +223,30 @@ The workflow reuses the same repository secrets already used by Fleet's other ma | `APPLE_USERNAME` / `APPLE_PASSWORD` | Apple ID + app-specific password for notarization | | `APPLE_TEAM_ID` | Apple Developer Team ID | | `KEYCHAIN_PASSWORD` | Temporary CI keychain password | +| `APPLE_FLEET_DESKTOP_APP_PROFILE_B64` | base64 of the Developer ID provisioning profile for `com.fleetdm.fleet-desktop` | +| `APPLE_PSSO_EXT_PROFILE_B64` | base64 of the Developer ID provisioning profile for `com.fleetdm.fleet-desktop.pssoextension` | The Developer ID certificate identities (SHA-1) are pinned in the workflow `env` block, matching the identities used by Fleet's orbit and fleetd-base builds. +#### Provisioning profiles (one-time Apple Developer portal setup) + +The `com.apple.developer.associated-domains*` entitlements are Apple-managed: `codesign` will not honor them without a Developer ID provisioning profile that grants them. Profiles are **not committed** — they are team/cert-bound build inputs that expire, so they're stored as the base64 secrets above (the same pattern as the `.p12` certs). + +Under Fleet's Apple Developer team (`8VBZ3948LU`, the team that owns the pinned Developer ID certificates): + +1. Register two App IDs: + - `com.fleetdm.fleet-desktop` (host app) + - `com.fleetdm.fleet-desktop.pssoextension` (extension) +2. Enable the **Associated Domains** and **MDM Managed Associated Domains** capabilities on both App IDs. +3. Create a **Developer ID** provisioning profile (distribution, platform macOS) for each App ID. +4. base64-encode each downloaded `.provisionprofile` and store them as `APPLE_FLEET_DESKTOP_APP_PROFILE_B64` and `APPLE_PSSO_EXT_PROFILE_B64`: + ```bash + base64 -i FleetDesktop_DeveloperID.provisionprofile | pbcopy # → APPLE_FLEET_DESKTOP_APP_PROFILE_B64 + base64 -i FleetPSSOExtension_DeveloperID.provisionprofile | pbcopy # → APPLE_PSSO_EXT_PROFILE_B64 + ``` + +Re-encode and update the secrets when a profile expires or the signing certificate is rotated. To confirm a profile grants the expected entitlements, dump it with `security cms -D -i .provisionprofile`. + ## License Licensed under the MIT Expat license via the repository [root LICENSE](../LICENSE). diff --git a/apps/fleet-desktop-macos/build.sh b/apps/fleet-desktop-macos/build.sh index b42c9c52740..8b942ad9988 100755 --- a/apps/fleet-desktop-macos/build.sh +++ b/apps/fleet-desktop-macos/build.sh @@ -3,33 +3,35 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SRC_DIR="$SCRIPT_DIR/FleetDesktop" +EXT_SRC_DIR="$SCRIPT_DIR/FleetPSSOExtension" BUILD_DIR="$SCRIPT_DIR/build" APP_DIR="$BUILD_DIR/Fleet Desktop.app" CONTENTS_DIR="$APP_DIR/Contents" MACOS_DIR="$CONTENTS_DIR/MacOS" +APPEX_DIR="$CONTENTS_DIR/PlugIns/FleetPSSOExtension.appex" +APPEX_CONTENTS_DIR="$APPEX_DIR/Contents" +APPEX_MACOS_DIR="$APPEX_CONTENTS_DIR/MacOS" echo "Building Fleet Desktop..." rm -rf "$BUILD_DIR" mkdir -p "$MACOS_DIR" +SDK="$(xcrun --show-sdk-path)" + +# --- Host app ------------------------------------------------------------- SOURCES=( "$SRC_DIR/FleetService.swift" "$SRC_DIR/BrowserWindow.swift" "$SRC_DIR/FleetDesktopApp.swift" ) -SDK="$(xcrun --show-sdk-path)" SWIFT_FLAGS=(-sdk "$SDK" -parse-as-library -O) -# Build for arm64 swiftc -target arm64-apple-macos13 "${SWIFT_FLAGS[@]}" \ -o "$BUILD_DIR/FleetDesktop-arm64" "${SOURCES[@]}" - -# Build for x86_64 swiftc -target x86_64-apple-macos13 "${SWIFT_FLAGS[@]}" \ -o "$BUILD_DIR/FleetDesktop-x86_64" "${SOURCES[@]}" -# Create universal binary lipo -create \ "$BUILD_DIR/FleetDesktop-arm64" \ "$BUILD_DIR/FleetDesktop-x86_64" \ @@ -47,5 +49,46 @@ if [ -f "$SRC_DIR/fleet-logo.png" ]; then cp "$SRC_DIR/fleet-logo.png" "$CONTENTS_DIR/Resources/fleet-logo.png" fi +# --- Platform SSO extension (.appex) -------------------------------------- +# Built as a Foundation app extension: no main(), entry point is +# NSExtensionMain (the principal class comes from the appex Info.plist). +# -module-name must match the NSExtensionPrincipalClass module prefix. +echo "Building Fleet PSSO extension..." +mkdir -p "$APPEX_MACOS_DIR" + +EXT_SOURCES=( + "$EXT_SRC_DIR/AuthenticationViewController.swift" + "$EXT_SRC_DIR/AuthenticationViewController+PSSO.swift" + "$EXT_SRC_DIR/AuthenticationViewController+Shared.swift" + "$EXT_SRC_DIR/AuthenticationViewController+Networking.swift" +) +EXT_SWIFT_FLAGS=( + -sdk "$SDK" -parse-as-library -O + -module-name FleetPSSOExtension + -framework AuthenticationServices -framework IOKit + -Xlinker -e -Xlinker _NSExtensionMain +) + +swiftc -target arm64-apple-macos14 "${EXT_SWIFT_FLAGS[@]}" \ + -o "$BUILD_DIR/FleetPSSOExtension-arm64" "${EXT_SOURCES[@]}" +swiftc -target x86_64-apple-macos14 "${EXT_SWIFT_FLAGS[@]}" \ + -o "$BUILD_DIR/FleetPSSOExtension-x86_64" "${EXT_SOURCES[@]}" + +lipo -create \ + "$BUILD_DIR/FleetPSSOExtension-arm64" \ + "$BUILD_DIR/FleetPSSOExtension-x86_64" \ + -output "$APPEX_MACOS_DIR/FleetPSSOExtension" + +rm "$BUILD_DIR/FleetPSSOExtension-arm64" "$BUILD_DIR/FleetPSSOExtension-x86_64" + +cp "$EXT_SRC_DIR/Info.plist" "$APPEX_CONTENTS_DIR/Info.plist" + +# Keep the embedded extension's version in lockstep with the host app. +APP_SHORT_VERSION=$(/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "$CONTENTS_DIR/Info.plist") +APP_BUILD_VERSION=$(/usr/libexec/PlistBuddy -c "Print :CFBundleVersion" "$CONTENTS_DIR/Info.plist") +/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $APP_SHORT_VERSION" "$APPEX_CONTENTS_DIR/Info.plist" +/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $APP_BUILD_VERSION" "$APPEX_CONTENTS_DIR/Info.plist" + echo "Build complete: $APP_DIR" +echo " embedded extension: $APPEX_DIR" echo "Run with: open \"$APP_DIR\"" diff --git a/apple-sso-extension/fleet-sso-extension-example.mobileconfig b/apps/fleet-desktop-macos/fleet-sso-extension-example.mobileconfig similarity index 94% rename from apple-sso-extension/fleet-sso-extension-example.mobileconfig rename to apps/fleet-desktop-macos/fleet-sso-extension-example.mobileconfig index 4ae19258846..e7293ba0962 100644 --- a/apple-sso-extension/fleet-sso-extension-example.mobileconfig +++ b/apps/fleet-desktop-macos/fleet-sso-extension-example.mobileconfig @@ -11,7 +11,7 @@ https://fleet.example.com ExtensionIdentifier - com.fleetdm.pssotesting.extension + com.fleetdm.fleet-desktop.pssoextension PayloadDisplayName Fleet Extensible Single Sign-On PayloadIdentifier @@ -34,7 +34,7 @@ ScreenLockedBehavior DoNotHandle TeamIdentifier - 5K28R5ZUK5 + 8VBZ3948LU Type Redirect URLs diff --git a/docs/Contributing/research/mdm/psso.md b/docs/Contributing/research/mdm/psso.md index b9db36a9468..82ec23c8df6 100644 --- a/docs/Contributing/research/mdm/psso.md +++ b/docs/Contributing/research/mdm/psso.md @@ -117,9 +117,9 @@ The nonce store mirrors `server/mdm/acme/internal/redis_nonces_store/` and expos The signing key(`MDMAssetPSSOSigningKey`) and the self-signed PSSO CA (`MDMAssetPSSOCACert`, backed by the same private key) are created once, the first time the feature is configured, via `bootstrapPSSOAssets` in `ModifyAppConfig` (covering the config API and GitOps). The bootstrap is idempotent and never regenerates existing assets, so the JWKS key and CA stay stable across reconfiguration and disable/re-enable. -### In-tree Swift extension at `apple-sso-extension/` +### In-tree Swift extension (shipped with Fleet Desktop) -The Swift sources for the SSO extension live in this repo at `apple-sso-extension/`. Signing and notarization happen out-of-band using the deployer's own Apple Developer ID; Fleet does not ship a signed binary. The hostname declared in the extension's `authsrv:` entitlement must match the hostname served by `/.well-known/apple-app-site-association`. +The Swift sources for the SSO extension live in this repo at `apps/fleet-desktop-macos/FleetPSSOExtension/`. The extension is built as an `.appex` embedded in the Fleet Desktop app and shipped in the same `.pkg`; CI signs and notarizes it with Fleet's Developer ID certificates and Developer ID provisioning profiles (team `8VBZ3948LU`). The associated domain is **not** baked into the binary — the entitlement ships as an empty array with `com.apple.developer.associated-domains.mdm-managed` set, and the actual `authsrv:` hostname is delivered at runtime by an MDM AssociatedDomains payload. That hostname must match the host served at `/.well-known/apple-app-site-association`. **Device registration must POST directly (no WKWebView).** `beginDeviceRegistration` submits the device's signing/encryption public keys to `/api/mdm/apple/psso/registration` via a direct `URLSession` POST, and reports `.success` only after Fleet returns 2xx. An earlier implementation routed the POST through a WKWebView navigation-delegate intercept (a holdover from an OAuth-code registration model). That web view isn't functional during Setup Assistant, so with `EnableRegistrationDuringSetup` the POST silently never fired, yet `completion(.success)` was still called unconditionally — the framework then went straight to nonce → token with an unregistered key and the token endpoint 404'd ("PSSOKeyID … not found", surfaced on-device as "Incorrect username or password"). Password-mode registration has no browser step, so the web view was never needed; awaiting the direct POST also guarantees the keys are persisted before the framework proceeds to authentication. diff --git a/ee/server/service/apple_psso.go b/ee/server/service/apple_psso.go index e5e49f2e759..25381b9c66a 100644 --- a/ee/server/service/apple_psso.go +++ b/ee/server/service/apple_psso.go @@ -40,14 +40,12 @@ type pssoServiceState struct { const ( pssoSigningAlg = "ES256" - // TODO: It's not clear if we need the overall app bundle ID or not either. We'll add it just in case - bundleID1 = "com.fleetdm.pssotesting" - bundleID2 = "com.fleetdm.pssotesting.extension" - - // TODO: Not sure if I actually need to use the team or my private user one so we'll define - // both for now... - teamID1 = "5K28R5ZUK5" - teamID2 = "B34KW9D28L" + // The host app bundle ID is included alongside the extension's just in case; + // PSSO validates against the extension, but listing both is harmless. + appBundleID = "com.fleetdm.fleet-desktop" + extensionBundleID = "com.fleetdm.fleet-desktop.pssoextension" + + fleetTeamID = "8VBZ3948LU" ) // getPSSOSigningKey loads Fleet's PSSO signing key from mdm_config_assets, @@ -742,11 +740,11 @@ func (svc *Service) PSSOJWKS(ctx context.Context) ([]byte, error) { return json.Marshal(jwks) } -// pssoAASAEntry mirrors the apple-app-site-association shape Apple's -// framework consumes for PSSO. Only webcredentials.apps is required. +// pssoAASA mirrors the apple-app-site-association shape Apple's framework +// consumes for PSSO. PSSO validates the extension's authsrv: entitlement, so +// only the authsrv service is needed (webcredentials is for password autofill). type pssoAASA struct { - WebCredentials pssoAASAApps `json:"webcredentials"` - AuthSrv pssoAASAApps `json:"authsrv"` + AuthSrv pssoAASAApps `json:"authsrv"` } type pssoAASAApps struct { @@ -771,11 +769,8 @@ func (svc *Service) PSSOAASA(ctx context.Context) ([]byte, error) { return nil, ¬FoundError{} } - ids := []string{teamID1 + "." + bundleID1, teamID2 + "." + bundleID1, teamID1 + "." + bundleID2, teamID2 + "." + bundleID2} + ids := []string{fleetTeamID + "." + appBundleID, fleetTeamID + "." + extensionBundleID} doc := pssoAASA{ - WebCredentials: pssoAASAApps{ - Apps: ids, - }, AuthSrv: pssoAASAApps{ Apps: ids, }, From 865cac7ecc4fde8c8249d414f9b1324240a3068e Mon Sep 17 00:00:00 2001 From: Jordan Montgomery Date: Thu, 18 Jun 2026 16:12:04 -0400 Subject: [PATCH 23/28] Fix Add missed files --- .../FleetDesktop/FleetDesktop.entitlements | 14 ++++++++++ .../FleetPSSOExtension.entitlements | 18 ++++++++++++ .../FleetPSSOExtension/Info.plist | 28 +++++++++++++++++++ 3 files changed, 60 insertions(+) create mode 100644 apps/fleet-desktop-macos/FleetDesktop/FleetDesktop.entitlements create mode 100644 apps/fleet-desktop-macos/FleetPSSOExtension/FleetPSSOExtension.entitlements create mode 100644 apps/fleet-desktop-macos/FleetPSSOExtension/Info.plist diff --git a/apps/fleet-desktop-macos/FleetDesktop/FleetDesktop.entitlements b/apps/fleet-desktop-macos/FleetDesktop/FleetDesktop.entitlements new file mode 100644 index 00000000000..34f494f1bad --- /dev/null +++ b/apps/fleet-desktop-macos/FleetDesktop/FleetDesktop.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.application-identifier + 8VBZ3948LU.com.fleetdm.fleet-desktop + com.apple.developer.team-identifier + 8VBZ3948LU + com.apple.developer.associated-domains + + com.apple.developer.associated-domains.mdm-managed + + + diff --git a/apps/fleet-desktop-macos/FleetPSSOExtension/FleetPSSOExtension.entitlements b/apps/fleet-desktop-macos/FleetPSSOExtension/FleetPSSOExtension.entitlements new file mode 100644 index 00000000000..9cbac326393 --- /dev/null +++ b/apps/fleet-desktop-macos/FleetPSSOExtension/FleetPSSOExtension.entitlements @@ -0,0 +1,18 @@ + + + + + com.apple.application-identifier + 8VBZ3948LU.com.fleetdm.fleet-desktop.pssoextension + com.apple.developer.team-identifier + 8VBZ3948LU + com.apple.developer.associated-domains + + com.apple.developer.associated-domains.mdm-managed + + com.apple.security.app-sandbox + + com.apple.security.network.client + + + diff --git a/apps/fleet-desktop-macos/FleetPSSOExtension/Info.plist b/apps/fleet-desktop-macos/FleetPSSOExtension/Info.plist new file mode 100644 index 00000000000..cfa279f000e --- /dev/null +++ b/apps/fleet-desktop-macos/FleetPSSOExtension/Info.plist @@ -0,0 +1,28 @@ + + + + + CFBundleDevelopmentRegionen + CFBundleDisplayNameFleet PSSO Extension + CFBundleExecutableFleetPSSOExtension + CFBundleIdentifiercom.fleetdm.fleet-desktop.pssoextension + CFBundleInfoDictionaryVersion6.0 + CFBundleNameFleetPSSOExtension + CFBundlePackageTypeXPC! + CFBundleShortVersionString1.3.1 + CFBundleVersion6 + LSMinimumSystemVersion14.0 + NSExtension + + NSExtensionPointIdentifier + com.apple.AppSSO.idp-extension + NSExtensionPrincipalClass + FleetPSSOExtension.AuthenticationViewController + NSExtensionAttributes + + ASAuthorizationProviderExtensionAuthorizationProtocolVersion + 2 + + + + From c8fdefa53380aa647181c59b71bfbc7a8c832d3e Mon Sep 17 00:00:00 2001 From: Jordan Montgomery Date: Thu, 18 Jun 2026 16:23:36 -0400 Subject: [PATCH 24/28] Add assoc domains to profile --- .../fleet-sso-extension-example.mobileconfig | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/apps/fleet-desktop-macos/fleet-sso-extension-example.mobileconfig b/apps/fleet-desktop-macos/fleet-sso-extension-example.mobileconfig index e7293ba0962..1f92a2b01af 100644 --- a/apps/fleet-desktop-macos/fleet-sso-extension-example.mobileconfig +++ b/apps/fleet-desktop-macos/fleet-sso-extension-example.mobileconfig @@ -42,6 +42,26 @@ https://fleet.example.com + + PayloadType + com.apple.associated-domains + PayloadIdentifier + com.apple.associated-domains.4D68D4CF-1250-4FF4-AFFB-1176DB539C49 + PayloadUUID + d77b6a04-6527-4333-1010-46422e8a5844 + Configuration + + + ApplicationIdentifier + 8VBZ3948LU.com.fleetdm.fleet-desktop.pssoextension + AssociatedDomains + + authsrv:fleet.example.com + + + + + PayloadDisplayName Fleet Platform SSO From 36ce4585fc1140dc73c55c978104cc0168f0dffe Mon Sep 17 00:00:00 2001 From: Jordan Montgomery Date: Fri, 19 Jun 2026 09:08:08 -0400 Subject: [PATCH 25/28] Update signing info --- .../workflows/fleet-desktop-macos-build.yml | 24 +++++++++++++++++-- apps/fleet-desktop-macos/README.md | 4 ++-- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/.github/workflows/fleet-desktop-macos-build.yml b/.github/workflows/fleet-desktop-macos-build.yml index f932f7d91ea..cf4d7bbc8c2 100644 --- a/.github/workflows/fleet-desktop-macos-build.yml +++ b/.github/workflows/fleet-desktop-macos-build.yml @@ -109,11 +109,31 @@ jobs: exit 1 fi - # Developer ID profiles authorizing the restricted entitlements. codesign - # validates the profile against the entitlements file at sign time. + # Developer ID profiles authorizing the restricted entitlements. echo "$APPLE_PSSO_EXT_PROFILE_B64" | base64 --decode > "$APPEX/Contents/embedded.provisionprofile" echo "$APPLE_FLEET_DESKTOP_APP_PROFILE_B64" | base64 --decode > "$APP/Contents/embedded.provisionprofile" + - name: Verify profiles authorize the signing certificate + run: | + APP="build/Fleet Desktop.app" + APPEX="$APP/Contents/$APPEX_REL_PATH" + + # AMFI requires the signing certificate to be listed in the embedded + # profile's DeveloperCertificates, or it SIGKILLs the app at launch. + # codesign, Gatekeeper, and notarization all pass regardless — so + # without this check a profile cut against the wrong cert produces a + # signed, notarized pkg that silently won't launch. Even two certs from + # the same team will result in a broken, unusable app - they must be the + # same cert + check='import sys,plistlib,hashlib; pl=plistlib.loads(sys.stdin.buffer.read()); h=[hashlib.sha1(bytes(c)).hexdigest().upper() for c in pl.get("DeveloperCertificates",[])]; print(" authorizes:",h); sys.exit(0 if sys.argv[1].upper() in h else 1)' + for prof in "$APPEX/Contents/embedded.provisionprofile" "$APP/Contents/embedded.provisionprofile"; do + echo "Checking $prof" + if ! security cms -D -i "$prof" | python3 -c "$check" "$APPLICATION_SIGNING_IDENTITY_SHA1"; then + echo "::error::$prof does not authorize signing certificate $APPLICATION_SIGNING_IDENTITY_SHA1. AMFI will SIGKILL the app at launch (notarization does NOT catch this). Regenerate the Developer ID provisioning profile selecting that certificate." + exit 1 + fi + done + - name: Code sign app and extension run: | APP="build/Fleet Desktop.app" diff --git a/apps/fleet-desktop-macos/README.md b/apps/fleet-desktop-macos/README.md index 6d674b13018..97121299d6f 100644 --- a/apps/fleet-desktop-macos/README.md +++ b/apps/fleet-desktop-macos/README.md @@ -238,14 +238,14 @@ Under Fleet's Apple Developer team (`8VBZ3948LU`, the team that owns the pinned - `com.fleetdm.fleet-desktop` (host app) - `com.fleetdm.fleet-desktop.pssoextension` (extension) 2. Enable the **Associated Domains** and **MDM Managed Associated Domains** capabilities on both App IDs. -3. Create a **Developer ID** provisioning profile (distribution, platform macOS) for each App ID. +3. Create a **Developer ID** provisioning profile (distribution, platform macOS) for each App ID. **Select the same Developer ID Application certificate that CI signs with** — SHA-1 `604D877399AAEB7630A78B84F288E2D28A2EDE42` (the identity pinned in the workflow). Fleet has more than one "Developer ID Application: Fleet Device Management Inc" certificate; a profile generated against the wrong one will sign and **notarize successfully but get SIGKILLed by AMFI at launch**, because AMFI requires the signing cert to appear in the profile's `DeveloperCertificates`. The `Verify profiles authorize the signing certificate` workflow step guards against this. 4. base64-encode each downloaded `.provisionprofile` and store them as `APPLE_FLEET_DESKTOP_APP_PROFILE_B64` and `APPLE_PSSO_EXT_PROFILE_B64`: ```bash base64 -i FleetDesktop_DeveloperID.provisionprofile | pbcopy # → APPLE_FLEET_DESKTOP_APP_PROFILE_B64 base64 -i FleetPSSOExtension_DeveloperID.provisionprofile | pbcopy # → APPLE_PSSO_EXT_PROFILE_B64 ``` -Re-encode and update the secrets when a profile expires or the signing certificate is rotated. To confirm a profile grants the expected entitlements, dump it with `security cms -D -i .provisionprofile`. +Re-encode and update the secrets when a profile expires or the signing certificate is rotated. To inspect a profile — its entitlements and, crucially, the certs it authorizes — dump it with `security cms -D -i .provisionprofile`; the `DeveloperCertificates` array must contain the CI signing cert above. ## License From 8dc53734ccfff68aabeb5b43d2e2d726533cc471 Mon Sep 17 00:00:00 2001 From: Jordan Montgomery Date: Fri, 19 Jun 2026 10:25:07 -0400 Subject: [PATCH 26/28] Fix macos ext payload --- .../fleet-sso-extension-example.mobileconfig | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/fleet-desktop-macos/fleet-sso-extension-example.mobileconfig b/apps/fleet-desktop-macos/fleet-sso-extension-example.mobileconfig index 1f92a2b01af..d2106d089e2 100644 --- a/apps/fleet-desktop-macos/fleet-sso-extension-example.mobileconfig +++ b/apps/fleet-desktop-macos/fleet-sso-extension-example.mobileconfig @@ -49,10 +49,20 @@ com.apple.associated-domains.4D68D4CF-1250-4FF4-AFFB-1176DB539C49 PayloadUUID d77b6a04-6527-4333-1010-46422e8a5844 + PayloadVersion + 1 Configuration ApplicationIdentifier + 8VBZ3948LU.com.fleetdm.fleet-desktop + AssociatedDomains + + authsrv:fleet.example.com + + + + ApplicationIdentifier 8VBZ3948LU.com.fleetdm.fleet-desktop.pssoextension AssociatedDomains From 0e3a2b39d719eafa43bb34f2020707f6bee3cf44 Mon Sep 17 00:00:00 2001 From: Jordan Montgomery Date: Fri, 19 Jun 2026 10:27:29 -0400 Subject: [PATCH 27/28] fix README wording --- apps/fleet-desktop-macos/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/fleet-desktop-macos/README.md b/apps/fleet-desktop-macos/README.md index 97121299d6f..97a6e872dce 100644 --- a/apps/fleet-desktop-macos/README.md +++ b/apps/fleet-desktop-macos/README.md @@ -89,7 +89,7 @@ From that it derives, under `/api/mdm/apple/psso/`: - `POST /token` — password login / key request / key exchange - `GET /jwks` — Fleet's PSSO signing public key -The server fleet server also serves an Apple App Site Association file at `https:///.well-known/apple-app-site-association` containing an `authsrv` entry naming the extension's `.` — i.e. `8VBZ3948LU.com.fleetdm.fleet-desktop.pssoextension`. +The Fleet server also serves an Apple App Site Association file at `https:///.well-known/apple-app-site-association` containing an `authsrv` entry naming the extension's `.` — i.e. `8VBZ3948LU.com.fleetdm.fleet-desktop.pssoextension`. Because the same generic, CI-built extension must work against *any* Fleet server, the associated domain is **not** baked into the binary. Instead: From 2838d83c4f8c5a8ed0a4ee1d60cb8302233f7a59 Mon Sep 17 00:00:00 2001 From: Jordan Montgomery Date: Fri, 19 Jun 2026 10:44:54 -0400 Subject: [PATCH 28/28] Update comment --- ee/server/service/apple_psso.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ee/server/service/apple_psso.go b/ee/server/service/apple_psso.go index 25381b9c66a..2922cacc83e 100644 --- a/ee/server/service/apple_psso.go +++ b/ee/server/service/apple_psso.go @@ -41,7 +41,8 @@ const ( pssoSigningAlg = "ES256" // The host app bundle ID is included alongside the extension's just in case; - // PSSO validates against the extension, but listing both is harmless. + // PSSO validates against the extension, but listing both is harmless and matches + // what the IdPs analyzed do. appBundleID = "com.fleetdm.fleet-desktop" extensionBundleID = "com.fleetdm.fleet-desktop.pssoextension" @@ -741,8 +742,7 @@ func (svc *Service) PSSOJWKS(ctx context.Context) ([]byte, error) { } // pssoAASA mirrors the apple-app-site-association shape Apple's framework -// consumes for PSSO. PSSO validates the extension's authsrv: entitlement, so -// only the authsrv service is needed (webcredentials is for password autofill). +// consumes for PSSO. PSSO validates the extension's authsrv: entitlement. type pssoAASA struct { AuthSrv pssoAASAApps `json:"authsrv"` }