Code

/**
 * mcp/skills/detectIdentityProvider.ts — detect_identity_provider
 *
 * Inspects installed agents and domain configuration to infer which
 * cloud identity provider the endpoint uses.  Returns a single canonical
 * idp ("okta" | "entra" | "google" | "unknown") plus any secondary
 * candidates and the evidence that led to the decision.
 *
 * Platform strategy
 * -----------------
 * darwin  Check for /Applications/Okta Verify.app,
 *         /Library/Application Support/JamfConnect/ (backend READ, not
 *         assumed — see detectJamfConnectDarwin),
 *         /Library/Intune/, and Google Credential Provider artefacts.
 * win32   Parse `dsregcmd /status` (for AzureAdJoined, WorkplaceJoined),
 *         registry HKLM\Software\Okta\Okta Verify, and
 *         HKLM\Software\Google\Credential Provider.
 *
 * Zero detected → return { primary: "unknown" } — never throw.
 * Multiple detected → first entry wins on `primary`; rest go in `secondary`.
 *
 * CALLERS: treat `primary` + `secondary` as an unordered set. Which IDP
 * lands in `primary` is decided purely by the hardcoded probe order in this
 * file, NOT by any notion of one IDP being more authoritative. On darwin the
 * Okta probes run first, so Entra can never be `primary` on a device that
 * also has an Okta or Jamf artefact; on win32 dsregcmd runs first, so the
 * reverse holds. A skill that branches on `primary` alone will misfire on
 * hybrid devices — test membership across both fields, as the entra-* skills
 * do.
 *
 * Smoke test
 *   npx tsx -r dotenv/config mcp/skills/detectIdentityProvider.ts
 */
 
import * as fs from "fs";
import * as os from "os";
import { z }   from "zod";
 
import { isDarwin, isWin32, execAsync, runPS } from "./_shared/platform";
import type { Idp } from "./_shared/idp";
import { normalizeIdp, idpFromDiscoveryUrl } from "./_shared/idp";
 
// -- Meta ---------------------------------------------------------------------
 
export const meta = {
  name: "detect_identity_provider",
  description:
    "Detects which cloud identity provider (Okta, Microsoft Entra, or Google " +
    "Workspace) the endpoint is joined to. Inspects installed agents and " +
    "domain configuration, not user credentials. Returns the primary IDP plus " +
    "any secondary detections and the evidence. Use at the start of cloud " +
    "password-reset or SSO-repair workflows.",
  riskLevel:       "low",
  destructive:     false,
  requiresConsent: false,
  supportsDryRun:  false,
  affectedScope:   ["user"],
  auditRequired:   false,
  outputKeys: ["platform","primary","secondary","evidence"],
  schema: {}, // no params
} as const;
 
// -- Types --------------------------------------------------------------------
 
interface Detection {
  idp:      Exclude<Idp, "unknown">;
  /** Short reason string — e.g. "Okta Verify installed". */
  evidence: string;
}
 
export interface IdpDetectionResult {
  platform:  "darwin" | "win32" | "other";
  primary:   Idp;
  secondary: Array<Exclude<Idp, "unknown">>;
  evidence:  string[];
}
 
// -- darwin implementation ----------------------------------------------------
 
async function detectDarwin(): Promise<Detection[]> {
  const detections: Detection[] = [];
 
  // Okta Verify — first-party Okta endpoint companion.
  if (safePathExists("/Applications/Okta Verify.app")) {
    detections.push({ idp: "okta", evidence: "Okta Verify.app installed" });
  }
 
  // Jamf Connect — IDP-agnostic; the backend is read, not assumed.
  detections.push(...await detectJamfConnectDarwin());
 
  // Intune — Microsoft-managed endpoints signal Entra.
  if (safePathExists("/Library/Intune")) {
    detections.push({ idp: "entra", evidence: "Microsoft Intune agent installed" });
  }
 
  // Company Portal for Entra.
  if (safePathExists("/Applications/Company Portal.app")) {
    detections.push({ idp: "entra", evidence: "Microsoft Company Portal installed" });
  }
 
  // Google Credential Provider / Workspace agent artefacts.
  // Google does not currently ship a Mac password-reset agent, but the
  // Workspace Endpoint Verification helper is a reasonable signal.
  if (safePathExists("/Applications/Endpoint Verification.app")) {
    detections.push({ idp: "google", evidence: "Google Endpoint Verification installed" });
  }
 
  // macOS Google Drive indicates Workspace but is a weak signal only —
  // do NOT return "google" solely on the basis of Google Drive.
 
  // Raw, NOT deduped — run() dedupes for primary/secondary and keeps this
  // list for evidence.
  return detections;
}
 
