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
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 \