slidemind 0.2.4 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,859 @@
1
+ // Delegated Microsoft Graph sign-in for the pane-free CLI (`slidemind login`).
2
+ //
3
+ // The engine routes answer discovery, search and blob-backed bytes off the CLI
4
+ // session alone — but SharePoint-backed BYTES are the user's own content, and
5
+ // only the user's own identity may fetch them. The CLI session token is minted
6
+ // by the add-in at claim redemption, not by Entra, so the backend cannot
7
+ // exchange it for a Graph token either. Until now the only identity that could
8
+ // was the task pane's browser, which is why those two failures
9
+ // (`asset_needs_host`, "Failed to connect to sharepoint provider") demanded an
10
+ // open PowerPoint window. This module mints that identity from the terminal
11
+ // instead: auth-code + PKCE through the system browser with a loopback
12
+ // redirect, the `az login` / `gh auth login` shape.
13
+ //
14
+ // The CLI never calls Graph itself. The token rides the existing bridge wire to
15
+ // the backend exactly as the pane's does (client.mjs attaches it to the
16
+ // pane-free engine ops and nothing else).
17
+ //
18
+ // Nothing about the app registration is hardcoded, per the connect doctrine:
19
+ // `GET <backend>/env/config` is unauthenticated and already serves the CLIENT_ID
20
+ // and TENANT_ID the pane itself uses, so `login` works against whichever
21
+ // deployment the session already names. Same app registration → the tenant's
22
+ // existing admin consent for its delegated Graph scopes carries over and
23
+ // `.default` resolves to the same grant set the pane gets.
24
+ //
25
+ // The refresh token is stored per backend in the session file (0600, already
26
+ // holding a bearer credential) and rotated on every refresh — AAD hands back a
27
+ // new one each time. Public-client refresh tokens live on a ~90-day sliding
28
+ // window, so an occasional re-login is expected, not a bug.
29
+
30
+ import crypto from "node:crypto";
31
+ import http from "node:http";
32
+
33
+ import {
34
+ deriveEnvLabel,
35
+ envPhrase,
36
+ getGraphRefreshToken,
37
+ getSessionFor,
38
+ setGraphRefreshToken,
39
+ } from "./config.mjs";
40
+ import { FAVICON_LINK } from "../builders/gate-server.mjs";
41
+ import { httpJson } from "./http-json.mjs";
42
+ import { register as registerSecret } from "./secrets.mjs";
43
+
44
+ // `.default` (rather than a scope list) is what makes this the SAME grant the
45
+ // pane holds: the tenant's admin consent for the app registration decides, so
46
+ // signing in from a terminal can never widen what the user already had.
47
+ // `openid` alone mints a MINIMAL id_token — no `oid`, no username — and the
48
+ // identity binding below needs `oid` while the closing line wants the name, so
49
+ // `profile` is required, not cosmetic. Combining openid/profile/offline_access
50
+ // with `.default` is explicitly allowed; only two RESOURCE scopes may not mix.
51
+ const GRAPH_SCOPE =
52
+ "https://graph.microsoft.com/.default offline_access openid profile";
53
+
54
+ // The SharePoint-resource scope for one deployment's tenant. A single sign-in
55
+ // covers BOTH resources: an Entra refresh token is multi-resource, so the
56
+ // interactive flow runs once against Graph and the SharePoint access token is
57
+ // redeemed from the same refresh token with this scope. The backend needs both
58
+ // — resolving `sg:` site-group membership takes Graph AND SharePoint REST
59
+ // (sp-dev-docs#5694), and the corpus routes refuse without them.
60
+ function sharePointScope(resource) {
61
+ return `${resource}/.default offline_access openid profile`;
62
+ }
63
+
64
+ const DEFAULT_AUTHORITY = "https://login.microsoftonline.com";
65
+
66
+ // How long the loopback listener waits for the browser to come back. Long
67
+ // enough for an MFA prompt and a password manager; short enough that a login
68
+ // the user abandoned doesn't hold a terminal forever.
69
+ const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
70
+
71
+ // Refresh this long before the access token actually expires, so a token handed
72
+ // to a long download doesn't die mid-transfer.
73
+ const EXPIRY_SKEW_MS = 5 * 60 * 1000;
74
+
75
+ // The Entra authority. Overridable the way AZURE_AUTHORITY_HOST is — sovereign
76
+ // clouds, and the test suite pointing the flow at a local mock endpoint. Held
77
+ // to the same transport rule as the setup claim: HTTPS, or plain HTTP only on
78
+ // loopback.
79
+ function authorityBase() {
80
+ const override = String(process.env.SLIDEMIND_GRAPH_AUTHORITY || "").trim();
81
+ if (!override) return DEFAULT_AUTHORITY;
82
+ let url;
83
+ try {
84
+ url = new URL(override);
85
+ } catch {
86
+ throw new Error(
87
+ "SLIDEMIND_GRAPH_AUTHORITY is not a valid URL — unset it to use the default Microsoft sign-in endpoint.",
88
+ );
89
+ }
90
+ const loopback = ["localhost", "127.0.0.1", "::1"].includes(url.hostname);
91
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) {
92
+ throw new Error(
93
+ "SLIDEMIND_GRAPH_AUTHORITY must be HTTPS (HTTP is allowed only for loopback development).",
94
+ );
95
+ }
96
+ return url.toString().replace(/\/$/, "");
97
+ }
98
+
99
+ function authorizeEndpoint(tenantId) {
100
+ return `${authorityBase()}/${encodeURIComponent(tenantId)}/oauth2/v2.0/authorize`;
101
+ }
102
+
103
+ function tokenEndpoint(tenantId) {
104
+ return `${authorityBase()}/${encodeURIComponent(tenantId)}/oauth2/v2.0/token`;
105
+ }
106
+
107
+ // One /env/config read per backend per process — the app registration doesn't
108
+ // change under a running command.
109
+ const appRegistrationCache = new Map();
110
+
111
+ // Which app registration this deployment signs in with. Unauthenticated on
112
+ // purpose: it is the same public configuration the add-in's own browser reads.
113
+ async function discoverAppRegistration(backendURL) {
114
+ const cached = appRegistrationCache.get(backendURL);
115
+ if (cached) return cached;
116
+ const env = deriveEnvLabel(backendURL);
117
+ let res;
118
+ try {
119
+ res = await httpJson("GET", `${backendURL}/env/config`);
120
+ } catch (err) {
121
+ throw new Error(
122
+ `could not reach slidemind${envPhrase(env, "on")} to start sign-in: ${err?.message || err}`,
123
+ );
124
+ }
125
+ const clientId = typeof res.json?.CLIENT_ID === "string" ? res.json.CLIENT_ID : "";
126
+ const tenantId = typeof res.json?.TENANT_ID === "string" ? res.json.TENANT_ID : "";
127
+ if (res.status !== 200 || !clientId || !tenantId) {
128
+ // An older deployment serves no sign-in details at all, so there is nothing
129
+ // to authorize against — this is an add-in-side update, not a user error.
130
+ throw new Error(
131
+ `the slidemind add-in${envPhrase(env, "on")} does not publish the sign-in details this command needs — ` +
132
+ "it needs an add-in update before `slidemind login` can work.",
133
+ );
134
+ }
135
+ // The deployment's SharePoint tenant root (e.g. https://tenant.sharepoint.com)
136
+ // — the RESOURCE the second token is minted for. Null on a deployment without
137
+ // SharePoint, where the site-group half of corpus identity is empty anyway.
138
+ let sharePointResource = null;
139
+ if (typeof res.json?.SHAREPOINT_SITE_URL === "string" && res.json.SHAREPOINT_SITE_URL) {
140
+ try {
141
+ sharePointResource = new URL(res.json.SHAREPOINT_SITE_URL).origin;
142
+ } catch {
143
+ sharePointResource = null;
144
+ }
145
+ }
146
+ const app = { clientId, tenantId, sharePointResource };
147
+ appRegistrationCache.set(backendURL, app);
148
+ return app;
149
+ }
150
+
151
+ function base64url(buf) {
152
+ return buf.toString("base64url");
153
+ }
154
+
155
+ // RFC 7636 S256: the verifier never leaves this process until the code is
156
+ // redeemed, so an authorization code intercepted on the loopback hop is useless
157
+ // on its own.
158
+ function pkcePair() {
159
+ const verifier = base64url(crypto.randomBytes(32));
160
+ const challenge = base64url(
161
+ crypto.createHash("sha256").update(verifier).digest(),
162
+ );
163
+ return { verifier, challenge };
164
+ }
165
+
166
+ // ---------------------------------------------------------------------------
167
+ // The browser pages.
168
+ //
169
+ // `slidemind login` is routinely typed by an AI agent, so the human at the
170
+ // keyboard can meet a Microsoft sign-in prompt they never asked for — and an
171
+ // unexpected sign-in prompt is exactly what a phishing page looks like. Entra
172
+ // cannot carry the reassurance: its sign-in page is tenant-branded, not
173
+ // per-app, and with admin-consented `.default` scopes the consent screen that
174
+ // WOULD name the app is skipped entirely. So slidemind says it in its own page,
175
+ // on its own loopback origin, and the user clicks through deliberately.
176
+ //
177
+ // Styled as the review/plan gates are — the same product identity a user has
178
+ // already seen from this CLI: the embossed "raised slide" mark, the drifting
179
+ // slide/mind wordmark, the dark product palette. Recognition is the security
180
+ // feature here, so this page must keep looking like the gates.
181
+
182
+ const LOGO_SVG =
183
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="160 160 960 540" preserveAspectRatio="none">' +
184
+ "<defs>" +
185
+ '<linearGradient id="rimTop" gradientUnits="userSpaceOnUse" x1="160" y1="0" x2="1120" y2="0">' +
186
+ '<stop offset="0%" stop-color="#d0e4ff" stop-opacity="0.85"/>' +
187
+ '<stop offset="35%" stop-color="#fff" stop-opacity="0.5"/>' +
188
+ '<stop offset="100%" stop-color="#fff" stop-opacity="0"/></linearGradient>' +
189
+ '<linearGradient id="rimLeft" gradientUnits="userSpaceOnUse" x1="0" y1="160" x2="0" y2="700">' +
190
+ '<stop offset="0%" stop-color="#d0e4ff" stop-opacity="0.6"/>' +
191
+ '<stop offset="40%" stop-color="#fff" stop-opacity="0.28"/>' +
192
+ '<stop offset="100%" stop-color="#fff" stop-opacity="0"/></linearGradient>' +
193
+ '<linearGradient id="rimBottom" gradientUnits="userSpaceOnUse" x1="1120" y1="0" x2="160" y2="0">' +
194
+ '<stop offset="0%" stop-color="#000" stop-opacity="0.55"/>' +
195
+ '<stop offset="60%" stop-color="#000" stop-opacity="0.2"/>' +
196
+ '<stop offset="100%" stop-color="#000" stop-opacity="0"/></linearGradient>' +
197
+ '<linearGradient id="rimRight" gradientUnits="userSpaceOnUse" x1="0" y1="700" x2="0" y2="160">' +
198
+ '<stop offset="0%" stop-color="#000" stop-opacity="0.45"/>' +
199
+ '<stop offset="60%" stop-color="#000" stop-opacity="0.16"/>' +
200
+ '<stop offset="100%" stop-color="#000" stop-opacity="0"/></linearGradient>' +
201
+ '<linearGradient id="faceFill" gradientUnits="userSpaceOnUse" x1="160" y1="160" x2="1120" y2="700">' +
202
+ '<stop offset="0%" stop-color="#fff" stop-opacity="0.08"/>' +
203
+ '<stop offset="50%" stop-color="#808080" stop-opacity="0.02"/>' +
204
+ '<stop offset="100%" stop-color="#000" stop-opacity="0.10"/></linearGradient>' +
205
+ "</defs>" +
206
+ '<rect x="160" y="160" width="960" height="540" rx="40" ry="40" fill="url(#faceFill)"/>' +
207
+ '<path d="M 160,200 A 40,40 0 0 1 200,160 L 1080,160 A 40,40 0 0 1 1120,200" fill="none" stroke="url(#rimTop)" stroke-width="3" stroke-linecap="round"/>' +
208
+ '<path d="M 160,200 L 160,660 A 40,40 0 0 0 200,700" fill="none" stroke="url(#rimLeft)" stroke-width="2.4" stroke-linecap="round"/>' +
209
+ '<path d="M 1120,660 A 40,40 0 0 1 1080,700 L 200,700 A 40,40 0 0 1 160,660" fill="none" stroke="url(#rimBottom)" stroke-width="3" stroke-linecap="round"/>' +
210
+ '<path d="M 1120,660 L 1120,200 A 40,40 0 0 0 1080,160" fill="none" stroke="url(#rimRight)" stroke-width="2.4" stroke-linecap="round"/>' +
211
+ '<rect x="160" y="160" width="960" height="540" rx="40" ry="40" fill="none" stroke="#fff" stroke-opacity="0.14" stroke-width="1.4"/>' +
212
+ "</svg>";
213
+
214
+ function escapeHtml(value) {
215
+ return String(value).replace(/[<&"]/g, (c) =>
216
+ c === "<" ? "&lt;" : c === "&" ? "&amp;" : "&quot;",
217
+ );
218
+ }
219
+
220
+ const PAGE_CSS = `
221
+ :root{--bg:#111113;--surface:#161618;--border:#2b2b30;--fg:#ededed;--dim:#9a9aa0;
222
+ --accent:#a9c2ff;--success:#2ecc71;--font:'Poppins',ui-sans-serif,system-ui,sans-serif;
223
+ --word:'Urbanist',var(--font);--mono:ui-monospace,SFMono-Regular,Menlo,monospace;}
224
+ *{box-sizing:border-box}
225
+ body{margin:0;min-height:100vh;display:flex;flex-direction:column;align-items:center;
226
+ justify-content:center;gap:20px;padding:40px 20px;font-family:var(--font);color:var(--fg);
227
+ background:radial-gradient(ellipse at center,#141416,#050507 78%);}
228
+ @keyframes drift-a{0%,100%{transform:translateX(-5px)}50%{transform:translateX(5px)}}
229
+ @keyframes drift-b{0%,100%{transform:translateX(5px)}50%{transform:translateX(-5px)}}
230
+ @keyframes rise{from{opacity:0;transform:translateY(14px)}to{opacity:1;transform:none}}
231
+ .logo{width:132px;height:74px;border-radius:11px;overflow:hidden;
232
+ background:linear-gradient(155deg,#262d3d,#0b0e14 76%);
233
+ box-shadow:0 10px 30px rgba(0,0,0,.6),inset 0 1px 0 rgba(255,255,255,.07),
234
+ 0 0 0 1px rgba(255,255,255,.05);animation:rise .6s cubic-bezier(.2,.7,.2,1) both;}
235
+ .logo svg{width:100%;height:100%;display:block}
236
+ .word{display:flex;flex-direction:column;gap:6px;font-family:var(--word);font-weight:300;
237
+ font-size:29px;letter-spacing:.58em;line-height:.98;text-align:center;
238
+ animation:rise .6s .06s cubic-bezier(.2,.7,.2,1) both;}
239
+ .word b{font-weight:300;will-change:transform}
240
+ .word .top{animation:drift-a 18s ease-in-out infinite}
241
+ .word .bot{animation:drift-b 18s ease-in-out infinite}
242
+ .tag{display:inline-flex;align-items:center;gap:8px;padding:5px 9px 5px 11px;border-radius:8px;
243
+ border:1px solid #2f3350;background:rgba(153,153,255,.07);font-family:var(--mono);font-size:11.5px;
244
+ font-weight:500;letter-spacing:.24em;text-transform:uppercase;color:#b4b4ff;
245
+ animation:rise .6s .1s cubic-bezier(.2,.7,.2,1) both;}
246
+ .tag .caret{color:#99f;opacity:.75;letter-spacing:0}
247
+ .tag .cur{display:inline-block;width:6px;height:12px;margin-left:-4px;background:#99f;
248
+ animation:blink 1.15s steps(1,end) infinite}
249
+ @keyframes blink{0%,49%{opacity:1}50%,100%{opacity:0}}
250
+ .card{width:min(430px,100%);padding:26px 28px;border-radius:16px;background:var(--surface);
251
+ border:1px solid var(--border);box-shadow:0 24px 60px rgba(0,0,0,.5);text-align:center;
252
+ animation:rise .6s .16s cubic-bezier(.2,.7,.2,1) both;}
253
+ .card h1{margin:0 0 6px;font-size:16.5px;font-weight:600;line-height:1.35}
254
+ .ask{margin:0 0 20px;font-size:12.5px;line-height:1.5;color:var(--dim)}
255
+ .ask b{color:#e4e4e8;font-weight:600}
256
+ .btn{display:inline-flex;align-items:center;justify-content:center;gap:9px;width:100%;
257
+ padding:12px 18px;border-radius:10px;background:#f4f4f5;color:#0f0f0f;text-decoration:none;
258
+ font-weight:600;font-size:13.5px;transition:transform .14s ease,box-shadow .14s ease,background .14s ease}
259
+ .btn:hover{background:#fff;transform:translateY(-1px);box-shadow:0 10px 26px rgba(0,0,0,.5)}
260
+ .btn svg{width:16px;height:16px}
261
+ .foot{margin:16px 0 0;font-size:11.5px;line-height:1.5;color:var(--dim)}
262
+ .foot b{color:#d4d4d8;font-weight:600}
263
+ .state{display:flex;align-items:center;justify-content:center;gap:10px;font-size:16px;font-weight:600}
264
+ .state svg{width:22px;height:22px;color:var(--success)}
265
+ .state.bad svg{color:#f87171}
266
+ code{font-family:var(--mono);font-size:11.5px;color:#d4d4d8;word-break:break-word}
267
+ `;
268
+
269
+ const CHECK_SVG =
270
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" ' +
271
+ 'stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>';
272
+ const MS_SVG =
273
+ '<svg viewBox="0 0 24 24"><rect x="2" y="2" width="9.5" height="9.5" fill="#f25022"/>' +
274
+ '<rect x="12.5" y="2" width="9.5" height="9.5" fill="#7fba00"/>' +
275
+ '<rect x="2" y="12.5" width="9.5" height="9.5" fill="#00a4ef"/>' +
276
+ '<rect x="12.5" y="12.5" width="9.5" height="9.5" fill="#ffb900"/></svg>';
277
+
278
+ // One shell for all three pages, so the sign-in the user is asked to trust and
279
+ // the pages that close it out are visibly the same application.
280
+ function page(title, cardHtml) {
281
+ return (
282
+ "<!doctype html><html><head><meta charset=utf-8>" +
283
+ `<title>${escapeHtml(title)}</title>` +
284
+ FAVICON_LINK +
285
+ '<link rel="preconnect" href="https://fonts.googleapis.com">' +
286
+ '<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>' +
287
+ '<link rel="stylesheet" href="https://fonts.googleapis.com/css2?' +
288
+ 'family=Poppins:wght@300;400;500;600&family=Urbanist:wght@300&display=swap">' +
289
+ `<style>${PAGE_CSS}</style></head><body>` +
290
+ `<div class="logo">${LOGO_SVG}</div>` +
291
+ '<div class="word"><b class="top">slide</b><b class="bot">mind</b></div>' +
292
+ '<div class="tag"><span class="caret">&#10095;</span>cli<span class="cur"></span></div>' +
293
+ `<div class="card">${cardHtml}</div>` +
294
+ "</body></html>"
295
+ );
296
+ }
297
+
298
+ function consentHtml({ env, authorizeUrl }) {
299
+ // `env` is deliberately NOT shown: which deployment this is tells the user
300
+ // nothing about whether to trust the prompt, and a URL-ish token next to a
301
+ // sign-in button reads as noise. WHO is asking, and WHY, is the whole message.
302
+ void env;
303
+ return page(
304
+ "slidemind sign-in",
305
+ "<h1>Sign in to Microsoft 365</h1>" +
306
+ '<p class="ask">An <b>AI agent</b> asked slidemind to sign you in, so it can read ' +
307
+ "content <b>on your behalf</b>.</p>" +
308
+ `<a class="btn" href="${escapeHtml(authorizeUrl)}">${MS_SVG}Continue with Microsoft</a>` +
309
+ '<p class="foot">Only what you can already open.<br>' +
310
+ "Not running a slidemind agent? Close this tab.</p>",
311
+ );
312
+ }
313
+
314
+ const SUCCESS_HTML = page(
315
+ "slidemind signed in",
316
+ `<div class="state">${CHECK_SVG}<span>Signed in</span></div>` +
317
+ '<p class="foot">Return to your terminal — slidemind is ready.</p>' +
318
+ // Best-effort: browsers only honour close() for tabs an external app
319
+ // opened (which this one is); if refused, the foot line still stands.
320
+ "<script>setTimeout(function(){window.close()},1500)</script>",
321
+ );
322
+
323
+ function failureHtml(detail) {
324
+ return page(
325
+ "slidemind sign-in failed",
326
+ '<div class="state bad"><span>Sign-in did not finish</span></div>' +
327
+ `<p class="foot"><code>${escapeHtml(detail)}</code></p>` +
328
+ '<p class="foot">Run <code>slidemind login</code> again to retry.</p>',
329
+ );
330
+ }
331
+
332
+ // Start the loopback listener and hand back its redirect URI plus a promise for
333
+ // the authorization code. Bound to 127.0.0.1 (never a routable interface) on an
334
+ // ephemeral port; `http://localhost` is registered as a Mobile-and-desktop
335
+ // redirect, which Entra matches on any port.
336
+ function startLoopback({ state, timeoutMs, env }) {
337
+ return new Promise((resolveStart, rejectStart) => {
338
+ let settle;
339
+ // Set once the authorize URL exists (it needs this listener's port first).
340
+ let authorizeUrl = null;
341
+ const code = new Promise((resolve, reject) => {
342
+ settle = { resolve, reject };
343
+ });
344
+ const server = http.createServer((req, res) => {
345
+ const url = new URL(req.url, "http://localhost");
346
+ if (url.pathname !== "/") {
347
+ // The browser also asks for /favicon.ico; only the redirect counts.
348
+ res.statusCode = 404;
349
+ res.end();
350
+ return;
351
+ }
352
+ res.setHeader("content-type", "text/html; charset=utf-8");
353
+ res.setHeader("connection", "close");
354
+ // No `code`/`error` means this is the browser arriving at the start page,
355
+ // not Entra returning — show slidemind's own explanation and stop. The
356
+ // listener keeps waiting; the real callback comes back to this same URL.
357
+ if (!url.searchParams.has("code") && !url.searchParams.has("error")) {
358
+ res.end(authorizeUrl ? consentHtml({ env, authorizeUrl }) : failureHtml("this sign-in is no longer waiting."));
359
+ return;
360
+ }
361
+ // Settling shuts the listener down and DESTROYS its sockets, so every
362
+ // branch waits for its page to flush first — otherwise the browser can be
363
+ // left staring at a blank tab after a perfectly good sign-in.
364
+ const answer = (html, done) => res.end(html, done);
365
+ const error = url.searchParams.get("error");
366
+ const detail = url.searchParams.get("error_description") || error;
367
+ if (error) {
368
+ // Conditional Access and consent refusals arrive here as AADSTS codes.
369
+ // Surfaced verbatim: retrying changes nothing, and the code is what a
370
+ // tenant admin needs to see.
371
+ answer(failureHtml(detail), () =>
372
+ settle.reject(new Error(`Microsoft sign-in was refused: ${detail}`)),
373
+ );
374
+ return;
375
+ }
376
+ if (url.searchParams.get("state") !== state) {
377
+ answer(failureHtml("the sign-in response did not match this request."), () =>
378
+ settle.reject(
379
+ new Error(
380
+ "the Microsoft sign-in response did not match this request (state mismatch) — run `slidemind login` again.",
381
+ ),
382
+ ),
383
+ );
384
+ return;
385
+ }
386
+ const received = url.searchParams.get("code");
387
+ if (!received) {
388
+ answer(failureHtml("the sign-in response carried no authorization code."), () =>
389
+ settle.reject(new Error("Microsoft sign-in returned no authorization code.")),
390
+ );
391
+ return;
392
+ }
393
+ answer(SUCCESS_HTML, () => settle.resolve(received));
394
+ });
395
+ const timer = setTimeout(() => {
396
+ settle.reject(
397
+ new Error(
398
+ "timed out waiting for the Microsoft sign-in to finish in the browser. Run `slidemind login` again.",
399
+ ),
400
+ );
401
+ }, timeoutMs);
402
+ if (timer.unref) timer.unref();
403
+ server.on("error", (err) => {
404
+ clearTimeout(timer);
405
+ rejectStart(err);
406
+ });
407
+ const shutdown = () => {
408
+ clearTimeout(timer);
409
+ server.close();
410
+ // The success page's socket is still open (keep-alive); without this the
411
+ // command would sit on it until the browser gave up.
412
+ server.closeAllConnections?.();
413
+ };
414
+ server.listen(0, "127.0.0.1", () => {
415
+ const { port } = server.address();
416
+ const settled = code.finally(shutdown);
417
+ // Marks the rejection handled without consuming it — a caller that never
418
+ // reaches `await code` (the opener blew up) must not crash the process
419
+ // when the wait later times out.
420
+ settled.catch(() => {});
421
+ resolveStart({
422
+ redirectUri: `http://localhost:${port}`,
423
+ startUrl: `http://localhost:${port}/`,
424
+ setAuthorizeUrl: (value) => {
425
+ authorizeUrl = value;
426
+ },
427
+ code: settled,
428
+ // Every abandoned listener holds the event loop open; the caller's
429
+ // error paths close it.
430
+ cancel: () => {
431
+ settle.reject(new Error("sign-in cancelled"));
432
+ },
433
+ });
434
+ });
435
+ });
436
+ }
437
+
438
+ // POST the token endpoint. Failures here carry `oauth: true` so callers can tell
439
+ // "Entra rejected this credential" (the refresh token is dead — drop it) from
440
+ // "the network was down" (keep it and try again later).
441
+ async function requestToken(tenantId, form) {
442
+ let res;
443
+ try {
444
+ res = await httpJson("POST", tokenEndpoint(tenantId), { form });
445
+ } catch (err) {
446
+ throw new Error(`could not reach Microsoft sign-in: ${err?.message || err}`);
447
+ }
448
+ if (res.status === 200 && res.json?.access_token) {
449
+ // Every mint and every refresh lands here, before any caller can act on the
450
+ // response or throw with it in scope — so this is where the bearer-shaped
451
+ // values get registered for output scrubbing. The id_token counts: it is a
452
+ // credential, not just claims.
453
+ registerSecret(res.json.access_token);
454
+ registerSecret(res.json.refresh_token);
455
+ registerSecret(res.json.id_token);
456
+ return res.json;
457
+ }
458
+ const detail =
459
+ res.json?.error_description || res.json?.error || res.text || `HTTP ${res.status}`;
460
+ const err = new Error(String(detail).split("\n")[0]);
461
+ err.oauth = true;
462
+ err.code = res.json?.error ?? null;
463
+ throw err;
464
+ }
465
+
466
+ // Display-only decode of an id_token payload. NOT a verification: the token
467
+ // arrived over TLS from the endpoint we asked, and nothing here is authorized
468
+ // on its contents — it only names the account in one printed line.
469
+ export function decodeJwtPayload(jwt) {
470
+ try {
471
+ const part = String(jwt).split(".")[1];
472
+ if (!part) return null;
473
+ return JSON.parse(Buffer.from(part, "base64url").toString("utf8"));
474
+ } catch {
475
+ return null;
476
+ }
477
+ }
478
+
479
+ function accountName(claims) {
480
+ if (!claims) return null;
481
+ for (const key of ["preferred_username", "upn", "email", "unique_name", "name"]) {
482
+ if (typeof claims[key] === "string" && claims[key]) return claims[key];
483
+ }
484
+ return null;
485
+ }
486
+
487
+ // The paired CLI session's identity, as the backend sees it. Called at login
488
+ // time only, to bind the browser sign-in to the session that will spend it.
489
+ async function sessionIdentity(backendURL, sessionToken) {
490
+ const res = await httpJson("GET", `${backendURL}/api/cli/whoami`, {
491
+ token: sessionToken,
492
+ });
493
+ if (res.status !== 200 || !res.json?.oid) {
494
+ throw new Error(
495
+ `could not confirm who this CLI is paired as${envPhrase(deriveEnvLabel(backendURL), "on")} — ` +
496
+ "run `slidemind doctor` and sign in again once the connection is healthy.",
497
+ );
498
+ }
499
+ return { oid: String(res.json.oid), tid: res.json.tid ? String(res.json.tid) : null };
500
+ }
501
+
502
+ // Refuse a sign-in that is not the paired session's own identity. The authority
503
+ // is tenant-pinned, so a foreign-tenant account already dies at Microsoft — but
504
+ // a same-tenant COLLEAGUE would pass, and then every SharePoint byte would be
505
+ // fetched as them for a session authorized as someone else. The id_token here is
506
+ // not signature-verified: it arrived over TLS from the token endpoint we chose,
507
+ // and this is a self-protection binding, not an authentication decision.
508
+ function assertTokenIdentity({ claims, tenantId, session, env }) {
509
+ if (!claims?.tid || claims.tid !== tenantId) {
510
+ throw new Error(
511
+ `Microsoft sign-in came back from the wrong tenant (expected this deployment's tenant${envPhrase(env, "on")}). ` +
512
+ "Sign in with the account you use in PowerPoint there.",
513
+ );
514
+ }
515
+ if (!claims?.oid) {
516
+ // No account id in the id_token at all — nothing to bind against. That is a
517
+ // sign-in CONFIGURATION fault (a missing `profile` scope, an app that
518
+ // suppresses the claim), not the user picking the wrong account, so say so
519
+ // rather than sending them round the picker again.
520
+ throw new Error(
521
+ "the Microsoft sign-in returned no account id, so slidemind cannot confirm it is " +
522
+ `the user this CLI is paired as${envPhrase(env, "on")}. This is a sign-in configuration ` +
523
+ "problem — report it rather than retrying.",
524
+ );
525
+ }
526
+ if (claims.oid !== session.oid) {
527
+ // Name the account that came back: the picker often holds several, and
528
+ // "wrong account" is unactionable until you know which one you picked.
529
+ const who = accountName(claims);
530
+ throw new Error(
531
+ `${who ? `'${who}'` : "that Microsoft account"} is not the user this CLI is ` +
532
+ `paired as${envPhrase(env, "on")}. Sign in with the same account you used when connecting ` +
533
+ "the CLI (the one you use in PowerPoint), or re-run setup from that account's add-in.",
534
+ );
535
+ }
536
+ }
537
+
538
+ // Whether this backend holds a stored Graph sign-in. Storage only — no network,
539
+ // so remediation text can ask it for free.
540
+ export function hasGraphSession(backendURL) {
541
+ return Boolean(backendURL) && Boolean(getGraphRefreshToken(backendURL));
542
+ }
543
+
544
+ // ---------------------------------------------------------------------------
545
+ // Auto sign-in: login happens where it is needed, not as a separate ritual.
546
+ //
547
+ // Any command that reaches the engine surface needs the user's own delegated
548
+ // tokens; instead of failing with "run `slidemind login`", the CLI runs that
549
+ // sign-in ITSELF — opens the browser, prints what it is waiting for, and blocks
550
+ // until the human finishes (or the 5-minute window closes). With a stored
551
+ // refresh token this is a no-op, so a signed-in user never sees it; the browser
552
+ // appears exactly once per machine per ~90 days.
553
+ //
554
+ // One attempt per backend per process: an abandoned or refused sign-in must not
555
+ // reopen the browser on every retry of a loop — the command then proceeds
556
+ // token-less and meets the backend's typed `login_required`, which names the
557
+ // manual `slidemind login` for whenever the user is ready.
558
+
559
+ const autoLoginPending = new Map();
560
+ const autoLoginFailed = new Set();
561
+
562
+ /**
563
+ * Make sure this backend has a Microsoft 365 sign-in, interactively if needed.
564
+ * Returns true when a sign-in is stored afterwards. Never throws.
565
+ * `open` is a test seam only — production callers pass nothing and get the
566
+ * same hardened opener `slidemind login` uses.
567
+ */
568
+ export async function ensureSignedIn(backendURL, { open = null } = {}) {
569
+ if (!backendURL) return false;
570
+ if (getGraphRefreshToken(backendURL)) return true;
571
+ if (autoLoginFailed.has(backendURL)) return false;
572
+ let pending = autoLoginPending.get(backendURL);
573
+ if (!pending) {
574
+ pending = (async () => {
575
+ const env = deriveEnvLabel(backendURL);
576
+ process.stderr.write(
577
+ `Microsoft 365 sign-in needed${envPhrase(env, "for")} — opening your browser. ` +
578
+ "Finish the sign-in there; this command waits for it.\n",
579
+ );
580
+ const result = await loginGraph({
581
+ backendURL,
582
+ open,
583
+ onUrl: (url) =>
584
+ process.stderr.write(` if no browser opened, open: ${url}\n`),
585
+ });
586
+ process.stderr.write(
587
+ `Signed in${result.username ? ` as ${result.username}` : ""}${envPhrase(env, "for")}.\n`,
588
+ );
589
+ })().finally(() => {
590
+ autoLoginPending.delete(backendURL);
591
+ });
592
+ autoLoginPending.set(backendURL, pending);
593
+ }
594
+ try {
595
+ await pending;
596
+ return true;
597
+ } catch (err) {
598
+ autoLoginFailed.add(backendURL);
599
+ process.stderr.write(
600
+ `Microsoft 365 sign-in did not finish: ${err?.message ?? err}\n` +
601
+ "Continuing without it — corpus search will answer `login_required` until " +
602
+ "`slidemind login` succeeds.\n",
603
+ );
604
+ return false;
605
+ }
606
+ }
607
+
608
+ // Drop a backend's Microsoft 365 sign-in (a revoked token, or `slidemind
609
+ // logout`) — both resource tokens go with the refresh token they came from.
610
+ export function clearGraphSession(backendURL) {
611
+ accessTokenCache.delete(cacheKey(backendURL, "graph"));
612
+ accessTokenCache.delete(cacheKey(backendURL, "sharepoint"));
613
+ return setGraphRefreshToken(backendURL, null);
614
+ }
615
+
616
+ /**
617
+ * Run the interactive sign-in for one backend and persist the refresh token.
618
+ * Returns { username, tenantId, env }.
619
+ *
620
+ * `open` is injected so this module stays free of the builders' browser chain
621
+ * in tests; the default is the same hardened opener the review/plan gates use.
622
+ * `onUrl` receives slidemind's local start URL before the browser is launched —
623
+ * the CLI prints it so a machine where no opener works is still recoverable by
624
+ * hand (that page then links on to Microsoft).
625
+ */
626
+ export async function loginGraph({
627
+ backendURL,
628
+ open = null,
629
+ onUrl = null,
630
+ timeoutMs = LOGIN_TIMEOUT_MS,
631
+ } = {}) {
632
+ if (!backendURL) throw new Error("loginGraph: backendURL is required");
633
+ const session = getSessionFor(backendURL);
634
+ if (!session) {
635
+ throw new Error(
636
+ `slidemind is not connected to '${deriveEnvLabel(backendURL)}'.`,
637
+ );
638
+ }
639
+ const { clientId, tenantId } = await discoverAppRegistration(backendURL);
640
+ // Fetched BEFORE the browser opens: if the session is dead the user learns it
641
+ // now, not after completing an MFA dance whose result would be thrown away.
642
+ const paired = await sessionIdentity(backendURL, session.sessionToken);
643
+ const { verifier, challenge } = pkcePair();
644
+ const state = base64url(crypto.randomBytes(16));
645
+ const { redirectUri, startUrl, setAuthorizeUrl, code, cancel } = await startLoopback({
646
+ state,
647
+ timeoutMs,
648
+ env: deriveEnvLabel(backendURL),
649
+ });
650
+
651
+ const authorizeUrl = new URL(authorizeEndpoint(tenantId));
652
+ authorizeUrl.search = new URLSearchParams({
653
+ client_id: clientId,
654
+ response_type: "code",
655
+ redirect_uri: redirectUri,
656
+ scope: GRAPH_SCOPE,
657
+ code_challenge: challenge,
658
+ code_challenge_method: "S256",
659
+ state,
660
+ // The CLI may be signing in as a different account than the browser's
661
+ // default — never silently reuse whoever the browser happens to hold.
662
+ prompt: "select_account",
663
+ }).toString();
664
+
665
+ // The browser is sent to slidemind's own page first, which then links on to
666
+ // Microsoft. The user must see WHO asked before a sign-in prompt appears —
667
+ // an agent may have started this without them.
668
+ setAuthorizeUrl(authorizeUrl.toString());
669
+
670
+ let tokens;
671
+ try {
672
+ if (onUrl) onUrl(startUrl);
673
+ const opener = open ?? (await import("../builders/build.mjs")).openInBrowser;
674
+ await opener(startUrl, { quiet: true });
675
+
676
+ const authCode = await code;
677
+ tokens = await requestToken(tenantId, {
678
+ client_id: clientId,
679
+ grant_type: "authorization_code",
680
+ code: authCode,
681
+ redirect_uri: redirectUri,
682
+ code_verifier: verifier,
683
+ scope: GRAPH_SCOPE,
684
+ });
685
+ } catch (err) {
686
+ cancel();
687
+ throw err;
688
+ }
689
+ if (!tokens.refresh_token) {
690
+ throw new Error(
691
+ "Microsoft sign-in returned no refresh token — the app registration is missing the offline_access grant.",
692
+ );
693
+ }
694
+ const claims = decodeJwtPayload(tokens.id_token);
695
+ assertTokenIdentity({
696
+ claims,
697
+ tenantId,
698
+ session: paired,
699
+ env: deriveEnvLabel(backendURL),
700
+ });
701
+ if (!setGraphRefreshToken(backendURL, tokens.refresh_token)) {
702
+ throw new Error(
703
+ `slidemind is not connected to '${deriveEnvLabel(backendURL)}'.`,
704
+ );
705
+ }
706
+ cacheAccessToken(backendURL, tokens);
707
+ // Mint the SharePoint-resource sibling right away (same refresh token) so a
708
+ // missing SharePoint consent surfaces HERE, at sign-in, with a human present
709
+ // — not as a silent login_required in the middle of an agent run. Best-effort:
710
+ // the sign-in itself already succeeded.
711
+ const { sharePointResource } = await discoverAppRegistration(backendURL);
712
+ if (sharePointResource) {
713
+ const spToken = await getSharePointToken(backendURL);
714
+ if (!spToken) {
715
+ process.stderr.write(
716
+ `signed in, but no SharePoint access token could be minted${envPhrase(deriveEnvLabel(backendURL), "for")} — ` +
717
+ "corpus search against SharePoint content will stay unavailable. " +
718
+ "This is an app-registration/consent problem to report, not a retry.\n",
719
+ );
720
+ }
721
+ }
722
+ return {
723
+ username: accountName(claims),
724
+ tenantId,
725
+ env: deriveEnvLabel(backendURL),
726
+ };
727
+ }
728
+
729
+ // Access tokens live in memory only — they are minutes-long and re-mintable
730
+ // from the stored refresh token, so writing them to disk would add exposure for
731
+ // nothing. Keyed by backend + resource: two connected environments are two
732
+ // sign-ins, and each sign-in mints one Graph and (where the deployment has
733
+ // SharePoint) one SharePoint token from the SAME refresh token.
734
+ const accessTokenCache = new Map(); // `${backendURL}${resource}` → { token, expiresAt }
735
+ const cacheKey = (backendURL, resource) => `${backendURL}${resource}`;
736
+
737
+ // Every refresh SPENDS the stored refresh token (AAD rotates it), and the two
738
+ // resources share it — so all mints for one backend are serialized on a single
739
+ // chain. Two concurrent Graph picks join the same promise; a Graph and a
740
+ // SharePoint mint run one after the other, each persisting the rotation before
741
+ // the next redeems it.
742
+ const refreshChain = new Map();
743
+
744
+ function withRefreshLock(backendURL, fn) {
745
+ const tail = refreshChain.get(backendURL) ?? Promise.resolve();
746
+ const next = tail.then(fn, fn);
747
+ refreshChain.set(
748
+ backendURL,
749
+ next.catch(() => {}),
750
+ );
751
+ return next;
752
+ }
753
+
754
+ function cacheAccessToken(backendURL, tokens, resource = "graph") {
755
+ const lifetimeMs = (Number(tokens.expires_in) || 3600) * 1000;
756
+ accessTokenCache.set(cacheKey(backendURL, resource), {
757
+ token: tokens.access_token,
758
+ expiresAt: Date.now() + lifetimeMs,
759
+ });
760
+ }
761
+
762
+ /**
763
+ * A usable Graph access token for one backend, or null.
764
+ *
765
+ * Null is the ordinary answer — not logged in, or the sign-in has aged out —
766
+ * and it means "carry on without one": the caller then meets the backend's
767
+ * typed `login_required` answer, which is the remediation. So this NEVER
768
+ * throws.
769
+ */
770
+ export async function getGraphToken(backendURL) {
771
+ return resourceToken(backendURL, "graph");
772
+ }
773
+
774
+ /**
775
+ * A usable SharePoint-resource access token for one backend, or null.
776
+ *
777
+ * Null when not signed in, when the deployment has no SharePoint configured
778
+ * (nothing to mint a token FOR), or when Entra refuses the mint. Redeemed from
779
+ * the same refresh token as the Graph token — one sign-in, two resources.
780
+ */
781
+ export async function getSharePointToken(backendURL) {
782
+ return resourceToken(backendURL, "sharepoint");
783
+ }
784
+
785
+ async function resourceToken(backendURL, resource) {
786
+ if (!backendURL) return null;
787
+ if (!getGraphRefreshToken(backendURL)) return null; // not logged in: no network, no cost
788
+ const cached = accessTokenCache.get(cacheKey(backendURL, resource));
789
+ if (cached && cached.expiresAt - EXPIRY_SKEW_MS > Date.now()) return cached.token;
790
+ // Serialized per backend (see withRefreshLock): concurrent picks must not
791
+ // each spend a rotation, and the sibling resource's mint must see the
792
+ // rotation this one persists.
793
+ return withRefreshLock(backendURL, async () => {
794
+ // Re-check under the lock — the mint that just finished may have been ours.
795
+ const fresh = accessTokenCache.get(cacheKey(backendURL, resource));
796
+ if (fresh && fresh.expiresAt - EXPIRY_SKEW_MS > Date.now()) return fresh.token;
797
+ const stored = getGraphRefreshToken(backendURL);
798
+ if (!stored) return null;
799
+ return refreshResourceToken(backendURL, resource, stored);
800
+ });
801
+ }
802
+
803
+ // One expiry note per backend per process: parallel picks would otherwise print
804
+ // it once per download, burying the one line the agent needs to relay.
805
+ const expiryNoted = new Set();
806
+
807
+ async function refreshResourceToken(backendURL, resource, storedRefreshToken) {
808
+ let tokens;
809
+ let scope;
810
+ try {
811
+ const { clientId, tenantId, sharePointResource } =
812
+ await discoverAppRegistration(backendURL);
813
+ if (resource === "sharepoint") {
814
+ // No SharePoint on this deployment — there is no resource to mint for,
815
+ // and that is a resolved answer, not a failure.
816
+ if (!sharePointResource) return null;
817
+ scope = sharePointScope(sharePointResource);
818
+ } else {
819
+ scope = GRAPH_SCOPE;
820
+ }
821
+ tokens = await requestToken(tenantId, {
822
+ client_id: clientId,
823
+ grant_type: "refresh_token",
824
+ refresh_token: storedRefreshToken,
825
+ scope,
826
+ });
827
+ } catch (err) {
828
+ // Only Entra REFUSING the credential proves it is dead (revoked, expired
829
+ // past the sliding window, consent withdrawn). A backend that was
830
+ // unreachable for a moment must not cost the user their sign-in — and a
831
+ // SharePoint-scope consent refusal must not kill the Graph half either, so
832
+ // only the errors that name the CREDENTIAL clear the session.
833
+ const credentialDead =
834
+ err?.oauth &&
835
+ ["invalid_grant", "interaction_required", "unauthorized_client"].includes(
836
+ String(err?.code ?? ""),
837
+ );
838
+ if (credentialDead) {
839
+ clearGraphSession(backendURL);
840
+ // Said HERE, at the moment the sign-in dies, so the agent driving this
841
+ // command can tell the difference between "never signed in" (silent —
842
+ // nothing changed) and "was signed in, needs the human again" — otherwise
843
+ // all it sees is a downstream login_required with no cause.
844
+ if (!expiryNoted.has(backendURL)) {
845
+ expiryNoted.add(backendURL);
846
+ process.stderr.write(
847
+ `Microsoft 365 sign-in expired${envPhrase(deriveEnvLabel(backendURL), "for")} — ` +
848
+ "corpus search and SharePoint content need the user to run `slidemind login` again.\n",
849
+ );
850
+ }
851
+ }
852
+ return null;
853
+ }
854
+ // Rotation: AAD issues a new refresh token on every refresh and the old one
855
+ // is spent. Persisting it is what keeps the ~90-day window sliding.
856
+ if (tokens.refresh_token) setGraphRefreshToken(backendURL, tokens.refresh_token);
857
+ cacheAccessToken(backendURL, tokens, resource);
858
+ return tokens.access_token;
859
+ }