detect_idp_username

detect_idp_username

Detects the user's IDP login username (email / UPN) from on-device IDP-agent state. Used to auto-populate username for cloud-IDP reset flows rather than asking the user via chat. Supports Windows × {Entra, Okta} (Phase 1) and macOS × {Entra, Okta, Google via Jamf Connect} (Phase 2). Phase 2 macOS probes read other apps' plists / Application Support JSON files which are gated by Full Disk Access on macOS 14+, hence tccCategories. Returns null with a reason for unsupported combinations — never throws.

Metadata

Name

detect_idp_username

Updated

last month

Source

GitHub source

Risk

Low

Requires consent

false

Affected scope

user

Code

/**
 * mcp/skills/detectIdpUsername.ts — detect_idp_username
 * 
 * Phase 1 — Windows × Entra + Windows × Okta.
 * Phase 2 (this commit) — macOS × Okta Verify, macOS × Entra (Company
 *                          Portal + Intune SSO + Office license), Jamf
 *                          Connect (works across Okta / Entra / Google
 *                          IDP backends). Re-adds tccCategories:
 *                          ["FullDiskAccess"] to this tool because
 *                          some macOS probes read other apps' plists
 *                          and JSON files under ~/Library/.
 * 
 * Purpose
 * -------
 * Reads the device's existing IDP-agent state to discover the user's
 * IDP login username (email / UPN).  Used by cloud-IDP password-reset
 * to auto-populate the username for `request_idemeum_idp_reset` rather
 * than asking the user via chat narration (which works poorly in
 * practice — the conversationIdRef clears on run end).
 *
 * Designed for cross-skill reuse: callers pass `idp` explicitly and
 * the tool does NOT depend on `detect_identity_provider`.  Other
 * skills (identity-auth-repair) can call this directly once they
 * know the IDP.
 *
 * Sources by platform + IDP
 * -------------------------
 * win32 + entra   `dsregcmd /status` → parse "User Name" / "Executing
 *                 Account Name" under the Diagnostic Data / SSO State
 *                 sections.  Reliable only when device is AAD-joined.
 * win32 + okta    `reg query HKCU\Software\Okta\Okta Verify /s` →
 *                 parse `UserName` REG_SZ values from each account
 *                 subkey.
 * win32 + google  Not supported.  Google Credential Provider for
 *                 Windows uses different storage.
 *
 * darwin + entra  Fallback chain (try each, collect ALL hits):
 *                 1. `defaults read com.microsoft.CompanyPortalMac`
 *                    UserPrincipalName / LastSignedInUser / UserEmail
 *                 2. `defaults read
 *                    com.microsoft.CompanyPortalMac.ssoextension`
 *                    SignedInUserUPN / lastSignedInUPN
 *                 3. `~/Library/Group Containers/UBF8T346G9.Office/
 *                    UserInfo.plist` via plutil → JSON
 *                 4. Jamf Connect probes filtered to IDP === "azure" / "entra"
 *
 * darwin + okta   Fallback chain:
 *                 1. `defaults read com.okta.OktaVerify` AccountList /
 *                    LastEnrolledUserEmail
 *                 2. `~/Library/Application Support/Okta Verify/
 *                    OktaVerifyData.json` via fs.readFile + JSON.parse
 *                 3. Jamf Connect probes filtered to IDP === "okta"
 *
 * darwin + google Fallback chain:
 *                 1. Jamf Connect probes filtered to IDP === "google"
 *                    (Google Workspace on macOS has no standalone
 *                    agent that stores the user email persistently;
 *                    Chrome / Google Workspace browser sign-in state
 *                    is not a reliable signal.)
 *
 * Jamf Connect (used across all darwin IDPs):
 *                 a. `dscl . -read /Users/$USER OIDCProvider
 *                    OIDCProviderUserName` — Jamf Connect writes the
 *                    IDP user into the local account schema once
 *                    bound. Most reliable signal, no FDA needed.
 *                 b. `defaults read com.jamf.connect.state` ADUserName /
 *                    NomadIdpUserName — Jamf Connect's user prefs.
 *                 c. `/Library/Preferences/com.jamf.connect.plist`
 *                    via plutil (system-scope, FDA-gated).
 *
 * Plist domains marked RESEARCHED below are documented in public Apple /
 * Microsoft / Okta / Jamf docs but not verified end-to-end against real
 * deployments at the time of writing. Probes use graceful empty-return
 * semantics — a wrong path degrades to "next source", never throws.
 *
 * Confidence ranking
 * ------------------
 * "high"   — direct identity-store probes (dsregcmd UPN on AAD-joined
 *            device; Okta Verify account in user-hive registry;
 *            dscl OIDCProvider on Jamf-Connect-bound macs;
 *            Company Portal UPN from defaults read).
 * "medium" — derived / secondary stores (Office license registration;
 *            Jamf Connect state plist; multi-account Okta Verify
 *            where caller must pick).
 * "low"    — speculative / partial matches (not used in Phase 1 or 2).
 *
 * Return contract
 * ---------------
 *   { primaryUsername: string | null, candidates: [...],
 *     platform, idp, reason? }
 *
 * When `primaryUsername` is null, `reason` explains why.  Callers
 * should NOT throw on unsupported combinations — null + reason is
 * the documented "I don't know" answer.
 *
 * Smoke test
 *   npx tsx -r dotenv/config mcp/skills/detectIdpUsername.ts
 */

