@aion0/forge 0.10.45 → 0.10.46
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/RELEASE_NOTES.md +41 -6
- package/app/api/auth/gitlab-2fa/route.ts +98 -0
- package/app/api/auth/idp-credentials/route.ts +41 -0
- package/app/api/auth/idp-login/route.ts +22 -0
- package/app/api/scratch/[...path]/route.ts +78 -0
- package/bin/forge-server.mjs +30 -3
- package/components/Dashboard.tsx +43 -50
- package/components/EnterpriseBadge.tsx +390 -1
- package/install.sh +6 -0
- package/lib/auth/gitlab-2fa.ts +443 -0
- package/lib/auth/idp-login.ts +221 -0
- package/lib/auth/terminal-keystroke.ts +245 -0
- package/lib/chat/link-patterns.ts +11 -0
- package/lib/chat/session-store.ts +5 -2
- package/lib/chat/tool-dispatcher.ts +90 -18
- package/lib/connectors/migration.ts +55 -0
- package/lib/connectors/registry.ts +20 -2
- package/lib/crypto.ts +1 -1
- package/lib/init.ts +9 -1
- package/lib/scratch-cleanup.ts +64 -0
- package/lib/settings.ts +20 -0
- package/next-env.d.ts +1 -1
- package/package.json +1 -1
|
@@ -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
|
+
}
|