Files
c5575e9d9a Add PSSO end to end integration tests (#48589)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #47171

Added integration tests for the fleet-psso feature and added PSSO
functionality to our MDM test client - idea being it is so tightly
integrated into the MDM side of things on the Apple side AND we ideall
want osquery-perf to be able to exercise it(coming in the next PR)

# Checklist for submitter

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

- [ ] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.

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

## Testing

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

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

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

* **New Features**
* Added Apple Platform SSO (PSSO) support for device registration,
password login, key requests, and key exchange.
* Added a simulator/test device for exercising the full PSSO workflow
end-to-end.
* Made PSSO AASA development app IDs configurable and enhanced macOS
PSSO activity in performance testing (with new counters).
  * Improved local macOS Desktop packaging/signing configurability.

* **Bug Fixes**
* Strengthened PSSO token/crypto handling, including algorithm pinning,
key ID canonicalization, encrypted assertion `typ` validation, and
replay protection.

* **Tests**
* Added extensive crypto interoperability tests (including Apple
known-answer vectors) plus new end-to-end integration coverage.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Magnus Jensen <magnus@fleetdm.com>
2026-07-10 18:51:33 -04:00

942 lines
36 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"crypto/x509"
"crypto/x509/pkix"
"database/sql"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"math/big"
"net/url"
"strings"
"sync"
"time"
jwt "github.com/golang-jwt/jwt/v4"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/dev_mode"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mdm/apple/psso/pssocrypto"
"github.com/fleetdm/fleet/v4/server/mdm/apple/psso/regtoken"
jose "github.com/go-jose/go-jose/v3"
)
// pssoServiceState caches the PSSO signing key, CA certificate, and password
// encryption key after first load. All 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
encryptionKey *ecdsa.PrivateKey
encKID string
}
const (
// The host app bundle ID is included alongside the extension's just in case;
// 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"
fleetTeamID = "8VBZ3948LU"
)
// 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
}
assets, err := svc.ds.GetAllMDMConfigAssetsByName(ctx,
[]fleet.MDMAssetName{fleet.MDMAssetPSSOSigningKey},
nil,
)
if err != nil {
if isAssetNotFound(err) {
return nil, "", ctxerr.Wrap(ctx, err, "psso signing key not found; configure the feature first")
}
return nil, "", ctxerr.Wrap(ctx, err, "get psso signing key asset")
}
asset, ok := assets[fleet.MDMAssetPSSOSigningKey]
if !ok || len(asset.Value) == 0 {
return nil, "", ctxerr.New(ctx, "psso signing key asset is empty")
}
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
}
// getPSSOEncryptionKey loads Fleet's PSSO password-encryption key from
// mdm_config_assets, caching it after first use. Like the signing key, it is
// created when the feature is first configured (bootstrapPSSOAssets) and never
// minted here; a missing key means the feature isn't configured.
func (svc *Service) getPSSOEncryptionKey(ctx context.Context) (*ecdsa.PrivateKey, string, error) {
svc.pssoState.mu.Lock()
defer svc.pssoState.mu.Unlock()
if svc.pssoState.encryptionKey != nil {
return svc.pssoState.encryptionKey, svc.pssoState.encKID, nil
}
assets, err := svc.ds.GetAllMDMConfigAssetsByName(ctx,
[]fleet.MDMAssetName{fleet.MDMAssetPSSOEncryptionKey},
nil,
)
if err != nil {
if isAssetNotFound(err) {
return nil, "", ctxerr.Wrap(ctx, err, "psso encryption key not found; configure the feature first")
}
return nil, "", ctxerr.Wrap(ctx, err, "get psso encryption key asset")
}
asset, ok := assets[fleet.MDMAssetPSSOEncryptionKey]
if !ok || len(asset.Value) == 0 {
return nil, "", ctxerr.New(ctx, "psso encryption key asset is empty")
}
// The encryption key shares the signing key's PEM encoding and kid scheme
// (base64url-nopad SHA-256 of the SPKI), which is the kid the extension
// echoes back in the embedded assertion's JWE header.
key, kid, err := parsePSSOSigningKeyPEM(asset.Value)
if err != nil {
return nil, "", ctxerr.Wrap(ctx, err, "parse stored psso encryption key")
}
svc.pssoState.encryptionKey = key
svc.pssoState.encKID = kid
return key, kid, nil
}
// 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, nil, err
}
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 {
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 x509.ParseCertificate(block.Bytes)
}
func parsePSSOSigningKeyPEM(pemBytes []byte) (*ecdsa.PrivateKey, string, error) {
block, _ := pem.Decode(pemBytes)
if block == nil {
return nil, "", errors.New("psso signing key: pem decode returned nil block")
}
key, err := x509.ParseECPrivateKey(block.Bytes)
if err != nil {
return nil, "", err
}
kid, err := computeKID(&key.PublicKey)
if err != nil {
return nil, "", err
}
return key, kid, nil
}
// computeKID returns base64url-nopad SHA-256 of the SubjectPublicKeyInfo DER
// 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 {
return "", err
}
sum := sha256.Sum256(der)
return base64.RawURLEncoding.EncodeToString(sum[:]), nil
}
// isAssetNotFound reports whether err indicates that the requested
// mdm_config_assets row was absent.
func isAssetNotFound(err error) bool {
if err == nil {
return false
}
if fleet.IsNotFound(err) {
return true
}
return errors.Is(err, sql.ErrNoRows)
}
// 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")
}
aap := cfg.MDM.AppleAccountProvisioning
if !aap.Configured() || cfg.ServerSettings.ServerURL == "" {
return nil, 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
// 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 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 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, skipSecret)
if err != nil {
return "", err
}
if settings == nil {
return "", errPSSONotConfigured
}
if svc.pssoNonceStore == nil {
return "", ctxerr.New(ctx, "psso nonce store not configured")
}
var buf [32]byte
if _, err := rand.Read(buf[:]); err != nil {
return "", ctxerr.Wrap(ctx, err, "generate psso nonce")
}
nonce := base64.RawURLEncoding.EncodeToString(buf[:])
if err := svc.pssoNonceStore.Store(ctx, nonce, pssoNonceTTL); err != nil {
return "", ctxerr.Wrap(ctx, err, "store psso nonce")
}
return nonce, nil
}
// 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"}
}
if svc.pssoNonceStore == nil {
return ctxerr.New(ctx, "psso nonce store not configured")
}
ok, err := svc.pssoNonceStore.Consume(ctx, requestNonce)
if err != nil {
return ctxerr.Wrap(ctx, err, "consume psso request_nonce")
}
if !ok {
return &fleet.BadRequestError{Message: "psso token: invalid or expired request_nonce"}
}
return nil
}
// 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.
//
// Password-mode registration carries no OAuth code/state — the extension
// 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) 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)
settings, err := svc.pssoSettingsIfConfigured(ctx, skipSecret)
if err != nil {
return err
}
if settings == nil {
return errPSSONotConfigured
}
if req.DeviceSigningKey == "" || req.DeviceEncryptionKey == "" || req.SigningKeyID == "" || req.EncryptionKeyID == "" {
return &fleet.BadRequestError{Message: "missing required psso registration fields"}
}
if req.RegistrationToken == "" {
return &fleet.BadRequestError{Message: "psso registration: missing registration token"}
}
// The registration token is what authenticates the device: it is a
// Fleet-signed JWT delivered in the configuration profile and bound to a
// specific host. Verify it with Fleet's PSSO signing key and take the host
// UUID from the token's subject — the device-reported DeviceUUID is not
// trusted for identity (an unauthenticated caller who guesses an enrolled
// host's hardware UUID must not be able to register keys for it).
signingKey, _, err := svc.getPSSOSigningKey(ctx)
if err != nil {
return ctxerr.Wrap(ctx, err, "load psso signing key for registration token validation")
}
hostUUID, err := regtoken.Validate(req.RegistrationToken, &signingKey.PublicKey, time.Now())
if err != nil {
return &fleet.BadRequestError{Message: "psso registration: invalid registration token", InternalErr: err}
}
// Reject unparseable key material up front: a bad PEM stored here would
// otherwise only surface as opaque verification failures at every
// subsequent login.
signingPub, err := pssocrypto.ParseECPublicKeyPEM([]byte(req.DeviceSigningKey))
if err != nil {
return &fleet.BadRequestError{Message: "psso registration: signing key is not a valid P-256 public key"}
}
encryptionPub, err := pssocrypto.ParseECPublicKeyPEM([]byte(req.DeviceEncryptionKey))
if err != nil {
return &fleet.BadRequestError{Message: "psso registration: encryption key is not a valid P-256 public key"}
}
// The token endpoint resolves a device's host by looking its key up by kid,
// so a caller free to pick an arbitrary kid could target and overwrite
// another device's key row. Bind each kid to its key: recompute the expected
// kid from the parsed public key and reject a submitted kid that doesn't
// match. The result is already canonical (base64url, no padding), so it's
// what we store below.
signingKID, err := pssocrypto.KIDFromRawECPoint(signingPub)
if err != nil {
return ctxerr.Wrap(ctx, err, "derive psso signing key id")
}
if signingKID != pssocrypto.CanonicalizeKID(req.SigningKeyID) {
return &fleet.BadRequestError{Message: "psso registration: signing key id does not match signing key"}
}
encryptionKID, err := pssocrypto.KIDFromRawECPoint(encryptionPub)
if err != nil {
return ctxerr.Wrap(ctx, err, "derive psso encryption key id")
}
if encryptionKID != pssocrypto.CanonicalizeKID(req.EncryptionKeyID) {
return &fleet.BadRequestError{Message: "psso registration: encryption key id does not match encryption key"}
}
// PSSO requires a matching enrolled host; the registration is keyed by the
// host UUID carried in the (validated) registration token.
host, err := svc.ds.HostByUUID(ctx, hostUUID)
if err != nil {
if fleet.IsNotFound(err) {
return &fleet.BadRequestError{Message: fmt.Sprintf("psso registration: no enrolled host matches device UUID %q", hostUUID)}
}
return ctxerr.Wrap(ctx, err, "look up host by device uuid")
}
keys := []fleet.PSSOKey{
{
KID: signingKID,
KeyType: fleet.PSSOKeyTypeSigning,
PEM: req.DeviceSigningKey,
},
{
KID: encryptionKID,
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
}
// PSSOToken handles the per-sign-in token endpoint. It parses the inbound
// signed JWT, looks up the registered device by kid, verifies the signature,
// 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, loadSecret)
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"}
}
claims, signKey, err := svc.parsePSSOInboundJWT(ctx, jwtBytes)
if err != nil {
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
}
// Key requests/exchanges carry a request_type and are dispatched first.
switch claims.RequestType {
case pssocrypto.RequestKey:
return svc.handlePSSOKeyRequest(ctx, signKey.HostUUID, claims)
case pssocrypto.RequestExchange:
return svc.handlePSSOKeyExchange(ctx, signKey.HostUUID, claims)
}
// PSSO v2 Password login. grant_type=password carries a plaintext password;
// when the extension enables password encryption Apple uses the JWT-bearer
// grant and ships the password inside an encrypted embedded assertion. Both
// land here and differ only in where handlePSSOPasswordLogin reads the
// password from.
if claims.GrantType == pssocrypto.GrantTypePassword || claims.GrantType == pssocrypto.GrantTypeJWTBearer {
return svc.handlePSSOPasswordLogin(ctx, settings, signKey.HostUUID, claims)
}
return nil, &fleet.BadRequestError{Message: "psso token: unsupported grant_type/request_type"}
}
// pssoDefaultTokenTTL is the id_token / refresh_token lifetime used when the
// upstream IdP doesn't return an expires_in.
const pssoDefaultTokenTTL = time.Hour
// pssoAccountClaimPrefix namespaces the IdP claims Fleet forwards into the
// minted id_token so they can be referenced from the profile's
// TokenToUserMapping (e.g. mapping AccountName to a custom "accountUsername"
// claim for the macOS short name). Only claims whose names begin with this
// prefix (case-insensitive) cross the IdP -> Fleet-signed-token boundary; no
// registered OIDC/JWT claim uses it, so it can't collide with reserved claims.
const pssoAccountClaimPrefix = "account"
// pssoIDTokenIssuer returns the value the device validates the login-response
// id_token `iss` claim against. Apple's login configuration derives the 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 {
// 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, "/")
}
// 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,
}
}
// buildPSSOIDTokenClaims assembles the claim set for the id_token Fleet mints
// and signs in the login response. It forwards the IdP's standard identity
// claims plus any namespaced "account*" custom claims (so the profile's
// TokenToUserMapping can map the macOS short name / full name to them), then
// sets Fleet's own iss/sub/aud/nonce/iat/exp last so a misconfigured or
// malicious IdP can never override the claims the device validates.
func buildPSSOIDTokenClaims(idpClaims *fleet.PSSOClaims, issuer, audience, nonce string, now time.Time, expiresIn int) jwt.MapClaims {
out := jwt.MapClaims{
"email": idpClaims.Email,
"name": idpClaims.Name,
"preferred_username": idpClaims.PreferredUsername,
}
for k, v := range idpClaims.Extra {
if strings.HasPrefix(strings.ToLower(k), pssoAccountClaimPrefix) {
out[k] = v
}
}
out["iss"] = issuer
out["sub"] = idpClaims.Subject
out["aud"] = audience // request iss == the extension's clientID
out["nonce"] = nonce
out["iat"] = now.Unix()
out["exp"] = now.Add(time.Duration(expiresIn) * time.Second).Unix()
return out
}
// handlePSSOPasswordLogin services a PSSO v2 Password login request. The
// extension sends a signed JWT carrying the plaintext password (the JWS is the
// integrity/authenticity envelope; transport is TLS) and a jwe_crypto recipe.
// 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, settings *fleet.PSSOSettings, hostUUID string, claims *pssocrypto.TokenClaims) ([]byte, error) {
if claims.JWECrypto == nil || claims.JWECrypto.APV == "" {
return nil, &fleet.BadRequestError{Message: "psso password login: missing jwe_crypto recipe"}
}
if claims.JWECrypto.Alg != "ECDH-ES" || claims.JWECrypto.Enc != "A256GCM" {
return nil, &fleet.BadRequestError{Message: fmt.Sprintf("psso password login: unsupported jwe_crypto %q/%q", claims.JWECrypto.Alg, claims.JWECrypto.Enc)}
}
username := claims.Username
if username == "" {
username = claims.Subject
}
password, err := svc.resolvePSSOLoginPassword(ctx, claims)
if err != nil {
return nil, err
}
if username == "" || password == "" {
return nil, &fleet.BadRequestError{Message: "psso password login: missing username or password"}
}
idpClient := pssoIdPClientFromSettings(settings)
idpClaims, err := idpClient.ValidatePasswordAndGetClaims(ctx, username, password)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "psso password validation")
}
recipientPub, err := svc.resolvePSSOEncryptionKey(ctx, hostUUID, claims.JWECrypto.APV)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "resolve device encryption pubkey")
}
// Per Apple's JWE login-response doc, the response id_token is verified by
// the device against jwksEndpointURL (Fleet's JWKS). The upstream IdP's
// id_token is signed by the IdP's key and would not verify there, so Fleet
// 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 := pssoIDTokenIssuer(settings)
expiresIn := idpClaims.ExpiresIn
if expiresIn <= 0 {
expiresIn = int(pssoDefaultTokenTTL.Seconds())
}
now := time.Now()
idTokenClaims := buildPSSOIDTokenClaims(idpClaims, issuer, claims.Issuer, claims.Nonce, now, expiresIn)
idToken, err := svc.signServerJWT(ctx, idTokenClaims)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "sign psso id_token")
}
refreshToken := idpClaims.RefreshToken
if refreshToken == "" {
// The device treats this as opaque; mint a placeholder when the IdP
// didn't return one (e.g. offline_access not granted).
var buf [32]byte
if _, err := rand.Read(buf[:]); err != nil {
return nil, ctxerr.Wrap(ctx, err, "generate psso refresh token")
}
refreshToken = base64.RawURLEncoding.EncodeToString(buf[:])
}
// The JWE plaintext is the OAuth token response Apple expects, not a bare
// JWT: id_token (verified), refresh_token (opaque, used for SSO renewal),
// and the token lifetimes.
payload, err := json.Marshal(map[string]any{
"id_token": string(idToken),
"refresh_token": refreshToken,
"token_type": "Bearer",
"expires_in": expiresIn,
"refresh_token_expires_in": expiresIn,
})
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "marshal psso login response")
}
jwe, err := pssocrypto.BuildPartyInfoJWE(payload, recipientPub, claims.JWECrypto.APV, pssocrypto.TypLoginResponse)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "build psso login response jwe")
}
return jwe, nil
}
// resolvePSSOLoginPassword returns the plaintext password for a password login.
// With password encryption disabled it's the plaintext Password claim. With it
// enabled the Password claim is empty and the password lives in the encrypted
// embedded assertion, which Fleet decrypts with its PSSO encryption key. The
// username is always taken from the (signed) outer JWT, not the assertion.
func (svc *Service) resolvePSSOLoginPassword(ctx context.Context, claims *pssocrypto.TokenClaims) (string, error) {
if claims.Password != "" {
return claims.Password, nil
}
if claims.Assertion == "" {
return "", nil
}
encKey, _, err := svc.getPSSOEncryptionKey(ctx)
if err != nil {
return "", ctxerr.Wrap(ctx, err, "load psso encryption key")
}
plaintext, err := pssocrypto.DecryptPartyInfoJWE([]byte(claims.Assertion), encKey, pssocrypto.TypEncryptedLoginAssertion)
if err != nil {
return "", ctxerr.Wrap(ctx, err, "decrypt psso login assertion")
}
password, err := pssocrypto.ParseEmbeddedAssertionPassword(plaintext)
if err != nil {
return "", ctxerr.Wrap(ctx, err, "parse psso login assertion")
}
return password, nil
}
// handlePSSOKeyRequest services a PSSO 2.0 key request (request_type
// "key_request", key_purpose "user_unlock"). Per Apple's "Supporting key
// requests and key exchange requests" doc, Fleet provisions a fresh EC256 key
// pair, certifies its public half, and returns {certificate, iat, exp,
// 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, hostUUID string, claims *pssocrypto.TokenClaims) ([]byte, error) {
if claims.JWECrypto == nil || claims.JWECrypto.APV == "" {
return nil, &fleet.BadRequestError{Message: "psso key request: missing jwe_crypto recipe"}
}
encPub, err := svc.resolvePSSOEncryptionKey(ctx, hostUUID, claims.JWECrypto.APV)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "resolve device encryption pubkey")
}
provisioned, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "generate provisioned key")
}
certDER, err := svc.issuePSSOProvisionedCertificate(ctx, &provisioned.PublicKey)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "issue psso provisioned certificate")
}
signingKey, _, err := svc.getPSSOSigningKey(ctx)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "load psso signing key")
}
kcKey, err := deriveKeyContextKey(signingKey)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "derive key_context key")
}
keyContext, err := sealKeyContext(provisioned, hostUUID, pssoKeyPurposeUserUnlock, kcKey)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "seal key_context")
}
now := time.Now()
payload, err := json.Marshal(map[string]any{
"certificate": base64.RawURLEncoding.EncodeToString(certDER),
"iat": now.Unix(),
"exp": now.Add(5 * time.Minute).Unix(),
"key_context": keyContext,
})
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "marshal key_request payload")
}
jwe, err := pssocrypto.BuildPartyInfoJWE(payload, encPub, claims.JWECrypto.APV, pssocrypto.TypKeyResponse)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "build key_request JWE")
}
return jwe, nil
}
// issuePSSOProvisionedCertificate issues an X.509 certificate over a
// 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, caCert, err := svc.getPSSOCA(ctx)
if err != nil {
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"},
NotBefore: now.Add(-time.Hour),
NotAfter: now.AddDate(1, 0, 0),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyAgreement,
}
devDER, err := x509.CreateCertificate(rand.Reader, devTmpl, caCert, provisionedKey, caKey)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "create psso provisioned certificate")
}
return devDER, nil
}
// handlePSSOKeyExchange services a PSSO 2.0 key exchange (request_type
// "key_exchange"). The device sends its DH public key (other_publickey) plus
// the key_context Fleet issued during the key request. Fleet recovers the
// 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, hostUUID string, claims *pssocrypto.TokenClaims) ([]byte, error) {
if claims.JWECrypto == nil || claims.JWECrypto.APV == "" {
return nil, &fleet.BadRequestError{Message: "psso key exchange: missing jwe_crypto recipe"}
}
if claims.OtherPublicKey == "" || claims.KeyContext == "" {
return nil, &fleet.BadRequestError{Message: "psso key exchange: missing other_publickey or key_context"}
}
signingKey, _, err := svc.getPSSOSigningKey(ctx)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "load psso signing key")
}
kcKey, err := deriveKeyContextKey(signingKey)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "derive key_context key")
}
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 := pssocrypto.DecodeBase64Flexible(claims.OtherPublicKey)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "decode other_publickey")
}
shared, err := pssocrypto.ComputeECDHShared(provisioned, otherRaw)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "compute key exchange shared secret")
}
encPub, err := svc.resolvePSSOEncryptionKey(ctx, hostUUID, claims.JWECrypto.APV)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "resolve device encryption pubkey")
}
now := time.Now()
payload, err := json.Marshal(map[string]any{
"iat": now.Unix(),
"exp": now.Add(5 * time.Minute).Unix(),
"key": base64.StdEncoding.EncodeToString(shared),
"key_context": claims.KeyContext,
})
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "marshal key_exchange payload")
}
jwe, err := pssocrypto.BuildPartyInfoJWE(payload, encPub, claims.JWECrypto.APV, pssocrypto.TypKeyResponse)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "build key_exchange JWE")
}
return jwe, nil
}
// 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, skipSecret)
if err != nil {
return nil, err
}
if settings == nil {
return nil, &notFoundError{}
}
key, kid, err := svc.getPSSOSigningKey(ctx)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "load psso signing key")
}
encKey, encKID, err := svc.getPSSOEncryptionKey(ctx)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "load psso encryption key")
}
jwks := jose.JSONWebKeySet{Keys: []jose.JSONWebKey{
{
Key: &key.PublicKey,
KeyID: kid,
Algorithm: pssocrypto.SigningAlg,
Use: "sig",
},
// The extension sets this key as loginRequestEncryptionPublicKey and
// encrypts the password to it (ECDH-ES), so the password is never visible
// to a TLS-terminating proxy.
{
Key: &encKey.PublicKey,
KeyID: encKID,
Algorithm: pssocrypto.EncryptionAlg,
Use: "enc",
},
}}
return json.Marshal(jwks)
}
// pssoAASA mirrors the apple-app-site-association shape Apple's framework
// consumes for PSSO. PSSO validates the extension's authsrv: entitlement.
type pssoAASA struct {
AuthSrv pssoAASAApps `json:"authsrv"`
}
type pssoAASAApps struct {
Apps []string `json:"apps"`
}
// pssoDevAASAAppIDs returns the local-development app ID override read from
// FLEET_DEV_PSSO_AASA_APP_IDS (comma-separated <TeamID>.<BundleID>)
func pssoDevAASAAppIDs() []string {
var ids []string
for id := range strings.SplitSeq(dev_mode.Env("FLEET_DEV_PSSO_AASA_APP_IDS"), ",") {
if id = strings.TrimSpace(id); id != "" {
ids = append(ids, id)
}
}
return ids
}
// PSSOAASA returns the apple-app-site-association JSON Apple's framework
// uses to validate the extension's authsrv: entitlement against Fleet's
// 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
// 624h 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, skipSecret)
if err != nil {
return nil, err
}
if settings == nil {
return nil, &notFoundError{}
}
// A contributor testing locally signs the extension under their own
// (non-production) Apple Developer team, so the published app IDs must match
// that team. The FLEET_DEV_PSSO_AASA_APP_IDS override supplies them; it is
// honored only when the server runs with --dev (dev_mode.Env gates on it), so
// production only uses Fleet's built-in app IDs but dev servers allow the production
// binary or a local development override.
ids := pssoDevAASAAppIDs()
ids = append(ids, fleetTeamID+"."+appBundleID, fleetTeamID+"."+extensionBundleID)
doc := pssoAASA{
AuthSrv: pssoAASAApps{
Apps: ids,
},
}
return json.Marshal(doc)
}