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.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/AGENTS.md +37 -8
- package/README.md +32 -2
- package/bin/slidemind.mjs +535 -122
- package/install.ps1 +70 -0
- package/install.sh +65 -0
- package/package.json +3 -1
- package/skills/create-slides/SKILL.md +30 -6
- package/skills/search/SKILL.md +3 -1
- package/src/bridge/build-and-push.mjs +418 -38
- package/src/bridge/capture.mjs +12 -1
- package/src/bridge/client.mjs +344 -26
- package/src/bridge/config.mjs +193 -1
- package/src/bridge/graph-auth.mjs +859 -0
- package/src/bridge/http-json.mjs +15 -6
- package/src/bridge/secrets.mjs +75 -0
- package/src/bridge/setup-claim.mjs +6 -0
- package/src/builders/build.mjs +3 -1
- package/src/builders/gate-server.mjs +156 -0
- package/src/builders/gate-session.js +355 -0
- package/src/builders/plan-overlay.js +507 -225
- package/src/builders/plan.mjs +169 -98
- package/src/builders/review-overlay.js +390 -302
- package/src/builders/review.mjs +136 -50
- package/src/lib/adopt.mjs +407 -0
- package/src/lib/conversions.mjs +263 -0
- package/src/lib/overwrite-guard.mjs +91 -0
- package/src/lib/reconcile-ack.mjs +46 -0
- package/src/lib/reconcile.mjs +306 -0
- package/src/lib/slide-guids.mjs +143 -0
- package/src/pptx/fill/engine.mjs +69 -1
- package/src/pptx/hidden-ole.mjs +22 -1
- package/src/pptx/slide-swap.mjs +23 -1
- package/src/pptx/tags.mjs +676 -0
|
@@ -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 === "<" ? "<" : c === "&" ? "&" : """,
|
|
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">❯</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
|
+
}
|