import { exec }      from "child_process";
import { promisify } from "util";
import * as os       from "os";
import { z }         from "zod";
import type { Idp }  from "./_shared/idp";

const execAsync = promisify(exec);

// -- Meta ---------------------------------------------------------------------

export const meta = {
  name: "detect_idp_username",
  description:
    "Detects the user's IDP login username (email / UPN) from on-device " +
    "IDP-agent state. Used to auto-populate username for cloud-IDP reset " +
    "flows rather than asking the user via chat. Supports Windows × " +
    "{Entra, Okta} (Phase 1) and macOS × {Entra, Okta, Google via Jamf " +
    "Connect} (Phase 2). Phase 2 macOS probes read other apps' plists / " +
    "Application Support JSON files which are gated by Full Disk Access " +
    "on macOS 14+, hence tccCategories. Returns null with a reason for " +
    "unsupported combinations — never throws.",
  riskLevel:       "low",
  destructive:     false,
  requiresConsent: false,
  supportsDryRun:  false,
  affectedScope:   ["user"],
  auditRequired:   false,
  // macOS probes read other apps' Library files (Company Portal,
  // Okta Verify, Jamf Connect state, Office license registration).
  // Some sources (dscl, defaults read of own-user prefs) don't need FDA,
  // but the FDA-gated sources are the safety net for completeness — when
  // FDA is granted all sources run; when not, FDA-required sources return
  // empty and the non-FDA sources still contribute. G4's tccPreflightCheck
  // asks for FDA once upfront rather than silently degrading mid-run.
  tccCategories:   ["FullDiskAccess"],
  schema: {
    idp: z
      .enum(["okta", "entra", "google"])
      .describe(
        "IDP type to probe. Caller passes the value from " +
        "detect_identity_provider.primary (or knows it independently).",
      ),
    tenant: z
      .string()
      .optional()
      .describe(
        "Optional tenant filter — when provided and the probe finds " +
        "multiple accounts, candidates are filtered to those matching " +
        "this tenant. Useful for Okta multi-tenant setups.",
      ),
  },
} as const;

// -- Types --------------------------------------------------------------------

export interface UsernameCandidate {
  username:   string;
  source:     string;
  confidence: "high" | "medium" | "low";
  tenant?:    string;
}

export interface DetectIdpUsernameResult {
  primaryUsername: string | null;
  candidates:      UsernameCandidate[];
  platform:        "darwin" | "win32" | "other";
  idp:             Idp;
  /** Populated when primaryUsername is null — explains why. */
  reason?:         string;
}

// -- Platform helper ----------------------------------------------------------

function resolvePlatform(): "darwin" | "win32" | "other" {
  const p = os.platform();
  if (p === "darwin") return "darwin";
  if (p === "win32")  return "win32";
  return "other";
}

// -- win32 + entra: dsregcmd /status -----------------------------------------

/**
 * Parse `dsregcmd /status` output for the user's UPN.
 *
 * Output format varies across Windows versions but the UPN consistently
 * appears under one of these labels:
 *   "User Name"            (Diagnostic Data / SSO State sections)
 *   "Executing Account Name"  (older Windows 10 builds)
 *   "UserPrincipalName"    (some variants)
 *
 * We try each pattern in order and return the first match. Filter out
 * MACHINE\AccountName-style values (no @ sign) — those are local
 * accounts, not UPNs.
 */
function parseDsregcmdUpn(stdout: string): string | null {
  const patterns = [\
    /^\s*User Name\s*:\s*(\S+@\S+\.\S+)\s*$/im,
    /^\s*Executing Account Name\s*:\s*(\S+@\S+\.\S+)\s*$/im,
    /^\s*UserPrincipalName\s*:\s*(\S+@\S+\.\S+)\s*$/im,
  ];

for (const re of patterns) {
    const m = stdout.match(re);
    if (m && m[1]) {
      const upn = m[1].trim();
      // Sanity check — must contain @ and a TLD-ish suffix.
      if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(upn)) {
        return upn;
      }
    }
  }
  return null;
}

