Files
Spencer McGuire b3918c3ee6 Fix Windows sign-in: don't launch the browser through cmd.exe (v1.0.1)
cmd's `start` splits unquoted commands at "&", truncating the OAuth authorize
URL after the first query parameter — Windows users hit AADSTS900144 (missing
'scope'). Launch via rundll32 url.dll,FileProtocolHandler instead, which takes
the URL as a plain argument with no shell parsing.

Same fix as claude-msplanner b07a241, applied to the canonical skill copy and
synced into the extension bundle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 08:01:12 -06:00

652 lines
24 KiB
JavaScript

// Microsoft To Do core client — Microsoft Graph, DELEGATED (auth-code + PKCE).
//
// Zero dependencies. Uses Node's global fetch (Node 18+). This module is the
// single source of truth for talking to Microsoft To Do; the CLI (todo.mjs) is a
// thin wrapper around it, and a future MCP server can import this same class.
//
// Auth model — IMPORTANT: Microsoft To Do does NOT support app-only access.
// The Graph /me/todo endpoints only accept *delegated* permissions
// (Tasks.ReadWrite), so a user must sign in. We use the OAuth2 *authorization
// code* flow with PKCE and a loopback (http://localhost) redirect: `login` opens
// the system browser, the user signs in, and the refresh token is cached to disk
// so subsequent runs refresh silently — no client secret, works with MFA.
//
// Why not device-code? Many tenants block the device-code flow via Conditional
// Access (AADSTS53003), because it's headless. The browser-based auth-code flow
// runs in the device's real browser session, so it satisfies far more CA
// policies. (If a policy still requires a *compliant/managed device*, the Mac
// must be registered/enrolled first — no auth flow can bypass that.)
//
// The app registration must register a public-client redirect URI of
// http://localhost ("Mobile and desktop applications" platform) and hold the
// delegated Tasks.ReadWrite (+ offline_access, openid, profile, User.Read)
// permission. See references/setup.md.
import { readFileSync, writeFileSync, existsSync, rmSync, chmodSync } from "node:fs";
import { createServer } from "node:http";
import { randomBytes, createHash } from "node:crypto";
import { spawn } from "node:child_process";
const LOGIN_HOST = "https://login.microsoftonline.com";
const GRAPH_ROOT = "https://graph.microsoft.com/v1.0";
// offline_access -> refresh token; openid/profile -> id_token (who signed in);
// User.Read -> identify the user in `test`; Tasks.ReadWrite -> full To Do CRUD.
const SCOPES = "offline_access openid profile User.Read Tasks.ReadWrite";
/** Error carrying the Graph status code + structured error body, when present. */
export class GraphError extends Error {
constructor(message, { status, code, requestId, body } = {}) {
super(message);
this.name = "GraphError";
this.status = status;
this.code = code;
this.requestId = requestId;
this.body = body;
}
}
/** Thrown when there is no cached login (or the refresh token is dead). */
export class AuthRequiredError extends Error {
constructor(message = 'Not signed in. Run "node todo.mjs login" first.') {
super(message);
this.name = "AuthRequiredError";
}
}
export class MSTodoClient {
/**
* @param {object} cfg
* @param {string} cfg.clientId Application (client) ID of a PUBLIC client app.
* @param {string} [cfg.tenantId="common"] Tenant id / "common" / "organizations" / "consumers".
* @param {string} cfg.tokenCachePath Absolute path to the JSON token cache file.
* @param {number} [cfg.redirectPort=0] Loopback port for the auth-code redirect.
* 0 = let the OS pick (works when http://localhost is registered, since Entra
* ignores the port for loopback). Pin it only if your tenant requires an exact
* redirect URI (then register http://localhost:<port>).
* @param {number} [cfg.maxRetries=3] Retries for 429/503 (honors Retry-After).
*/
constructor({ clientId, tenantId = "common", tokenCachePath, redirectPort = 0, maxRetries = 3 } = {}) {
if (!clientId) {
throw new Error("Missing clientId (set TODO_CLIENT_ID). See references/setup.md.");
}
if (!tokenCachePath) {
throw new Error("Missing tokenCachePath.");
}
this.clientId = clientId;
this.tenantId = tenantId || "common";
this.tokenCachePath = tokenCachePath;
this.redirectPort = redirectPort;
this.maxRetries = maxRetries;
this._cache = null; // lazy-loaded { accessToken, expiresAt, refreshToken, ... }
this._listCache = new Map(); // nameOrId(lower) -> list object
}
// ---- Token cache (on disk) ---------------------------------------------
_loadCache() {
if (this._cache) return this._cache;
if (!existsSync(this.tokenCachePath)) return null;
try {
this._cache = JSON.parse(readFileSync(this.tokenCachePath, "utf8"));
} catch {
this._cache = null;
}
return this._cache;
}
_saveCache(tokens) {
const cache = {
clientId: this.clientId,
tenantId: this.tenantId,
accessToken: tokens.access_token,
expiresAt: Date.now() + (tokens.expires_in ?? 3600) * 1000,
refreshToken: tokens.refresh_token ?? this._cache?.refreshToken,
scope: tokens.scope ?? SCOPES,
account: tokens.account ?? this._cache?.account,
};
writeFileSync(this.tokenCachePath, JSON.stringify(cache, null, 2));
try {
chmodSync(this.tokenCachePath, 0o600); // best-effort; refresh token is sensitive
} catch {
/* ignore (e.g. on filesystems without POSIX modes) */
}
this._cache = cache;
return cache;
}
/** Remove the cached login. Returns true if a cache file existed. */
logout() {
this._cache = null;
if (existsSync(this.tokenCachePath)) {
rmSync(this.tokenCachePath);
return true;
}
return false;
}
isLoggedIn() {
const c = this._loadCache();
return Boolean(c?.refreshToken);
}
// ---- Auth ---------------------------------------------------------------
/**
* Interactive sign-in via the authorization-code + PKCE flow. Spins up a
* temporary loopback HTTP server, opens the system browser to the Entra
* authorize page, captures the redirect, exchanges the code for tokens, and
* persists them to the cache file. Returns the cached account.
*
* @param {(info:{authorizeUrl:string,redirectUri:string})=>void} [onPrompt]
* Called with the URL being opened (so the CLI can print it as a fallback).
* @param {object} [opts]
* @param {number} [opts.timeoutMs=300000] How long to wait for the redirect.
*/
async login(onPrompt, { timeoutMs = 300_000 } = {}) {
const verifier = base64url(randomBytes(32));
const challenge = base64url(createHash("sha256").update(verifier).digest());
const state = base64url(randomBytes(16));
// Start the loopback server first so we know the actual port for redirect_uri.
const { server, port, waitForCode } = await startRedirectServer(
state,
timeoutMs,
this.redirectPort,
);
// 127.0.0.1 (not "localhost") so the bind address and the browser's target
// always agree, even on IPv6-first hosts. Register http://localhost AND
// http://127.0.0.1 in the app so either works. Entra ignores the port for
// loopback redirects, so the OS-assigned port needs no registration.
const redirectUri = `http://127.0.0.1:${port}`;
const authorizeUrl =
`${LOGIN_HOST}/${encodeURIComponent(this.tenantId)}/oauth2/v2.0/authorize?` +
new URLSearchParams({
client_id: this.clientId,
response_type: "code",
redirect_uri: redirectUri,
response_mode: "query",
scope: SCOPES,
state,
code_challenge: challenge,
code_challenge_method: "S256",
prompt: "select_account",
});
if (typeof onPrompt === "function") onPrompt({ authorizeUrl, redirectUri });
openBrowser(authorizeUrl);
let code;
try {
code = await waitForCode;
} finally {
server.close();
}
const tokRes = await fetch(
`${LOGIN_HOST}/${encodeURIComponent(this.tenantId)}/oauth2/v2.0/token`,
{
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
client_id: this.clientId,
code,
redirect_uri: redirectUri,
code_verifier: verifier,
scope: SCOPES,
}),
},
);
const tok = await tokRes.json().catch(() => ({}));
if (!tokRes.ok) {
throw new GraphError(
`Token exchange failed (${tokRes.status}): ${tok.error_description || tok.error || tokRes.statusText}`,
{ status: tokRes.status, code: tok.error, body: tok },
);
}
this._saveCache({ ...tok, account: accountFromIdToken(tok.id_token) });
return this._cache.account ?? { ok: true };
}
/** Get a valid access token, refreshing with the cached refresh token if needed. */
async getToken() {
const cache = this._loadCache();
if (!cache?.refreshToken) throw new AuthRequiredError();
if (cache.accessToken && cache.expiresAt - 60_000 > Date.now()) {
return cache.accessToken;
}
const res = await fetch(
`${LOGIN_HOST}/${encodeURIComponent(this.tenantId)}/oauth2/v2.0/token`,
{
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
client_id: this.clientId,
refresh_token: cache.refreshToken,
scope: SCOPES,
}),
},
);
const json = await res.json().catch(() => ({}));
if (!res.ok) {
// invalid_grant => the refresh token is revoked/expired; force re-login.
if (json.error === "invalid_grant") {
throw new AuthRequiredError(
'Saved sign-in expired or was revoked. Run "node todo.mjs login" again.',
);
}
throw new GraphError(
`Token refresh failed (${res.status}): ${json.error_description || json.error || res.statusText}`,
{ status: res.status, code: json.error, body: json },
);
}
this._saveCache(json);
return this._cache.accessToken;
}
// ---- Low-level request --------------------------------------------------
/**
* Make a Graph request. `path` is relative to the v1.0 root (e.g. "/me/todo/lists").
* @param {string} method
* @param {string} path
* @param {object} [opts]
* @param {object} [opts.query] Query params; keys starting with "$" are OData.
* @param {object} [opts.body] JSON body (will be stringified).
* @param {object} [opts.headers] Extra headers (e.g. Prefer).
*/
async graph(method, path, { query, body, headers } = {}) {
const url = new URL(path.startsWith("http") ? path : `${GRAPH_ROOT}${path}`);
if (query) {
for (const [k, v] of Object.entries(query)) {
if (v !== undefined && v !== null && v !== "") url.searchParams.set(k, v);
}
}
for (let attempt = 0; ; attempt++) {
const token = await this.getToken();
const res = await fetch(url, {
method,
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/json",
...(body ? { "Content-Type": "application/json" } : {}),
...headers,
},
body: body ? JSON.stringify(body) : undefined,
});
if ((res.status === 429 || res.status === 503) && attempt < this.maxRetries) {
const retryAfter = Number(res.headers.get("Retry-After")) || 2 ** attempt;
await sleep(retryAfter * 1000);
continue;
}
if (res.status === 204) return null; // No Content (e.g. DELETE)
const json = await res.json().catch(() => null);
if (!res.ok) {
const err = json?.error || {};
throw new GraphError(
`Graph ${method} ${url.pathname} failed (${res.status}): ${err.message || res.statusText}`,
{
status: res.status,
code: err.code,
requestId: res.headers.get("request-id"),
body: json,
},
);
}
return json;
}
}
/** Identify the signed-in user. */
async me() {
return this.graph("GET", "/me", {
query: { $select: "id,displayName,userPrincipalName,mail" },
});
}
// ---- Task lists ---------------------------------------------------------
/** List all To Do task lists. */
async listTaskLists() {
const data = await this.graph("GET", "/me/todo/lists");
return data.value;
}
/**
* Resolve a list identifier (opaque list id or display name) to its id.
* To Do list ids are long opaque strings, not GUIDs, so we always check
* display names too. Caches per identifier.
*/
async resolveListId(listIdOrName) {
if (!listIdOrName) throw new Error("A list id or name is required.");
const key = listIdOrName.toLowerCase();
if (this._listCache.has(key)) return this._listCache.get(key).id;
const lists = await this.listTaskLists();
// Exact id match first (ids are case-sensitive opaque strings).
let match = lists.find((l) => l.id === listIdOrName);
if (!match) {
match = lists.find((l) => l.displayName?.toLowerCase() === key);
}
if (!match) {
const names = lists.map((l) => l.displayName).join(", ");
throw new Error(
`No task list named "${listIdOrName}". Available: ${names || "(none)"}`,
);
}
this._listCache.set(key, match);
return match.id;
}
/** Create a task list. */
async createTaskList(displayName) {
if (!displayName) throw new Error("displayName is required.");
this._listCache.clear();
return this.graph("POST", "/me/todo/lists", { body: { displayName } });
}
/** Rename a task list. */
async updateTaskList(listIdOrName, displayName) {
const listId = await this.resolveListId(listIdOrName);
this._listCache.clear();
return this.graph("PATCH", `/me/todo/lists/${listId}`, { body: { displayName } });
}
/** Delete a task list (and all its tasks). */
async deleteTaskList(listIdOrName) {
const listId = await this.resolveListId(listIdOrName);
await this.graph("DELETE", `/me/todo/lists/${listId}`);
this._listCache.clear();
return true;
}
// ---- Tasks: read --------------------------------------------------------
/**
* List tasks in a list.
* @param {object} [opts]
* @param {string} [opts.filter] OData $filter, e.g. "status ne 'completed'".
* @param {string} [opts.select] Comma list of properties, e.g. "title,status,dueDateTime".
* @param {string} [opts.orderby] e.g. "dueDateTime/dateTime asc".
* @param {number} [opts.top] Page size / max when not fetching all.
* @param {boolean}[opts.all] Follow @odata.nextLink to fetch every page.
*/
async listTasks(listIdOrName, opts = {}) {
const listId = await this.resolveListId(listIdOrName);
const { filter, select, orderby, top, all = false } = opts;
const query = { $filter: filter, $select: select, $orderby: orderby, $top: top };
let data = await this.graph("GET", `/me/todo/lists/${listId}/tasks`, { query });
const tasks = data.value.map(simplifyTask);
if (!all) return tasks;
while (data["@odata.nextLink"]) {
data = await this.graph("GET", data["@odata.nextLink"]);
tasks.push(...data.value.map(simplifyTask));
}
return tasks;
}
/** Get a single task by id. */
async getTask(listIdOrName, taskId) {
const listId = await this.resolveListId(listIdOrName);
const data = await this.graph("GET", `/me/todo/lists/${listId}/tasks/${taskId}`);
return simplifyTask(data);
}
// ---- Tasks: write -------------------------------------------------------
/** Create a task. `fields` is a map of todoTask property -> value (title required). */
async createTask(listIdOrName, fields) {
const listId = await this.resolveListId(listIdOrName);
if (!fields || !fields.title) throw new Error("A task `title` is required.");
const data = await this.graph("POST", `/me/todo/lists/${listId}/tasks`, { body: fields });
return simplifyTask(data);
}
/** Update a task (partial; only provided properties change). */
async updateTask(listIdOrName, taskId, fields) {
const listId = await this.resolveListId(listIdOrName);
const data = await this.graph("PATCH", `/me/todo/lists/${listId}/tasks/${taskId}`, {
body: fields,
});
return simplifyTask(data);
}
/** Mark a task completed (convenience over updateTask). */
async completeTask(listIdOrName, taskId) {
return this.updateTask(listIdOrName, taskId, { status: "completed" });
}
/** Delete a task by id. */
async deleteTask(listIdOrName, taskId) {
const listId = await this.resolveListId(listIdOrName);
await this.graph("DELETE", `/me/todo/lists/${listId}/tasks/${taskId}`);
return true;
}
// ---- Checklist items (subtasks) ----------------------------------------
async listChecklistItems(listIdOrName, taskId) {
const listId = await this.resolveListId(listIdOrName);
const data = await this.graph(
"GET",
`/me/todo/lists/${listId}/tasks/${taskId}/checklistItems`,
);
return data.value;
}
async createChecklistItem(listIdOrName, taskId, displayName) {
if (!displayName) throw new Error("displayName is required.");
const listId = await this.resolveListId(listIdOrName);
return this.graph("POST", `/me/todo/lists/${listId}/tasks/${taskId}/checklistItems`, {
body: { displayName },
});
}
/** Update a checklist item: rename and/or (un)check it. */
async updateChecklistItem(listIdOrName, taskId, itemId, { displayName, isChecked } = {}) {
const listId = await this.resolveListId(listIdOrName);
const body = {};
if (displayName !== undefined) body.displayName = displayName;
if (isChecked !== undefined) body.isChecked = isChecked;
return this.graph(
"PATCH",
`/me/todo/lists/${listId}/tasks/${taskId}/checklistItems/${itemId}`,
{ body },
);
}
async deleteChecklistItem(listIdOrName, taskId, itemId) {
const listId = await this.resolveListId(listIdOrName);
await this.graph(
"DELETE",
`/me/todo/lists/${listId}/tasks/${taskId}/checklistItems/${itemId}`,
);
return true;
}
// ---- Convenience --------------------------------------------------------
/** Verify the cached login works. Returns the signed-in user + list count. */
async test() {
const me = await this.me();
const lists = await this.listTaskLists();
return {
ok: true,
signedInAs: me.userPrincipalName || me.mail || me.displayName,
displayName: me.displayName,
listCount: lists.length,
lists: lists.map((l) => l.displayName),
};
}
}
// ---- helpers --------------------------------------------------------------
/** Flatten a todoTask into a compact, useful shape. */
function simplifyTask(t) {
if (!t) return t;
return {
id: t.id,
title: t.title,
status: t.status,
importance: t.importance,
isReminderOn: t.isReminderOn,
dueDateTime: t.dueDateTime,
startDateTime: t.startDateTime,
reminderDateTime: t.reminderDateTime,
completedDateTime: t.completedDateTime,
categories: t.categories,
body: t.body?.content ? t.body : undefined,
recurrence: t.recurrence,
hasAttachments: t.hasAttachments,
checklistItems: t.checklistItems,
createdDateTime: t.createdDateTime,
lastModifiedDateTime: t.lastModifiedDateTime,
};
}
/** Best-effort decode of the id_token payload to record which account signed in. */
function accountFromIdToken(idToken) {
if (!idToken || typeof idToken !== "string") return undefined;
const parts = idToken.split(".");
if (parts.length < 2) return undefined;
try {
const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
return {
username: payload.preferred_username || payload.upn || payload.email,
name: payload.name,
tid: payload.tid,
};
} catch {
return undefined;
}
}
/**
* Build a Graph dateTimeTimeZone object from a friendly date string.
* Accepts "2026-07-01", "2026-07-01T15:30", or a full ISO string. A bare date
* defaults to midnight. Returns { dateTime, timeZone }.
*/
export function toDateTimeTimeZone(input, timeZone = "UTC") {
if (!input) return undefined;
let dateTime = String(input).trim();
if (/^\d{4}-\d{2}-\d{2}$/.test(dateTime)) dateTime += "T00:00:00";
// Strip a trailing Z — Graph carries the zone in the timeZone field instead.
dateTime = dateTime.replace(/Z$/, "");
return { dateTime, timeZone };
}
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
/** URL-safe base64 with no padding (for PKCE verifier/challenge/state). */
function base64url(buf) {
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
/**
* Start a loopback HTTP server that captures the OAuth redirect. Resolves with
* `{ server, port, waitForCode }`, where `waitForCode` is a promise for the
* authorization code (rejects on error param, state mismatch, or timeout).
*/
function startRedirectServer(expectedState, timeoutMs, listenPort = 0) {
return new Promise((resolveStart, rejectStart) => {
let settle;
const waitForCode = new Promise((resolve, reject) => {
settle = { resolve, reject };
});
const server = createServer((req, res) => {
const url = new URL(req.url, "http://localhost");
if (url.pathname === "/favicon.ico") {
res.writeHead(204).end();
return;
}
const error = url.searchParams.get("error");
const errorDesc = url.searchParams.get("error_description");
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
const reply = (title, msg) => {
res.writeHead(200, { "Content-Type": "text/html" });
res.end(
`<!doctype html><meta charset="utf-8"><title>${title}</title>` +
`<body style="font:16px system-ui;margin:3rem"><h2>${title}</h2><p>${msg}</p>` +
`<p>You can close this tab and return to the terminal.</p></body>`,
);
};
if (error) {
reply("Sign-in failed", escapeHtml(errorDesc || error));
settle.reject(
new GraphError(`Sign-in was blocked or cancelled: ${errorDesc || error}`, {
status: 400,
code: error,
}),
);
} else if (!code) {
reply("Waiting…", "No authorization code in the request.");
} else if (state !== expectedState) {
reply("Sign-in failed", "State mismatch — possible CSRF. Try again.");
settle.reject(new Error("OAuth state mismatch; aborting sign-in."));
} else {
reply("Signed in ✓", "Microsoft To Do is now connected.");
settle.resolve(code);
}
});
const timer = setTimeout(() => {
settle.reject(new Error("Timed out waiting for browser sign-in."));
server.close();
}, timeoutMs);
// Clear the timer when the code promise settles, without creating an
// unhandled-rejection branch (login() is the one that handles the rejection).
waitForCode.finally(() => clearTimeout(timer)).catch(() => {});
server.on("error", (e) => rejectStart(e));
server.listen(listenPort, "127.0.0.1", () => {
resolveStart({ server, port: server.address().port, waitForCode });
});
});
}
/** Best-effort open of a URL in the system browser (macOS / Windows / Linux). */
function openBrowser(url) {
try {
let child;
if (process.platform === "win32") {
// Never route the URL through cmd.exe (`start`): cmd splits unquoted
// commands at "&", which truncates the authorize URL after the first
// query parameter (the user then sees AADSTS900144: missing 'scope').
// rundll32 opens the default browser with the URL as a plain argument.
child = spawn("rundll32", ["url.dll,FileProtocolHandler", url], {
stdio: "ignore",
detached: true,
});
} else {
child = spawn(process.platform === "darwin" ? "open" : "xdg-open", [url], {
stdio: "ignore",
detached: true,
});
}
child.on("error", () => {}); // ignore; the CLI also prints the URL as a fallback
child.unref();
} catch {
/* ignore — user can open the printed URL manually */
}
}
function escapeHtml(s) {
return String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
}
export default MSTodoClient;