@aion0/forge 0.10.45 → 0.10.47

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,443 @@
1
+ /**
2
+ * GitLab SSH 2FA verifier.
3
+ *
4
+ * Wraps `ssh git@<host> 2fa_verify` in a node-pty session so a UI-supplied
5
+ * OTP can be fed without the user dropping into a terminal. After success
6
+ * GitLab marks the SSH key 2FA-cleared for ~1h, so subsequent `git push`
7
+ * via SSH (and Forge pipelines that push) work without prompting.
8
+ *
9
+ * Host comes from the installed `gitlab` connector's `config.base_url` —
10
+ * we just take the hostname. If the user has a non-default SSH endpoint
11
+ * (different port / user) we'd need a per-connector setting; left as a
12
+ * follow-up.
13
+ */
14
+
15
+ import * as pty from 'node-pty';
16
+ import { getInstalledConnector } from '../connectors/registry';
17
+
18
+ const SSH_USER = 'git';
19
+ const VERIFY_TIMEOUT_MS = 60_000;
20
+ const OTP_RE = /^\d{6,8}$/;
21
+
22
+ export interface VerifyResult {
23
+ ok: boolean;
24
+ verifiedAt?: string;
25
+ host?: string;
26
+ error?: string;
27
+ transcript?: string;
28
+ }
29
+
30
+ /**
31
+ * Resolve the SSH hostname from the gitlab connector config. Returns null
32
+ * if gitlab isn't installed or its base_url is empty.
33
+ */
34
+ export function getGitlabSshHost(): string | null {
35
+ const cfg = getInstalledConnector('gitlab')?.config as
36
+ | { base_url?: string }
37
+ | undefined;
38
+ const url = (cfg?.base_url || '').trim();
39
+ if (!url) return null;
40
+ try {
41
+ const u = new URL(url);
42
+ return u.hostname || null;
43
+ } catch {
44
+ // Tolerate base_url without scheme
45
+ const m = url.match(/^([^/]+)/);
46
+ return m?.[1] || null;
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Run `ssh git@<host> 2fa_verify`, feed the OTP, return whether the host
52
+ * said OK. Never throws — failure modes are returned via `{ok:false, error}`.
53
+ */
54
+ export async function verifyGitlab2FA(otp: string): Promise<VerifyResult> {
55
+ const cleaned = String(otp || '').replace(/\s+/g, '');
56
+ if (!OTP_RE.test(cleaned)) {
57
+ return { ok: false, error: 'OTP must be 6–8 digits' };
58
+ }
59
+ const host = getGitlabSshHost();
60
+ if (!host) {
61
+ return { ok: false, error: 'GitLab connector not configured — set base_url first' };
62
+ }
63
+
64
+ return new Promise<VerifyResult>((resolve) => {
65
+ let buf = '';
66
+ let otpSent = false;
67
+ let resolved = false;
68
+ const finish = (r: VerifyResult) => {
69
+ if (resolved) return;
70
+ resolved = true;
71
+ try { proc.kill(); } catch { /* already dead */ }
72
+ clearTimeout(killTimer);
73
+ clearTimeout(fallbackTimer);
74
+ if (!r.ok) {
75
+ // Log to forge.log — transcript shouldn't be in UI permanently,
76
+ // but ops need it for first-time diagnostics.
77
+ console.warn(
78
+ `[gitlab-2fa] verify failed on ${host}: ${r.error}\n` +
79
+ `--- transcript ---\n${r.transcript || '(empty)'}\n------------------`,
80
+ );
81
+ }
82
+ resolve(r);
83
+ };
84
+ const sendOtp = (why: string) => {
85
+ if (otpSent) return;
86
+ otpSent = true;
87
+ console.log(`[gitlab-2fa] sending OTP (${why})`);
88
+ try { proc.write(cleaned + '\n'); } catch { /* proc died */ }
89
+ };
90
+
91
+ const args = [
92
+ '-T', // disable pseudo-TTY allocation we control PTY
93
+ '-o', 'BatchMode=no',
94
+ '-o', 'StrictHostKeyChecking=accept-new',
95
+ '-o', 'ConnectTimeout=30', // VPN first-packet can be slow
96
+ '-o', 'NumberOfPasswordPrompts=0', // we use SSH key, not password
97
+ '-o', 'PreferredAuthentications=publickey',
98
+ `${SSH_USER}@${host}`,
99
+ '2fa_verify',
100
+ ];
101
+
102
+ const proc = pty.spawn('ssh', args, {
103
+ name: 'xterm-256color',
104
+ cols: 120,
105
+ rows: 30,
106
+ env: process.env as Record<string, string>,
107
+ });
108
+
109
+ // Fallback: GitLab versions / forks vary in prompt wording (some
110
+ // print "OTP:", some "2FA token:", some include welcome banners
111
+ // before the prompt). If we haven't sent OTP within 2s of any
112
+ // output, send anyway — gitlab-shell waits silently for input.
113
+ const fallbackTimer = setTimeout(() => sendOtp('1.5s fallback'), 1500);
114
+
115
+ const killTimer = setTimeout(() => {
116
+ finish({
117
+ ok: false,
118
+ host,
119
+ error: `Timed out after ${VERIFY_TIMEOUT_MS / 1000}s — last output:\n${tail(buf)}`,
120
+ transcript: buf,
121
+ });
122
+ }, VERIFY_TIMEOUT_MS);
123
+
124
+ proc.onData((chunk) => {
125
+ buf += chunk;
126
+ // Strip ANSI escape sequences before matching prompt / markers.
127
+ const visible = stripAnsi(buf);
128
+
129
+ // Eager send: as soon as we see "OTP" / "code" / "token" / a ":"
130
+ // line tail, push the digits. The 1.5s fallback above covers
131
+ // versions that print only the prompt with no easy keyword.
132
+ if (!otpSent && /\b(otp|2fa|two-?factor|verification|code|token)\b/i.test(visible)) {
133
+ sendOtp('keyword match');
134
+ } else if (!otpSent && /:\s*$/.test(visible)) {
135
+ sendOtp('colon-prompt');
136
+ }
137
+
138
+ if (/\bOK\b/i.test(visible) && /two-?factor|successful|verified/i.test(visible)) {
139
+ finish({
140
+ ok: true,
141
+ verifiedAt: new Date().toISOString(),
142
+ host,
143
+ transcript: visible,
144
+ });
145
+ }
146
+ });
147
+
148
+ proc.onExit(({ exitCode }) => {
149
+ if (resolved) return;
150
+ const visible = stripAnsi(buf);
151
+ const lower = visible.toLowerCase();
152
+ let error = `ssh exited (code ${exitCode})`;
153
+ if (lower.includes('invalid otp') || lower.includes('invalid code') || lower.includes('otp validation failed')) {
154
+ error = 'OTP rejected by GitLab — wrong code or expired';
155
+ } else if (lower.includes('permission denied') || lower.includes('publickey')) {
156
+ error = `SSH key not authorized on ${host} — add your key to GitLab → SSH Keys first`;
157
+ } else if (
158
+ lower.includes('connection refused') ||
159
+ lower.includes('connection timed out') ||
160
+ lower.includes('operation timed out') || // macOS BSD ssh wording
161
+ lower.includes('network is unreachable') ||
162
+ lower.includes('no route to host')
163
+ ) {
164
+ error = `Can't reach ${host}:22 — are you on the corporate VPN?`;
165
+ } else if (lower.includes('host key verification failed')) {
166
+ error = `Host key changed for ${host} — run 'ssh-keygen -R ${host}' and try again`;
167
+ } else if (lower.includes('command rejected') || lower.includes('disallowed command') || lower.includes('not allowed')) {
168
+ error = `GitLab refused the '2fa_verify' command on ${host} — older GitLab without 2FA-over-SSH? Run it manually first to confirm.`;
169
+ } else if (visible.trim()) {
170
+ // Surface the last non-empty line so the user sees the real
171
+ // reason. Strip any pure-digit lines first — those are the OTP
172
+ // we echoed back through the PTY, not a real error.
173
+ const tailText = tail(visible)
174
+ .split('\n')
175
+ .filter(l => !/^\s*\d{4,8}\s*$/.test(l))
176
+ .join('\n')
177
+ .trim();
178
+ error = tailText
179
+ ? `ssh exited (code ${exitCode}): ${tailText}`
180
+ : `ssh exited (code ${exitCode})`;
181
+ }
182
+ finish({ ok: false, host, error, transcript: visible });
183
+ });
184
+ });
185
+ }
186
+
187
+ function stripAnsi(s: string): string {
188
+ // Strip CSI sequences + lone carriage returns that mess up regex.
189
+ return s.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '').replace(/\r(?!\n)/g, '');
190
+ }
191
+
192
+ function tail(s: string, lines = 6): string {
193
+ const arr = s.split('\n').filter(l => l.length > 0);
194
+ return arr.slice(-lines).join('\n');
195
+ }
196
+
197
+ /**
198
+ * GitLab's SSH 2FA grace window. Used by the UI to render "X min left".
199
+ * Conservatively 55 min instead of the documented 60 — gives the user
200
+ * time to re-verify before push starts failing.
201
+ */
202
+ export const GITLAB_2FA_VALID_MS = 55 * 60 * 1000;
203
+
204
+ export function secondsLeft(verifiedAt: string | undefined): number {
205
+ if (!verifiedAt) return 0;
206
+ const t = Date.parse(verifiedAt);
207
+ if (!Number.isFinite(t)) return 0;
208
+ return Math.max(0, Math.floor((t + GITLAB_2FA_VALID_MS - Date.now()) / 1000));
209
+ }
210
+
211
+ // ─── Keystroke fallback (macOS) ────────────────────────────────────────
212
+ //
213
+ // When direct ssh is blocked by per-process VPN filtering (FortiClient),
214
+ // the only viable automation is "type into the user's already-trusted
215
+ // Terminal window" via System Events. The typed `ssh ... 2fa_verify`
216
+ // then runs as a direct child of the trusted zsh PID — same as if the
217
+ // user typed it themselves — so the VPN tunnel applies and the host
218
+ // is reachable.
219
+ //
220
+ // Requires:
221
+ // - macOS
222
+ // - osascript on PATH (always shipped)
223
+ // - Accessibility permission for the parent (node/Forge) — first run
224
+ // prompts; if denied, error message guides the user to System
225
+ // Settings → Privacy → Accessibility
226
+ // - At least one Terminal.app window with the user's trusted zsh
227
+ //
228
+ // Privacy notes:
229
+ // - Command line starts with a SPACE so users with `setopt HIST_IGNORE_SPACE`
230
+ // keep it out of shell history.
231
+ // - The OTP itself is sent as a separate keystroke event after the
232
+ // ssh process prompts — it goes into ssh, not the shell, so it
233
+ // never lands in history regardless.
234
+ //
235
+ // Caveats:
236
+ // - Terminal must be reachable to AppleScript (running, at least one
237
+ // window). If none, the script errors with -1719 (no window).
238
+ // - Brings Terminal to front for ~5 seconds — disruptive if the user
239
+ // was typing somewhere else.
240
+ // - We scrape `contents of front window` to detect outcome, looking
241
+ // for a unique marker we emit. Output that contains the marker
242
+ // wrongly (very unlikely) would mis-detect.
243
+
244
+ import { spawn } from 'node:child_process';
245
+
246
+ interface AppleScriptResult { stdout: string; stderr: string; code: number | null }
247
+
248
+ function runAppleScript(script: string, timeoutMs = 60_000): Promise<AppleScriptResult> {
249
+ return new Promise((resolve) => {
250
+ const p = spawn('osascript', [], { stdio: ['pipe', 'pipe', 'pipe'] });
251
+ let stdout = '', stderr = '';
252
+ p.stdout.on('data', (d) => { stdout += d.toString(); });
253
+ p.stderr.on('data', (d) => { stderr += d.toString(); });
254
+ const timer = setTimeout(() => { try { p.kill(); } catch {} }, timeoutMs);
255
+ p.on('close', (code) => { clearTimeout(timer); resolve({ stdout, stderr, code }); });
256
+ p.stdin.write(script);
257
+ p.stdin.end();
258
+ });
259
+ }
260
+
261
+ /**
262
+ * Drive `ssh git@host 2fa_verify` by typing into the user's frontmost
263
+ * Terminal window. The ssh process becomes a direct child of the
264
+ * trusted zsh, getting FortiClient VPN access automatically.
265
+ */
266
+ export async function verifyGitlab2FAViaKeystroke(otp: string): Promise<VerifyResult> {
267
+ if (process.platform !== 'darwin') {
268
+ return { ok: false, error: 'Keystroke mode is macOS-only.' };
269
+ }
270
+ const cleaned = String(otp || '').replace(/\s+/g, '');
271
+ if (!OTP_RE.test(cleaned)) {
272
+ return { ok: false, error: 'OTP must be 6–8 digits' };
273
+ }
274
+ const host = getGitlabSshHost();
275
+ if (!host) {
276
+ return { ok: false, error: 'GitLab connector not configured — set base_url first' };
277
+ }
278
+
279
+ const tag = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
280
+ const markOk = `FORGE_2FA_OK_${tag}`;
281
+ const markFail = `FORGE_2FA_FAIL_${tag}`;
282
+ // Leading space → HIST_IGNORE_SPACE keeps it out of history.
283
+ // Two distinct atomic markers so partial-render races can't mis-
284
+ // identify outcome (Terminal might render the marker line in
285
+ // pieces; checking for one short atomic token sidesteps that).
286
+ const cmd = ` ssh git@${host} 2fa_verify && echo "${markOk}" || echo "${markFail}"`;
287
+
288
+ // AppleScript escape: ", \ → \", \\.
289
+ const esc = (s: string) => s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
290
+ // Use clipboard + cmd+v instead of keystroke-char-by-char.
291
+ // keystroke drops characters on slow/busy targets (user saw
292
+ // 'sshgitdops-git106...' — spaces and '@' eaten). Clipboard paste
293
+ // lands the entire string atomically.
294
+ // We save the user's previous clipboard and restore at the end.
295
+ const script = [
296
+ 'set savedClip to ""',
297
+ 'try',
298
+ ' set savedClip to (the clipboard as text)',
299
+ 'end try',
300
+ 'tell application "Terminal" to activate',
301
+ 'delay 0.4',
302
+ // Snapshot buffer BEFORE we paste so the prompt check only looks
303
+ // at fresh output. Scrollback from prior runs contains old "OTP"
304
+ // strings that would otherwise match immediately and we'd paste
305
+ // the OTP before ssh has even printed its prompt.
306
+ 'tell application "Terminal"',
307
+ ' set baseline to (contents of front window as text)',
308
+ 'end tell',
309
+ 'set baseLen to count of baseline',
310
+ // Paste the ssh command
311
+ `set the clipboard to "${esc(cmd)}"`,
312
+ 'delay 0.1',
313
+ 'tell application "System Events"',
314
+ ' keystroke "v" using {command down}',
315
+ ' delay 0.15',
316
+ ' key code 36',
317
+ 'end tell',
318
+ // Wait for the OTP prompt before pasting the code.
319
+ 'set promptDeadline to (current date) + 20',
320
+ 'set sawPrompt to false',
321
+ 'repeat',
322
+ ' if (current date) > promptDeadline then exit repeat',
323
+ ' delay 0.4',
324
+ ' try',
325
+ ' tell application "Terminal"',
326
+ ' set txt to contents of front window as text',
327
+ ' end tell',
328
+ // Only look at content added since we started — strip the baseline
329
+ // prefix. Defensive guard: if Terminal scrolls and the baseline no
330
+ // longer matches a prefix (rare), fall back to last-1000 chars.
331
+ ' set fresh to txt',
332
+ ' if (count of txt) > baseLen then',
333
+ ' try',
334
+ ' set fresh to text (baseLen + 1) thru -1 of txt',
335
+ ' end try',
336
+ ' else if (count of txt) > 1000 then',
337
+ ' set fresh to text -1000 thru -1 of txt',
338
+ ' end if',
339
+ ` if fresh contains "OTP" or fresh contains "Token" or fresh contains "verification" or fresh contains "code:" then`,
340
+ ' set sawPrompt to true',
341
+ ' exit repeat',
342
+ ' end if',
343
+ ' end try',
344
+ 'end repeat',
345
+ 'if not sawPrompt then',
346
+ ` set the clipboard to savedClip`,
347
+ ' return "noprompt|no OTP prompt detected after 20s — VPN slow or ssh failed before prompting"',
348
+ 'end if',
349
+ 'delay 0.3',
350
+ // Paste the OTP
351
+ `set the clipboard to "${esc(cleaned)}"`,
352
+ 'delay 0.1',
353
+ 'tell application "System Events"',
354
+ ' keystroke "v" using {command down}',
355
+ ' delay 0.15',
356
+ ' key code 36',
357
+ 'end tell',
358
+ 'set t0 to current date',
359
+ 'set verdict to "timeout"',
360
+ 'set tail to ""',
361
+ 'repeat',
362
+ ' if ((current date) - t0) > 30 then exit repeat',
363
+ ' delay 0.4',
364
+ ' try',
365
+ ' tell application "Terminal"',
366
+ ' set txt to contents of front window as text',
367
+ ' end tell',
368
+ ` if txt contains "${markOk}" then`,
369
+ ' set verdict to "ok"',
370
+ ' exit repeat',
371
+ ` else if txt contains "${markFail}" then`,
372
+ ' set verdict to "fail"',
373
+ ' if (length of txt) > 400 then',
374
+ ' set tail to text -400 thru -1 of txt',
375
+ ' else',
376
+ ' set tail to txt',
377
+ ' end if',
378
+ ' exit repeat',
379
+ ' end if',
380
+ ' on error errMsg',
381
+ ` set the clipboard to savedClip`,
382
+ ' return "error:" & errMsg',
383
+ ' end try',
384
+ 'end repeat',
385
+ // Restore user's clipboard.
386
+ 'set the clipboard to savedClip',
387
+ 'return verdict & "|" & tail',
388
+ ].join('\n');
389
+
390
+ const r = await runAppleScript(script, 45_000);
391
+ const out = (r.stdout || '').trim();
392
+ if (r.code !== 0) {
393
+ const err = (r.stderr || '').trim();
394
+ if (/not authorized.*assistive|accessibility|tcc/i.test(err)) {
395
+ return {
396
+ ok: false,
397
+ host,
398
+ error:
399
+ 'Forge needs Accessibility permission to type into Terminal. ' +
400
+ 'Open System Settings → Privacy & Security → Accessibility, ' +
401
+ 'add the process running Forge (node) and turn it on, then retry.',
402
+ };
403
+ }
404
+ if (/-1719|no window|application isn.t running/i.test(err)) {
405
+ return {
406
+ ok: false,
407
+ host,
408
+ error: 'Terminal.app has no open window. Open a Terminal window (Cmd+N) and retry.',
409
+ };
410
+ }
411
+ return { ok: false, host, error: `osascript failed (code ${r.code}): ${err || out}` };
412
+ }
413
+ // out format: "ok|" or "fail|<tail>" or "timeout|" or "error:<msg>"
414
+ const [verdict, ...rest] = out.split('|');
415
+ const tail = rest.join('|');
416
+ if (verdict === 'ok') {
417
+ return {
418
+ ok: true,
419
+ verifiedAt: new Date().toISOString(),
420
+ host,
421
+ transcript: tail,
422
+ };
423
+ }
424
+ if (verdict === 'fail') {
425
+ return { ok: false, host, error: 'GitLab rejected the OTP', transcript: tail };
426
+ }
427
+ if (verdict.startsWith('error:')) {
428
+ return { ok: false, host, error: verdict.slice('error:'.length) };
429
+ }
430
+ if (verdict === 'noprompt') {
431
+ return {
432
+ ok: false,
433
+ host,
434
+ error: tail || 'No OTP prompt appeared in your Terminal — ssh may have failed to connect',
435
+ };
436
+ }
437
+ return {
438
+ ok: false,
439
+ host,
440
+ error: 'Timed out reading Terminal output — did the command actually run?',
441
+ transcript: tail,
442
+ };
443
+ }
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Unified IdP login — one form (user + pass + OTP) in Forge logs the
3
+ * user into every SAML SP that shares the IdP. Backend is just a thin
4
+ * wrapper over the extension's `auth.idpLogin` RPC.
5
+ *
6
+ * Tenants may declare MULTIPLE IdPs in their wizard template — e.g.
7
+ * Fortinet has FAC (fac.corp.fortinet.com) covering mantis/pmdb/tp
8
+ * AND an EMEA SAML (sso.frval.fortinet-emea.com) covering scap. We
9
+ * loop through each entry sequentially, each gets a fresh trigger SP
10
+ * tab, each independently reports ok/error. post_login_terminal is
11
+ * per-entry (e.g. GitLab SSH 2FA hangs off the FAC entry).
12
+ *
13
+ * Assumption: every IdP accepts the same user/pass/OTP. True for
14
+ * environments with a shared TOTP secret (FortiToken). If that ever
15
+ * breaks, swap in per-entry credential lookup here.
16
+ */
17
+
18
+ import { bridgeRpc } from '../chat/bridge-client';
19
+ import { getInstalledConnector, listInstalledConnectors } from '../connectors/registry';
20
+ import { resolveWizardTemplate } from '../connectors/wizard-template';
21
+ import { runTerminalKeystroke, type TerminalSpec, type TerminalResult } from './terminal-keystroke';
22
+ import { expandSettingsTokens } from '../plugins/templates';
23
+ import { loadSettings } from '../settings';
24
+
25
+ export interface IdpLoginRequest {
26
+ username: string;
27
+ password: string;
28
+ otp: string;
29
+ }
30
+
31
+ /** Per-IdP outcome — populated for each entry in the template. */
32
+ export interface IdpLoginEntry {
33
+ idp_host: string;
34
+ trigger_url?: string;
35
+ ok: boolean;
36
+ steps_completed?: number;
37
+ final_url?: string;
38
+ error?: string;
39
+ post_login_terminal?: TerminalResult[];
40
+ }
41
+
42
+ export interface IdpLoginResult {
43
+ /** True iff every IdP entry's login succeeded. */
44
+ ok: boolean;
45
+ /** Per-IdP results in template order. */
46
+ idp_logins: IdpLoginEntry[];
47
+ error?: string;
48
+ }
49
+
50
+ interface IdpTemplateBlock {
51
+ host?: string;
52
+ /** Optional alt hostnames the IdP also uses (e.g. regional SAML servers). */
53
+ alt_hosts?: string[];
54
+ saml_sps?: string[];
55
+ /** Explicit trigger URL — wins over saml_sps lookup. Use when the SP
56
+ * connector has no base_url in its config (only host_match), so the
57
+ * default pickTriggerUrl can't find a URL to open. */
58
+ trigger_url?: string;
59
+ /** Override the username sent to this IdP. Supports template tokens
60
+ * like {user.email} / {user.login}. Use when the IdP expects a
61
+ * different identifier than other entries (e.g. Microsoft federated
62
+ * login needs full email at a tenant-specific domain). */
63
+ username?: string;
64
+ /** Per-IdP CSS selector overrides (Microsoft, Okta, Auth0 — non-FAC
65
+ * shapes). Sent as-is to the extension; missing fields fall back to
66
+ * FAC defaults. See extension's IdpSelectors type. */
67
+ selectors?: {
68
+ form?: string;
69
+ username?: string;
70
+ password?: string;
71
+ token_code?: string;
72
+ submit?: string;
73
+ };
74
+ /** Per-entry timeout override (seconds). Teams / Microsoft login is
75
+ * slow due to multiple cross-domain redirects. */
76
+ timeout_seconds?: number;
77
+ post_login_terminal?: TerminalSpec[];
78
+ }
79
+
80
+ /**
81
+ * Walk every installed enterprise source's wizard template until we
82
+ * find one with an `_idp` block. The block may be a single object
83
+ * (legacy) or an array of objects (multi-IdP). Returns normalized
84
+ * array of valid blocks.
85
+ */
86
+ function readIdpBlocks(): IdpTemplateBlock[] {
87
+ const tryRead = (sourceId?: string): IdpTemplateBlock[] | null => {
88
+ const t = resolveWizardTemplate(sourceId);
89
+ const raw = (t?.template as { _idp?: IdpTemplateBlock | IdpTemplateBlock[] } | undefined)?._idp;
90
+ if (!raw) return null;
91
+ const arr = Array.isArray(raw) ? raw : [raw];
92
+ const valid = arr.filter((b) => b?.host && b.saml_sps?.length);
93
+ return valid.length > 0 ? valid : null;
94
+ };
95
+ const candidates = new Set<string>();
96
+ for (const c of listInstalledConnectors()) {
97
+ if (c.installed_source_id) candidates.add(c.installed_source_id);
98
+ }
99
+ for (const sourceId of candidates) {
100
+ const blocks = tryRead(sourceId);
101
+ if (blocks) return blocks;
102
+ }
103
+ return tryRead() || [];
104
+ }
105
+
106
+ /** First installed SP from saml_sps wins as trigger. */
107
+ function pickTriggerUrl(saml_sps: string[]): string | null {
108
+ for (const id of saml_sps) {
109
+ const c = getInstalledConnector(id);
110
+ const url = (c?.config as { base_url?: string } | undefined)?.base_url;
111
+ if (url) return url.replace(/\/+$/, '/');
112
+ }
113
+ return null;
114
+ }
115
+
116
+ async function runOneIdp(block: IdpTemplateBlock, req: IdpLoginRequest): Promise<IdpLoginEntry> {
117
+ const idp_host = block.host!;
118
+ const trigger_url = block.trigger_url || pickTriggerUrl(block.saml_sps!);
119
+ if (!trigger_url) {
120
+ return {
121
+ idp_host,
122
+ ok: false,
123
+ error: `No trigger URL — SPs (${block.saml_sps!.join(', ')}) have no base_url, and no explicit "trigger_url" in _idp block.`,
124
+ };
125
+ }
126
+ // Template-provided username override (e.g. "{user.login}@fortinet-us.com"
127
+ // for Microsoft federated login). Falls back to caller's input.
128
+ let username = req.username;
129
+ if (block.username && block.username.trim()) {
130
+ try {
131
+ const settings = loadSettings() as unknown as Record<string, unknown>;
132
+ username = expandSettingsTokens(block.username, settings);
133
+ } catch {
134
+ username = block.username;
135
+ }
136
+ }
137
+ // Teams / Microsoft login crosses 3+ domains and can take >60s when
138
+ // the network is loaded; let templates extend the budget per entry.
139
+ const idpTimeout = block.timeout_seconds && block.timeout_seconds > 0 ? block.timeout_seconds : 60;
140
+ try {
141
+ const raw = await bridgeRpc(
142
+ 'auth.idpLogin',
143
+ {
144
+ trigger_url,
145
+ idp_host,
146
+ idp_alt_hosts: block.alt_hosts || [],
147
+ selectors: block.selectors || undefined,
148
+ username,
149
+ password: req.password,
150
+ otp: req.otp,
151
+ timeout_seconds: idpTimeout,
152
+ },
153
+ // bridgeRpc timeout = idp timeout + 15s grace for the round-trip.
154
+ (idpTimeout + 15) * 1000,
155
+ );
156
+ const r = (raw || {}) as { ok?: boolean; final_url?: string; steps_completed?: number; error?: string };
157
+ if (!r.ok) {
158
+ return {
159
+ idp_host,
160
+ trigger_url,
161
+ ok: false,
162
+ final_url: r.final_url,
163
+ steps_completed: r.steps_completed,
164
+ error: r.error || 'idp-login failed (no error from extension)',
165
+ };
166
+ }
167
+
168
+ const postResults: TerminalResult[] = [];
169
+ if (block.post_login_terminal && block.post_login_terminal.length > 0) {
170
+ for (const spec of block.post_login_terminal) {
171
+ postResults.push(await runTerminalKeystroke(spec, req.otp));
172
+ }
173
+ }
174
+ return {
175
+ idp_host,
176
+ trigger_url,
177
+ ok: true,
178
+ final_url: r.final_url,
179
+ steps_completed: r.steps_completed,
180
+ post_login_terminal: postResults.length > 0 ? postResults : undefined,
181
+ };
182
+ } catch (e) {
183
+ const msg = e instanceof Error ? e.message : String(e);
184
+ let friendly = msg;
185
+ if (msg.includes('unknown method') && msg.includes('auth.idpLogin')) {
186
+ friendly =
187
+ 'Forge browser extension is out of date — it does not know auth.idpLogin yet. ' +
188
+ 'Rebuild the extension (pnpm build), reload it in chrome://extensions, then try again.';
189
+ } else if (msg.includes('no paired extensions') || msg.includes('no connected') || msg.includes('No extension connected')) {
190
+ friendly = 'Forge browser extension is not connected. Open the extension popup → Settings → Login.';
191
+ }
192
+ return { idp_host, trigger_url, ok: false, error: friendly };
193
+ }
194
+ }
195
+
196
+ export async function performIdpLogin(req: IdpLoginRequest): Promise<IdpLoginResult> {
197
+ if (!req.username || !req.password || !req.otp) {
198
+ return { ok: false, idp_logins: [], error: 'username, password, and otp are all required' };
199
+ }
200
+ const blocks = readIdpBlocks();
201
+ if (blocks.length === 0) {
202
+ return {
203
+ ok: false,
204
+ idp_logins: [],
205
+ error:
206
+ 'No _idp block in the active wizard template — declare { "_idp": [ { "host": "...", "saml_sps": [...] } ] } to enable unified login.',
207
+ };
208
+ }
209
+
210
+ // Sequential — each opens a tab and we don't want to race them.
211
+ const results: IdpLoginEntry[] = [];
212
+ for (const block of blocks) {
213
+ results.push(await runOneIdp(block, req));
214
+ }
215
+ const allOk = results.every((r) => r.ok);
216
+ return {
217
+ ok: allOk,
218
+ idp_logins: results,
219
+ error: allOk ? undefined : results.find((r) => !r.ok)?.error,
220
+ };
221
+ }