// -- Jamf Connect (darwin) ----------------------------------------------------
 
/** Jamf Connect artefacts that indicate the product is installed / configured. */
const JAMF_SUPPORT_DIR   = "/Library/Application Support/JamfConnect";
const JAMF_MANAGED_PREFS = [
  "/Library/Managed Preferences/com.jamf.connect.login",
  "/Library/Managed Preferences/com.jamf.connect",
];
/** Config-profile keys naming the backend directly. */
const JAMF_PROVIDER_KEYS  = ["OIDCProvider", "OIDCProviderName", "AuthServer"];
/** Config-profile keys naming the backend indirectly, via endpoint host. */
const JAMF_DISCOVERY_KEYS = ["OIDCDiscoveryURL", "OIDCROPGDiscoveryURL", "AuthServerURL"];
 
/**
 * Detect Jamf Connect and, crucially, WHICH IDP it is bound to.
 *
 * Jamf Connect replaces the macOS login window and authenticates against a
 * configurable backend — Okta, Azure/Entra, Google, OneLogin, Ping or a
 * custom OIDC endpoint. Its install path is identical in every case, so
 * presence alone carries no IDP information. This previously returned a
 * hardcoded "okta", which silently misclassified every Entra-bound fleet.
 *
 * Two independent sources, answering subtly different questions:
 *
 *   policy   `/Library/Managed Preferences/com.jamf.connect.login`
 *            The MDM-pushed config profile — what the admin has configured
 *            RIGHT NOW. Present from enrolment, before any user has logged
 *            in. Authoritative for a freshly imaged Mac, and for a tenant
 *            migration where the profile has been updated.
 *
 *   binding  `dscl . -read /Users/$USER OIDCProvider`
 *            What this user actually authenticated against at their last
 *            Jamf Connect login. Present only after a successful login, and
 *            can lag the profile after a migration.
 *
 * Both are reported when they disagree — no consumer of this tool
 * distinguishes `primary` from `secondary` (every caller tests membership
 * across both), so surfacing both is strictly safer than picking one and
 * discarding the evidence for the other.
 *
 * When Jamf Connect is present but no source is readable, falls back to the
 * historical "okta" assumption rather than dropping the detection — but says
 * so in the evidence string, so an operator reading an audit log can tell a
 * real reading from a guess.
 */
async function detectJamfConnectDarwin(): Promise<Detection[]> {
  const installed =
    safePathExists(JAMF_SUPPORT_DIR) ||
    JAMF_MANAGED_PREFS.some((d) => safePathExists(`${d}.plist`));
  if (!installed) return [];
 
  const policy  = await readJamfPolicyIdp();
  const binding = await readJamfBindingIdp();
 
  const detections: Detection[] = [];
  if (policy) {
    detections.push({
      idp:      policy.idp,
      evidence: `Jamf Connect configured for ${policy.idp} (${policy.source})`,
    });
  }
  if (binding && binding.idp !== policy?.idp) {
    detections.push({
      idp:      binding.idp,
      evidence: policy
        // Disagreement is worth spelling out — it is the migration / re-image
        // signal, and an operator seeing only one value would misread it.
        ? `Jamf Connect last login used ${binding.idp} (dscl OIDCProvider) — ` +
          `differs from the configured ${policy.idp}`
        : `Jamf Connect bound to ${binding.idp} (dscl OIDCProvider)`,
    });
  }
 
  if (detections.length === 0) {
    return [{
      idp:      "okta",
      evidence: "Jamf Connect present but backend not readable — assuming Okta",
    }];
  }
  return detections;
}
 