async function probeEntraWin32(): Promise<UsernameCandidate[]> {
  let stdout = "";
  try {
    ({ stdout } = await execAsync("dsregcmd /status", {
      maxBuffer: 2 * 1024 * 1024,
      timeout:   10_000,
    }));
  } catch {
    return [];
  }

// First check the device is actually AAD-joined — a UPN from dsregcmd on
  // a non-joined device is meaningless (could be stale state).
  const aadJoined = /AzureAdJoined\s*:\s*YES/i.test(stdout);
  if (!aadJoined) {
    return [];
  }

const upn = parseDsregcmdUpn(stdout);
  if (!upn) return [];

return [{\
    username:   upn,\
    source:     "dsregcmd /status (AAD-joined)",\
    confidence: "high",\
  }];
}

// -- win32 + okta: registry probe --------------------------------------------

/**
 * Parse `reg query HKCU\Software\Okta\Okta Verify /s` output.
 *
 * Recursive registry dump format is roughly:
 *   HKEY_CURRENT_USER\Software\Okta\Okta Verify\Accounts\<id>
 *       UserName    REG_SZ    alice@example.com
 *       OrgUrl      REG_SZ    https://acme.okta.com
 *       ...
 *   HKEY_CURRENT_USER\Software\Okta\Okta Verify\Accounts\<id2>
 *       UserName    REG_SZ    bob@example.com
 *       ...
 *
 * We extract UserName + OrgUrl pairs by tracking the current key context.
 * Returns 0-N candidates depending on how many accounts are configured.
 *
 * NOTE: Exact registry layout differs across Okta Verify versions. This
 * parser is based on the documented format from Okta Verify 5.x+.
 * Verify on a real install if results look wrong.
 */
function parseOktaVerifyRegistry(stdout: string): UsernameCandidate[] {
  const candidates: UsernameCandidate[] = [];
  let currentTenant: string | undefined;
  let pendingUserName: string | undefined;

const flush = () => {
    if (pendingUserName) {
      candidates.push({
        username:   pendingUserName,
        source:     "Okta Verify registry (HKCU)",
        confidence: "high",
        ...(currentTenant ? { tenant: currentTenant } : {}),
      });
    }
    pendingUserName = undefined;
    currentTenant   = undefined;
  };

const lines = stdout.split(/\r?\n/);
  for (const line of lines) {
    // New key — flush any pending account from the previous key.
    if (/^HKEY_(CURRENT_USER|LOCAL_MACHINE)\/i.test(line)) {
      flush();
      continue;
    }
    // Value lines look like:  "    UserName    REG_SZ    alice@example.com"
    const userMatch = line.match(/^\s+UserName\s+REG_SZ\s+(\S+@\S+\.\S+)\s*$/i);
    if (userMatch) {
      pendingUserName = userMatch[1].trim();
      continue;
    }
    const orgMatch = line.match(/^\s+OrgUrl\s+REG_SZ\s+https?:\/\/([^/\s]+)/i);
    if (orgMatch) {
      // "acme.okta.com" → "acme"
      const host = orgMatch[1];
      const slug = host.replace(/\.okta(?:preview|-emea)?\.com$/i, "");
      if (slug && slug !== host) {
        currentTenant = slug;
      }
      continue;
    }
  }
  flush(); // final account at EOF
  return candidates;
}

async function probeOktaWin32(): Promise<UsernameCandidate[]> {
  // reg query is shell-safe — fixed key, no user input interpolation.
  // /s = recursive (subkeys + values).
  let stdout = "";
  try {
    ({ stdout } = await execAsync(
      `reg query "HKCU\\Software\\Okta\\Okta Verify" /s`,
      { maxBuffer: 2 * 1024 * 1024, timeout: 10_000 },
    ));
  } catch {
    // Key may not exist (Okta Verify not installed / no accounts configured)
    // — return empty rather than throwing.
    return [];
  }

return parseOktaVerifyRegistry(stdout);
}

// -- darwin shared helpers ----------------------------------------------------

const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

/** Validate "email-shaped" — must contain @ and a TLD-ish suffix. */
function looksLikeEmail(value: unknown): value is string {
  return typeof value === "string" && EMAIL_RE.test(value.trim());
}

/**
 * Run `defaults read <domain> <key>` and return the trimmed stdout, or
 * null on any failure (missing domain, missing key, parse error, etc).
 *
 * `defaults` exits non-zero when domain or key is missing — we swallow
 * those to keep probes silent.
 */
async function runDefaultsRead(
  domain: string,
  key:    string,
): Promise<string | null> {
  // `defaults read` arg-list is shell-safe (no shell interpretation; exec
  // spawns directly). Domain + key come from this file's hardcoded
  // constants — no user input interpolation.
  let stdout = "";
  try {
    ({ stdout } = await execAsync(`defaults read \