// 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:). * @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( `${title}` + `

${title}

${msg}

` + `

You can close this tab and return to the terminal.

`, ); }; 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) { const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open"; try { const child = spawn(cmd, process.platform === "win32" ? ["", url] : [url], { stdio: "ignore", detached: true, shell: process.platform === "win32", }); 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) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]); } export default MSTodoClient;