/** Read the MDM-pushed config profile. Returns null when unreadable. */
async function readJamfPolicyIdp(): Promise<{ idp: Exclude<Idp, "unknown">; source: string } | null> {
  for (const domain of JAMF_MANAGED_PREFS) {
    for (const key of JAMF_PROVIDER_KEYS) {
      const raw = await readDefault(domain, key);
      const idp = raw ? normalizeIdp(raw) : null;
      if (idp && idp !== "unknown") {
        return { idp, source: `${key} in managed preferences` };
      }
    }
    // OIDCProvider: Custom — the discovery endpoint is the only identifier.
    for (const key of JAMF_DISCOVERY_KEYS) {
      const raw = await readDefault(domain, key);
      const idp = raw ? idpFromDiscoveryUrl(raw) : null;
      if (idp && idp !== "unknown") {
        return { idp, source: `${key} host` };
      }
    }
  }
  return null;
}
 
/** Read the per-user binding written by Jamf Connect at login. */
async function readJamfBindingIdp(): Promise<{ idp: Exclude<Idp, "unknown"> } | null> {
  const user = os.userInfo().username;
  let stdout = "";
  try {
    ({ stdout } = await execAsync(
      `dscl . -read "/Users/${user}" OIDCProvider`,
      { maxBuffer: 256 * 1024, timeout: 5_000 },
    ));
  } catch {
    // Key absent (never logged in via Jamf Connect) or dscl unavailable.
    return null;
  }
  const m = stdout.match(/^OIDCProvider:\s*(\S+)\s*$/im);
  if (!m) return null;
  const idp = normalizeIdp(m[1]);
  return idp && idp !== "unknown" ? { idp } : null;
}
 
/**
 * `defaults read <domain-or-path> <key>` → trimmed stdout, or null on any
 * failure. `defaults` exits non-zero for a missing domain or key, which is
 * the common case here, so failures are swallowed silently.
 *
 * Domain and key are module constants — no user input is interpolated.
 */
async function readDefault(domain: string, key: string): Promise<string | null> {
  try {
    const { stdout } = await execAsync(`defaults read "${domain}" "${key}"`, {
      maxBuffer: 256 * 1024, timeout: 5_000,
    });
    const trimmed = stdout.trim();
    return trimmed.length > 0 ? trimmed : null;
  } catch {
    return null;
  }
}
 
// -- win32 implementation -----------------------------------------------------
 
async function detectWin32(): Promise<Detection[]> {
  const detections: Detection[] = [];
 
  // dsregcmd /status reports AzureAdJoined / WorkplaceJoined / DomainJoined.
  try {
    const { stdout } = await execAsync("dsregcmd /status", {
      maxBuffer: 2 * 1024 * 1024, timeout: 10_000,
    });
    if (/AzureAdJoined\s*:\s*YES/i.test(stdout)) {
      detections.push({ idp: "entra", evidence: "dsregcmd reports AzureAdJoined: YES" });
    } else if (/WorkplaceJoined\s*:\s*YES/i.test(stdout)) {
      detections.push({ idp: "entra", evidence: "dsregcmd reports WorkplaceJoined: YES" });
    }
  } catch {
    // dsregcmd may not be on PATH in locked-down environments; skip silently.
  }
 
  // Okta Verify registry key.
  if (await winRegistryKeyExists("HKLM\\Software\\Okta\\Okta Verify")) {
    detections.push({ idp: "okta", evidence: "Okta Verify installed (registry)" });
  }
  if (await winRegistryKeyExists("HKCU\\Software\\Okta\\Okta Verify")) {
    detections.push({ idp: "okta", evidence: "Okta Verify installed (registry, user hive)" });
  }
 
  // Google Credential Provider for Windows.
  if (await winRegistryKeyExists("HKLM\\Software\\Google\\Credential Provider")) {
    detections.push({ idp: "google", evidence: "Google Credential Provider installed" });
  }
 
  // Raw, NOT deduped — run() dedupes for primary/secondary and keeps this
  // list for evidence. Both HKLM and HKCU Okta keys can fire on the same
  // device; the second is dropped from the IDP set but its evidence is kept.
  return detections;
}
 
// -- Helpers ------------------------------------------------------------------
 
function safePathExists(p: string): boolean {
  try {
    return fs.existsSync(p);
  } catch {
    return false;
  }
}
 
/**
 * Preserve ordering but drop duplicate IDPs so each canonical value
 * appears at most once.  Used for primary/secondary only — run() builds
 * `evidence` from the PRE-dedupe list so the full trail survives.
 *
 * That distinction matters for Jamf Connect: when a device has both an
 * Okta artefact and a Jamf profile whose policy and binding disagree, the
 * duplicate dropped here is the entry explaining the disagreement. The IDP
 * set is unaffected, but an operator reading the audit log would otherwise
 * lose the only record of WHY the device reported two IDPs.
 */
function dedupeByIdp(detections: Detection[]): Detection[] {
  const seen = new Set<string>();
  const out: Detection[] = [];
  for (const d of detections) {
    if (seen.has(d.idp)) continue;
    seen.add(d.idp);
    out.push(d);
  }
  return out;
}
 
/**
 * Query Windows registry by firing `reg query` and checking for a
 * non-zero-length output.  Returns false on any non-zero exit or error.
 */
async function winRegistryKeyExists(key: string): Promise<boolean> {
  if (!isWin32()) return false;
  const safe = key.replace(/["'&|]/g, ""); // strip shell metacharacters
  try {
    const { stdout } = await execAsync(`reg query "${safe}"`, {
      maxBuffer: 1 * 1024 * 1024, timeout: 5_000,
    });
    return stdout.trim().length > 0;
  } catch {
    return false;
  }
}
 
// Exported purely for unit tests that want to fake platform detection.
export const __testing = {
  detectDarwin,
  detectWin32,
  detectJamfConnectDarwin,
};
 
// -- Exported run function ----------------------------------------------------
 
export async function run(): Promise<IdpDetectionResult> {
  let detections: Detection[] = [];
  let platform: "darwin" | "win32" | "other" = "other";
 
  try {
    if (isDarwin()) {
      platform = "darwin";
      detections = await detectDarwin();
    } else if (isWin32()) {
      platform = "win32";
      detections = await detectWin32();
    }
  } catch {
    // Any unexpected error falls through to the "unknown" result.
    detections = [];
  }
 
  if (detections.length === 0) {
    return { platform, primary: "unknown", secondary: [], evidence: [] };
  }
 
  // Reference runPS so the import is not flagged unused — it's listed
  // alongside execAsync in _shared/platform because win32-specific tools
  // in Phase 2 will need it.  The detection code here uses execAsync +
  // reg query directly.
  void runPS;
 
  // Dedupe decides the IDP set; `detections` stays raw so every signal that
  // fired is recorded. On a device with e.g. Okta Verify AND a Jamf profile
  // whose policy and binding disagree, the dropped duplicate is the entry
  // explaining the disagreement — the IDP set is unchanged but the audit
  // trail would otherwise lose the reason.
  const unique = dedupeByIdp(detections);
 
  const [first, ...rest] = unique;
  return {
    platform,
    primary:   first.idp,
    secondary: rest.map((d) => d.idp),
    evidence:  detections.map((d) => d.evidence),
  };
}
 
// -- CLI smoke test -----------------------------------------------------------
 
if (false) {
  run()
    .then((r) => console.log(JSON.stringify(r, null, 2)))
    .catch((err: Error) => { console.error(err.message); process.exit(1); });
}