scenescout 3.14.1 → 3.15.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/CHANGELOG.md +19 -0
- package/README.md +26 -3
- package/dist/check-run.js +22 -11
- package/dist/ci-run.js +113 -17
- package/dist/cli.js +119 -6
- package/dist/commands.js +39 -2
- package/dist/engine/browser.js +546 -133
- package/dist/engine/check.js +77 -30
- package/dist/engine/ci.js +97 -0
- package/dist/engine/claims.js +81 -15
- package/dist/engine/collector.js +307 -65
- package/dist/engine/dedup.js +329 -9
- package/dist/engine/design.js +81 -12
- package/dist/engine/forms.js +17 -0
- package/dist/engine/memory.js +180 -36
- package/dist/engine/oracles.js +126 -1
- package/dist/engine/provider.js +4 -3
- package/dist/engine/refresh.js +270 -22
- package/dist/engine/report.js +8 -1
- package/dist/engine/scripted-login.js +342 -62
- package/dist/first-run.js +577 -0
- package/dist/login-run.js +136 -35
- package/dist/mcp-server.js +75 -16
- package/package.json +1 -1
- package/skills/scenescout/SKILL.md +3 -3
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The rules of `scenescout login <url> --role <name> --script`: a sign-in
|
|
3
|
-
* with no person at the keyboard, for CI. The username, the password
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
3
|
+
* with no person at the keyboard, for CI. The username, the password (none
|
|
4
|
+
* for a passwordless sign-in) and the one-time code, from a TOTP secret or a
|
|
5
|
+
* fixed code a test environment accepts, come from the environment; the
|
|
6
|
+
* browser fills the identity provider's form with them and the session is
|
|
7
|
+
* saved as the role's profile exactly as the manual login saves it.
|
|
7
8
|
*
|
|
8
9
|
* Everything here is Playwright-free so it can be table-tested: which
|
|
9
10
|
* environment variables and flags configure a run, the RFC 6238 one-time
|
|
10
|
-
* code, which input on a page is the username, the password or the code
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* login-run.ts.
|
|
11
|
+
* code, which input on a page is the username, the password or the code
|
|
12
|
+
* (one field, or one box per character), which button moves the form on,
|
|
13
|
+
* what counts as signed in or refused, and the redaction every line of
|
|
14
|
+
* output goes through. The browser half is in login-run.ts.
|
|
14
15
|
*
|
|
15
16
|
* A credential value is never printed, logged or written. Each value, and
|
|
16
17
|
* each of its URL-encoded forms, is replaced before any line leaves the
|
|
@@ -24,6 +25,7 @@ export const LOGIN_ENV = {
|
|
|
24
25
|
username: "SCENESCOUT_LOGIN_USERNAME",
|
|
25
26
|
password: "SCENESCOUT_LOGIN_PASSWORD",
|
|
26
27
|
totpSecret: "SCENESCOUT_LOGIN_TOTP_SECRET",
|
|
28
|
+
otpCode: "SCENESCOUT_LOGIN_OTP_CODE",
|
|
27
29
|
usernameSelector: "SCENESCOUT_LOGIN_USERNAME_SELECTOR",
|
|
28
30
|
passwordSelector: "SCENESCOUT_LOGIN_PASSWORD_SELECTOR",
|
|
29
31
|
otpSelector: "SCENESCOUT_LOGIN_OTP_SELECTOR",
|
|
@@ -53,20 +55,43 @@ export const MAX_TIMEOUT_S = 600;
|
|
|
53
55
|
export function readScriptedLogin(flags, env) {
|
|
54
56
|
const errors = [];
|
|
55
57
|
const username = env[LOGIN_ENV.username] ?? "";
|
|
56
|
-
const
|
|
58
|
+
const rawPassword = env[LOGIN_ENV.password];
|
|
57
59
|
if (username.trim() === "")
|
|
58
60
|
errors.push(`${LOGIN_ENV.username} is not set: the test user's username or email`);
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const
|
|
63
|
-
|
|
61
|
+
const rawSecret = env[LOGIN_ENV.totpSecret] ?? "";
|
|
62
|
+
const rawCode = env[LOGIN_ENV.otpCode] ?? "";
|
|
63
|
+
const hasSecret = rawSecret.trim() !== "";
|
|
64
|
+
const hasCode = rawCode.trim() !== "";
|
|
65
|
+
const codeGiven = hasSecret || hasCode;
|
|
66
|
+
let code;
|
|
67
|
+
if (hasSecret && hasCode) {
|
|
68
|
+
errors.push(`${LOGIN_ENV.otpCode} and ${LOGIN_ENV.totpSecret} are both set: set the fixed code or the secret that generates codes, not both`);
|
|
69
|
+
}
|
|
70
|
+
else if (hasSecret) {
|
|
64
71
|
const parsed = parseTotpSecret(rawSecret);
|
|
65
72
|
if (parsed.ok)
|
|
66
|
-
|
|
73
|
+
code = { kind: "totp", params: parsed.params };
|
|
67
74
|
else
|
|
68
75
|
errors.push(`${LOGIN_ENV.totpSecret} ${parsed.error}`);
|
|
69
76
|
}
|
|
77
|
+
else if (hasCode) {
|
|
78
|
+
const parsed = parseOtpCode(rawCode);
|
|
79
|
+
if (parsed.ok)
|
|
80
|
+
code = { kind: "fixed", code: parsed.code };
|
|
81
|
+
else
|
|
82
|
+
errors.push(`${LOGIN_ENV.otpCode} ${parsed.error}`);
|
|
83
|
+
}
|
|
84
|
+
// Passwordless means the password is left unset. Set but empty is a secret that resolved to nothing (a CI secret that does not
|
|
85
|
+
// exist reads as ""), refused here rather than found out when the form asks for it.
|
|
86
|
+
if (rawPassword === "") {
|
|
87
|
+
errors.push(`${LOGIN_ENV.password} is set but empty: give the test user's password, or leave it unset for a passwordless sign-in`);
|
|
88
|
+
}
|
|
89
|
+
else if (rawPassword === undefined && !codeGiven) {
|
|
90
|
+
// A code variable that is set but blank is most likely a CI secret that does not exist: say so rather than "set it".
|
|
91
|
+
const blank = [LOGIN_ENV.otpCode, LOGIN_ENV.totpSecret].filter((name) => env[name] !== undefined);
|
|
92
|
+
errors.push(`${LOGIN_ENV.password} is not set: the test user's password (for a passwordless sign-in that asks only for a one-time code, set ${LOGIN_ENV.otpCode} or ${LOGIN_ENV.totpSecret} instead)` +
|
|
93
|
+
(blank.length > 0 ? `; ${blank.join(" and ")} ${blank.length > 1 ? "are" : "is"} set but empty` : ""));
|
|
94
|
+
}
|
|
70
95
|
const pick = (flag, name) => {
|
|
71
96
|
const v = flags.get(flag) ?? env[name];
|
|
72
97
|
return v !== undefined && v.trim() !== "" ? v.trim() : undefined;
|
|
@@ -84,8 +109,11 @@ export function readScriptedLogin(flags, env) {
|
|
|
84
109
|
selectors.otp = o;
|
|
85
110
|
if (s)
|
|
86
111
|
selectors.submit = s;
|
|
87
|
-
if (selectors.otp && !
|
|
88
|
-
errors.push(`an OTP selector is set but ${LOGIN_ENV.totpSecret} is
|
|
112
|
+
if (selectors.otp && !codeGiven) {
|
|
113
|
+
errors.push(`an OTP selector is set but neither ${LOGIN_ENV.otpCode} nor ${LOGIN_ENV.totpSecret} is: there is no code to type into it`);
|
|
114
|
+
}
|
|
115
|
+
if (selectors.password && rawPassword === undefined)
|
|
116
|
+
errors.push(`a password selector is set but ${LOGIN_ENV.password} is not: there is no password to type into it`);
|
|
89
117
|
const success = {};
|
|
90
118
|
const successUrl = pick("success-url", LOGIN_ENV.successUrl);
|
|
91
119
|
const successSelector = pick("success-selector", LOGIN_ENV.successSelector);
|
|
@@ -104,7 +132,35 @@ export function readScriptedLogin(flags, env) {
|
|
|
104
132
|
}
|
|
105
133
|
if (errors.length > 0)
|
|
106
134
|
return { ok: false, errors };
|
|
107
|
-
return {
|
|
135
|
+
return {
|
|
136
|
+
ok: true,
|
|
137
|
+
config: {
|
|
138
|
+
username,
|
|
139
|
+
...(rawPassword !== undefined ? { password: rawPassword } : {}),
|
|
140
|
+
...(code ? { code } : {}),
|
|
141
|
+
selectors,
|
|
142
|
+
success,
|
|
143
|
+
timeoutMs: timeoutS * 1000,
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
/** The shortest and longest fixed one-time code accepted. */
|
|
148
|
+
const MIN_OTP_CODE = 4;
|
|
149
|
+
const MAX_OTP_CODE = 12;
|
|
150
|
+
/**
|
|
151
|
+
* Read a fixed one-time code: letters and digits only, as the code field
|
|
152
|
+
* takes it, with the whitespace a pasted secret often carries trimmed. The
|
|
153
|
+
* error never quotes the value.
|
|
154
|
+
*/
|
|
155
|
+
export function parseOtpCode(raw) {
|
|
156
|
+
const code = raw.trim();
|
|
157
|
+
if (!new RegExp(`^[A-Za-z0-9]{${MIN_OTP_CODE},${MAX_OTP_CODE}}$`).test(code)) {
|
|
158
|
+
return {
|
|
159
|
+
ok: false,
|
|
160
|
+
error: `must be ${MIN_OTP_CODE} to ${MAX_OTP_CODE} letters or digits, with no spaces or dashes: the code exactly as the code field takes it`,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
return { ok: true, code };
|
|
108
164
|
}
|
|
109
165
|
// ── redaction ───────────────────────────────────────────────────────────────
|
|
110
166
|
export const REDACTED = "[redacted]";
|
|
@@ -112,6 +168,7 @@ export const REDACTED = "[redacted]";
|
|
|
112
168
|
* Every form a credential could take in text: as typed, URL-encoded (a query
|
|
113
169
|
* string, a form body), and with `+` for spaces. The TOTP secret as given,
|
|
114
170
|
* and its base32 without spaces or padding, so a reformatted echo is caught.
|
|
171
|
+
* A fixed one-time code is a credential like the password.
|
|
115
172
|
*/
|
|
116
173
|
export function credentialValues(config) {
|
|
117
174
|
const out = new Set();
|
|
@@ -128,6 +185,7 @@ export function credentialValues(config) {
|
|
|
128
185
|
};
|
|
129
186
|
add(config.username);
|
|
130
187
|
add(config.password);
|
|
188
|
+
add(config.otpCode);
|
|
131
189
|
if (config.totpSecretRaw) {
|
|
132
190
|
add(config.totpSecretRaw);
|
|
133
191
|
add(config.totpSecretRaw.replace(/[\s=-]/g, "").toUpperCase());
|
|
@@ -137,7 +195,8 @@ export function credentialValues(config) {
|
|
|
137
195
|
/**
|
|
138
196
|
* Replace every credential value in a line, longest first so a value
|
|
139
197
|
* containing another is not left half-printed. Case-sensitive except for the
|
|
140
|
-
* username, which identity providers
|
|
198
|
+
* values given as `caseInsensitive`: the username, which identity providers
|
|
199
|
+
* commonly echo lower-cased, and a fixed one-time code.
|
|
141
200
|
*/
|
|
142
201
|
export function redactCredentials(text, values, caseInsensitive = []) {
|
|
143
202
|
let out = text;
|
|
@@ -149,11 +208,17 @@ export function redactCredentials(text, values, caseInsensitive = []) {
|
|
|
149
208
|
}
|
|
150
209
|
return out;
|
|
151
210
|
}
|
|
152
|
-
/** The redactor for one scripted sign-in: every form of each credential, the username also without regard to case. */
|
|
211
|
+
/** The redactor for one scripted sign-in: every form of each credential, the username and a fixed code also without regard to case. */
|
|
153
212
|
export function credentialRedactor(config, totpSecretRaw) {
|
|
154
|
-
const
|
|
213
|
+
const otpCode = config.code?.kind === "fixed" ? config.code.code : undefined;
|
|
214
|
+
const values = credentialValues({
|
|
215
|
+
username: config.username,
|
|
216
|
+
...(config.password !== undefined ? { password: config.password } : {}),
|
|
217
|
+
...(otpCode ? { otpCode } : {}),
|
|
218
|
+
...(totpSecretRaw ? { totpSecretRaw } : {}),
|
|
219
|
+
});
|
|
155
220
|
const username = config.username.trim();
|
|
156
|
-
const anyCase = username ? [username, encodeURIComponent(username)] : [];
|
|
221
|
+
const anyCase = [...(username ? [username, encodeURIComponent(username)] : []), ...(otpCode ? [otpCode] : [])];
|
|
157
222
|
return {
|
|
158
223
|
redact: (text) => redactCredentials(text, values, anyCase),
|
|
159
224
|
add: (value) => {
|
|
@@ -241,17 +306,88 @@ export function secondsLeft(params, unixSeconds) {
|
|
|
241
306
|
}
|
|
242
307
|
/** With fewer seconds than this left, wait for the next code rather than type one that expires in transit. */
|
|
243
308
|
export const TOTP_MIN_SECONDS_LEFT = 3;
|
|
244
|
-
const OTP_WORDS = /\b(otp|one[\s_-]?time|totp|mfa|2fa|two[\s_-]?factor|verification[\s_-]?code
|
|
309
|
+
const OTP_WORDS = /\b(otp|one[\s_-]?time|totp|mfa|2fa|two[\s_-]?factor|verification([\s_-]?code)?|auth(entication|enticator)?[\s_-]?code|security[\s_-]?code|passcode|code)\b/i;
|
|
245
310
|
const USER_WORDS = /(user(name)?|e-?mail|login|account|identifier|\bid\b)/i;
|
|
246
311
|
const NOT_USER = /(search|query|coupon|promo)/i;
|
|
247
312
|
const TEXTLIKE = new Set(["text", "email", "tel", "number", ""]);
|
|
313
|
+
/** Words that make a field's "code" some other code: a postal code, a country code, a promotion. */
|
|
314
|
+
const NOT_CODE = /(zip|postal|post\s?code|country|promo|coupon)/i;
|
|
315
|
+
/** A numeric field these words describe is a number of another kind, not a one-time code. */
|
|
316
|
+
const OTHER_NUMBER = /(phone|mobile|card|amount|quantity|year)/i;
|
|
248
317
|
const describe = (f) => `${f.name} ${f.id} ${f.label}`.replace(/[_-]+/g, " ");
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
318
|
+
const autocompletes = (f, token) => f.autocomplete.split(/\s+/).includes(token);
|
|
319
|
+
/**
|
|
320
|
+
* Why a field is a single one-time-code field, if it is. "named": its
|
|
321
|
+
* autocomplete is one-time-code, or else the words of its name, id and label
|
|
322
|
+
* say code, OTP or verification (a code field masked as a password field
|
|
323
|
+
* included, unless its words say password or passcode). "shape": only its
|
|
324
|
+
* shape says so, a numeric field (inputmode="numeric") 4 to 12 characters
|
|
325
|
+
* long with no autocomplete purpose of its own, whose words do not say phone,
|
|
326
|
+
* card, postal code or username. Short of autocomplete="one-time-code", a
|
|
327
|
+
* field that is the username by its type or autocomplete is never the code,
|
|
328
|
+
* whatever its label says ("Email: we will send you a code").
|
|
329
|
+
*/
|
|
330
|
+
function otpEvidence(f) {
|
|
331
|
+
if (f.tag !== "input")
|
|
332
|
+
return null;
|
|
333
|
+
if (autocompletes(f, "one-time-code"))
|
|
334
|
+
return TEXTLIKE.has(f.type) || f.type === "password" ? "named" : null;
|
|
335
|
+
if (f.type === "email" || autocompletes(f, "username") || autocompletes(f, "email"))
|
|
336
|
+
return null;
|
|
337
|
+
const words = describe(f);
|
|
338
|
+
if (f.type === "password")
|
|
339
|
+
return OTP_WORDS.test(words) && !/pass(word|code)/i.test(words) ? "named" : null;
|
|
340
|
+
if (!TEXTLIKE.has(f.type) || NOT_CODE.test(words))
|
|
341
|
+
return null;
|
|
342
|
+
if (OTP_WORDS.test(words))
|
|
343
|
+
return "named";
|
|
344
|
+
const unnamedPurpose = f.autocomplete === "" || f.autocomplete === "off";
|
|
345
|
+
const sized = f.inputmode === "numeric" && f.maxLength >= MIN_OTP_CODE && f.maxLength <= MAX_OTP_CODE;
|
|
346
|
+
return sized && unnamedPurpose && !OTHER_NUMBER.test(words) && !USER_WORDS.test(words) ? "shape" : null;
|
|
347
|
+
}
|
|
348
|
+
/** A code field known only by its shape, not chosen by a selector: the sign-in's only in the steps signInFields allows. */
|
|
349
|
+
function shapeOnlyOtp(f) {
|
|
350
|
+
return f.forced !== "otp" && otpEvidence(f) === "shape";
|
|
351
|
+
}
|
|
352
|
+
/** The fewest and most boxes a one-time code split one character per box is laid out in. */
|
|
353
|
+
const MIN_CODE_BOXES = 4;
|
|
354
|
+
const MAX_CODE_BOXES = 10;
|
|
355
|
+
/**
|
|
356
|
+
* A one-time code split into one box per character: a run of 4 to 10 text
|
|
357
|
+
* inputs that each take one character (maxlength 1), side by side with no
|
|
358
|
+
* other control between them, the first of them enabled. Later boxes may be
|
|
359
|
+
* disabled until the earlier ones are filled. A split date, phone or card
|
|
360
|
+
* number has boxes of 2 to 4 characters, so it is never taken for one.
|
|
361
|
+
*/
|
|
362
|
+
export function codeBoxes(fields) {
|
|
363
|
+
const isBox = (f) => f.tag === "input" && f.maxLength === 1 && ((TEXTLIKE.has(f.type) && f.type !== "email") || f.type === "password");
|
|
364
|
+
const runs = [];
|
|
365
|
+
for (const f of fields.filter(isBox).sort((a, b) => a.index - b.index)) {
|
|
366
|
+
const run = runs[runs.length - 1];
|
|
367
|
+
if (run && run[run.length - 1].index === f.index - 1)
|
|
368
|
+
run.push(f);
|
|
369
|
+
else
|
|
370
|
+
runs.push([f]);
|
|
371
|
+
}
|
|
372
|
+
return runs.find((r) => r.length >= MIN_CODE_BOXES && r.length <= MAX_CODE_BOXES && !r[0].disabled) ?? null;
|
|
373
|
+
}
|
|
374
|
+
/** The boxes of a split code that `otp` (the field chosen for the code) is the first of, or null when it is a single field. */
|
|
375
|
+
export function otpBoxes(fields, otp) {
|
|
376
|
+
const boxes = codeBoxes(fields);
|
|
377
|
+
return boxes && boxes[0].index === otp.index ? boxes : null;
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* One character per box, or why the code cannot fill these boxes: the number
|
|
381
|
+
* of boxes and where the code comes from, never the code itself.
|
|
382
|
+
*/
|
|
383
|
+
export function splitCode(code, boxes, source) {
|
|
384
|
+
const chars = [...code];
|
|
385
|
+
if (chars.length === boxes)
|
|
386
|
+
return { ok: true, chars };
|
|
387
|
+
const from = source === "fixed"
|
|
388
|
+
? `${LOGIN_ENV.otpCode} has a different number of characters`
|
|
389
|
+
: `the codes ${LOGIN_ENV.totpSecret} generates have ${chars.length} digits (an otpauth:// URI's digits parameter sets how many)`;
|
|
390
|
+
return { ok: false, error: `the page splits the code into ${boxes} boxes, one character each, and ${from}` };
|
|
255
391
|
}
|
|
256
392
|
function isUsername(f) {
|
|
257
393
|
if (f.tag !== "input" || !TEXTLIKE.has(f.type) || f.type === "number")
|
|
@@ -278,7 +414,10 @@ function usernameRank(f) {
|
|
|
278
414
|
* control that fits, by autocomplete, type, then name/id/label words. A
|
|
279
415
|
* password field for a NEW password (autocomplete="new-password") is chosen
|
|
280
416
|
* only when it is the only one: a sign-up form's confirm field is not the
|
|
281
|
-
* sign-in password.
|
|
417
|
+
* sign-in password. A code field named as one wins over one known only by its
|
|
418
|
+
* shape. A code split one character per box is chosen by its first box
|
|
419
|
+
* (otpBoxes gives the rest), and none of its boxes is taken for the username
|
|
420
|
+
* or the password.
|
|
282
421
|
*/
|
|
283
422
|
export function chooseFields(fields) {
|
|
284
423
|
const usable = fields.filter((f) => !f.disabled && f.tag !== "button");
|
|
@@ -288,13 +427,20 @@ export function chooseFields(fields) {
|
|
|
288
427
|
if (forced)
|
|
289
428
|
out[kind] = forced;
|
|
290
429
|
}
|
|
430
|
+
const boxes = codeBoxes(fields);
|
|
431
|
+
const inBoxes = new Set(boxes?.map((b) => b.index));
|
|
432
|
+
// A selector naming one box of a split code names the code.
|
|
433
|
+
if (boxes && (!out.otp || inBoxes.has(out.otp.index)))
|
|
434
|
+
out.otp = boxes[0];
|
|
291
435
|
if (!out.password) {
|
|
292
|
-
const pw = usable.filter((f) => f.tag === "input" && f.type === "password");
|
|
436
|
+
const pw = usable.filter((f) => f.tag === "input" && f.type === "password" && !inBoxes.has(f.index) && otpEvidence(f) === null);
|
|
293
437
|
out.password = pw.find((f) => f.autocomplete.includes("current-password")) ?? pw.find((f) => !f.autocomplete.includes("new-password")) ?? pw[0];
|
|
294
438
|
}
|
|
295
|
-
const taken = new Set([out.username?.index, out.password?.index, out.otp?.index].filter((i) => i !== undefined));
|
|
296
|
-
if (!out.otp)
|
|
297
|
-
|
|
439
|
+
const taken = new Set([...inBoxes, out.username?.index, out.password?.index, out.otp?.index].filter((i) => i !== undefined));
|
|
440
|
+
if (!out.otp) {
|
|
441
|
+
const free = usable.filter((f) => !taken.has(f.index));
|
|
442
|
+
out.otp = free.find((f) => otpEvidence(f) === "named") ?? free.find((f) => otpEvidence(f) === "shape");
|
|
443
|
+
}
|
|
298
444
|
if (out.otp)
|
|
299
445
|
taken.add(out.otp.index);
|
|
300
446
|
if (!out.username) {
|
|
@@ -306,23 +452,112 @@ export function chooseFields(fields) {
|
|
|
306
452
|
delete out[k];
|
|
307
453
|
return out;
|
|
308
454
|
}
|
|
455
|
+
/** Words on a button that signs in or moves the sign-in on. */
|
|
309
456
|
const GO_WORDS = /\b(sign[\s-]?in|log[\s-]?in|login|continue|next|verify|submit|confirm|proceed)\b/i;
|
|
310
|
-
/** Buttons that lead away from a password sign-in: another provider, another flow. */
|
|
311
|
-
const AWAY_WORDS = /\b(forgot|reset|sign[\s-]?up|register|create|continue with|provider|google|microsoft|github|apple|facebook|sso|single sign|passkey|magic link|cancel|back|resend|remember)\b/i;
|
|
312
457
|
/**
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
*
|
|
316
|
-
*
|
|
458
|
+
* Words on a button that sends a one-time code, or continues with an email, a
|
|
459
|
+
* phone or a code: the way on in a passwordless sign-in, and second to a
|
|
460
|
+
* button that signs in or verifies wherever both are on a page (a page asking
|
|
461
|
+
* for a code may also offer to text one).
|
|
462
|
+
*/
|
|
463
|
+
const SEND_WORDS = /\b(send|(get|request)( me)?( a| the| my)?( [\w-]+)? code|(e-?mail|text) me|continue with)\b/i;
|
|
464
|
+
/**
|
|
465
|
+
* Buttons that lead away from this sign-in: another provider, another flow,
|
|
466
|
+
* another address, or a new code in place of the one being typed. "Continue
|
|
467
|
+
* with" leads to another provider, except with an email, a phone or a code.
|
|
468
|
+
*/
|
|
469
|
+
const AWAY_WORDS = /\b(forgot|reset|sign[\s-]?up|register|create|continue with(?! (your |my |a |an |work |personal )?(e-?mail|phone|([\w-]+ )?code))|provider|google|microsoft|github|apple|facebook|sso|single sign|passkey|magic link|cancel|back|re-?send|remember|new code|another|(send|try)\b.*\bagain|different|change|edit|instead|didn['’]?t|did not|not you)\b/i;
|
|
470
|
+
/**
|
|
471
|
+
* Pick the button that moves the form on, enabled or not: a form often
|
|
472
|
+
* enables its button only once it is complete, and the caller waits for it.
|
|
473
|
+
* A configured selector wins. Otherwise, in this order, the first on the page
|
|
474
|
+
* of: a submit button whose text signs in or verifies (sign in, next,
|
|
475
|
+
* continue, verify), any button with such text, a submit button whose text
|
|
476
|
+
* sends a code or continues with an email, any button with such text, any
|
|
477
|
+
* other submit button; at the same rank an enabled button before a disabled
|
|
478
|
+
* one. A button that leads elsewhere (another provider, a reset, a new code,
|
|
479
|
+
* another address, or a password the run does not have) is never chosen.
|
|
480
|
+
* Null means press Enter in the field just filled, which submits a form with
|
|
481
|
+
* a single text field.
|
|
317
482
|
*/
|
|
318
|
-
export function chooseSubmit(fields) {
|
|
319
|
-
const buttons = fields.filter((f) =>
|
|
483
|
+
export function chooseSubmit(fields, opts = {}) {
|
|
484
|
+
const buttons = fields.filter((f) => f.tag === "button" || (f.tag === "input" && (f.type === "submit" || f.type === "button")));
|
|
320
485
|
const forced = buttons.find((f) => f.forced === "submit");
|
|
321
486
|
if (forced)
|
|
322
487
|
return forced;
|
|
323
488
|
const isSubmit = (f) => f.type === "submit" || (f.tag === "button" && f.type === "");
|
|
324
|
-
const
|
|
325
|
-
|
|
489
|
+
const rank = (f) => {
|
|
490
|
+
if (AWAY_WORDS.test(f.text) || (opts.passwordless && /\bpassword\b/i.test(f.text)))
|
|
491
|
+
return null;
|
|
492
|
+
const submit = isSubmit(f);
|
|
493
|
+
if (SEND_WORDS.test(f.text))
|
|
494
|
+
return submit ? 2 : 3;
|
|
495
|
+
if (GO_WORDS.test(f.text))
|
|
496
|
+
return submit ? 0 : 1;
|
|
497
|
+
return submit ? 4 : null;
|
|
498
|
+
};
|
|
499
|
+
let best = null;
|
|
500
|
+
let bestKey = Infinity;
|
|
501
|
+
for (const f of buttons) {
|
|
502
|
+
const r = rank(f);
|
|
503
|
+
const key = r === null ? Infinity : r * 2 + (f.disabled ? 1 : 0);
|
|
504
|
+
if (key < bestKey) {
|
|
505
|
+
best = f;
|
|
506
|
+
bestKey = key;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
return best;
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* Decide how to submit what a step typed, from the page as it is now; the
|
|
513
|
+
* caller reads the page again and asks again while the answer is wait.
|
|
514
|
+
*
|
|
515
|
+
* - moved: the page left (another URL, or gone mid-navigation), or no field
|
|
516
|
+
* of a kind just typed is on it any more, or it has been emptied. The page
|
|
517
|
+
* took the step itself (a code that submits itself once complete), so
|
|
518
|
+
* nothing is submitted again.
|
|
519
|
+
* - fill-more: the same page now asks for a sign-in field it did not show
|
|
520
|
+
* before the typing (a password field enabled once the email is valid).
|
|
521
|
+
* Fill that before submitting.
|
|
522
|
+
* - click: the button chooseSubmit picks, enabled. After the username alone
|
|
523
|
+
* in a passwordless run, a disabled button that signs in is waiting for a
|
|
524
|
+
* code nobody has asked for yet: an enabled button that sends one is
|
|
525
|
+
* clicked instead.
|
|
526
|
+
* - wait: that button is disabled (the page is checking what was typed, or
|
|
527
|
+
* enables it only once the form is complete), or there is no button and
|
|
528
|
+
* the field just typed into is disabled.
|
|
529
|
+
* - enter: no button, so Enter in the field just typed into.
|
|
530
|
+
*
|
|
531
|
+
* A typed field is looked for again by its kind, disabled fields included,
|
|
532
|
+
* not by its label, which can change as it is typed into: a page that
|
|
533
|
+
* disables its code boxes once they are full, waiting for a click, has not
|
|
534
|
+
* moved on.
|
|
535
|
+
*/
|
|
536
|
+
export function afterTyping(typed, page, shownBefore, opts = {}) {
|
|
537
|
+
if (page.left || typed.length === 0)
|
|
538
|
+
return { kind: "moved" };
|
|
539
|
+
const stillThere = chooseFields(page.fields.map((f) => ({ ...f, disabled: false })));
|
|
540
|
+
if (typed.some((k) => !stillThere[k]?.filled))
|
|
541
|
+
return { kind: "moved" };
|
|
542
|
+
const chosen = chooseFields(page.fields);
|
|
543
|
+
const asksMore = ["username", "password", "otp"].some((k) => {
|
|
544
|
+
const f = chosen[k];
|
|
545
|
+
if (!f || f.filled || typed.includes(k) || shownBefore.has(k))
|
|
546
|
+
return false;
|
|
547
|
+
return k !== "otp" || !shapeOnlyOtp(f);
|
|
548
|
+
});
|
|
549
|
+
if (asksMore)
|
|
550
|
+
return { kind: "fill-more" };
|
|
551
|
+
const button = chooseSubmit(page.fields, opts);
|
|
552
|
+
if (button?.disabled && opts.passwordless && typed.length === 1 && typed[0] === "username") {
|
|
553
|
+
const send = chooseSubmit(page.fields.filter((f) => !f.disabled), opts);
|
|
554
|
+
if (send && SEND_WORDS.test(send.text))
|
|
555
|
+
return { kind: "click", button: send };
|
|
556
|
+
}
|
|
557
|
+
if (button)
|
|
558
|
+
return button.disabled ? { kind: "wait", button } : { kind: "click", button };
|
|
559
|
+
const field = chosen[typed[typed.length - 1]];
|
|
560
|
+
return field ? { kind: "enter", field } : { kind: "wait", button: null };
|
|
326
561
|
}
|
|
327
562
|
/** What makes a field the same field when a form comes back: its name, id, type, autocomplete and label, never its value. */
|
|
328
563
|
export function fieldIdentity(f) {
|
|
@@ -332,22 +567,24 @@ export function fieldIdentity(f) {
|
|
|
332
567
|
* Decide the next step from what is on the page now.
|
|
333
568
|
*
|
|
334
569
|
* - Signed in: the success URL or selector matched when one is configured;
|
|
335
|
-
* with neither,
|
|
570
|
+
* with neither, a password or a code went through and no sign-in field is
|
|
336
571
|
* left: no password or code field, and no username field unless it is a
|
|
337
572
|
* different field from the one the username went into (an app's own email
|
|
338
|
-
* field, say).
|
|
573
|
+
* field, say). After the username alone, a page with no field is still on
|
|
574
|
+
* its way to the next one (sending a code takes a moment), not signed in.
|
|
339
575
|
* - Refused: a field already submitted is back — the password field after the
|
|
340
576
|
* password went, the code field after the code went, or the same username
|
|
341
577
|
* field, empty, after the password went: the provider sent the form back.
|
|
342
|
-
* - A
|
|
343
|
-
* to set.
|
|
578
|
+
* - A password field with no password configured, or a code field with no
|
|
579
|
+
* code or TOTP secret configured, is stuck, with the variable to set.
|
|
344
580
|
* - Otherwise fill what is showing and has not been submitted, or wait.
|
|
345
581
|
*/
|
|
346
|
-
export function nextStep(
|
|
582
|
+
export function nextStep(onPage, progress, opts) {
|
|
347
583
|
// A success match before anything was submitted is the sign-in page itself matching (`/signin?next=/dashboard`).
|
|
348
584
|
if (opts.successMatched && progress.submits > 0)
|
|
349
585
|
return { kind: "done" };
|
|
350
586
|
const { submitted } = progress;
|
|
587
|
+
const chosen = signInFields(onPage, submitted, opts);
|
|
351
588
|
const sameUsername = chosen.username !== undefined && submitted.get("username") === fieldIdentity(chosen.username);
|
|
352
589
|
if (submitted.has("password") && chosen.password) {
|
|
353
590
|
return { kind: "refused", reason: "the password field came back after the password was submitted: the username or password was refused" };
|
|
@@ -356,23 +593,45 @@ export function nextStep(chosen, progress, opts) {
|
|
|
356
593
|
return { kind: "refused", reason: "the sign-in form came back after the password was submitted: the username or password was refused" };
|
|
357
594
|
}
|
|
358
595
|
if (submitted.has("otp") && chosen.otp) {
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
reason: "the one-time-code field came back after a code was submitted: the code was refused (check the TOTP secret and the runner's clock)",
|
|
362
|
-
};
|
|
596
|
+
const check = opts.code === "fixed" ? `check ${LOGIN_ENV.otpCode} is the code the app accepts` : "check the TOTP secret and the runner's clock";
|
|
597
|
+
return { kind: "refused", reason: `the one-time-code field came back after a code was submitted: the code was refused (${check})` };
|
|
363
598
|
}
|
|
364
599
|
const usernameIsSignIn = chosen.username !== undefined && (!submitted.has("username") || sameUsername);
|
|
365
600
|
if (!chosen.password && !chosen.otp && !usernameIsSignIn) {
|
|
366
|
-
|
|
601
|
+
const credentialSent = submitted.has("password") || submitted.has("otp");
|
|
602
|
+
if (progress.submits === 0 || opts.successConfigured || !credentialSent)
|
|
367
603
|
return { kind: "wait" };
|
|
368
604
|
return { kind: "done" };
|
|
369
605
|
}
|
|
370
|
-
if (chosen.
|
|
371
|
-
return { kind: "stuck", reason: `the page asks for a
|
|
606
|
+
if (chosen.password && !opts.hasPassword) {
|
|
607
|
+
return { kind: "stuck", reason: `the page asks for a password and ${LOGIN_ENV.password} is not set` };
|
|
608
|
+
}
|
|
609
|
+
if (chosen.otp && !opts.code) {
|
|
610
|
+
return { kind: "stuck", reason: `the page asks for a one-time code and neither ${LOGIN_ENV.otpCode} nor ${LOGIN_ENV.totpSecret} is set` };
|
|
372
611
|
}
|
|
373
612
|
const fill = ["username", "password", "otp"].filter((k) => chosen[k] && !submitted.has(k));
|
|
374
613
|
return fill.length > 0 ? { kind: "fill", fill } : { kind: "wait" };
|
|
375
614
|
}
|
|
615
|
+
/**
|
|
616
|
+
* The chosen fields that belong to the sign-in. A code field known only by
|
|
617
|
+
* its shape (a numeric field sized for a code) does only while a code is
|
|
618
|
+
* configured and still to be typed after the username or the password has
|
|
619
|
+
* gone, or when it is the very field the code went into. Otherwise it is the app's own field
|
|
620
|
+
* (an order number on the signed-in page) and is left out, so it is neither
|
|
621
|
+
* filled nor read as the code coming back.
|
|
622
|
+
*/
|
|
623
|
+
function signInFields(chosen, submitted, opts) {
|
|
624
|
+
const otp = chosen.otp;
|
|
625
|
+
if (!otp || !shapeOnlyOtp(otp))
|
|
626
|
+
return chosen;
|
|
627
|
+
// After the username or the password: a first page may ask for the password alone, the username carried in its link.
|
|
628
|
+
const stillToType = opts.code !== undefined && (submitted.has("username") || submitted.has("password")) && !submitted.has("otp");
|
|
629
|
+
if (stillToType || submitted.get("otp") === fieldIdentity(otp))
|
|
630
|
+
return chosen;
|
|
631
|
+
const rest = { ...chosen };
|
|
632
|
+
delete rest.otp;
|
|
633
|
+
return rest;
|
|
634
|
+
}
|
|
376
635
|
/**
|
|
377
636
|
* Whether a page URL is the configured success URL: an absolute URL matches
|
|
378
637
|
* as a prefix, anything else as text contained in the path (`/dashboard`),
|
|
@@ -390,11 +649,32 @@ export function urlMatches(pageUrl, want) {
|
|
|
390
649
|
}
|
|
391
650
|
return pathname.includes(want);
|
|
392
651
|
}
|
|
393
|
-
/**
|
|
394
|
-
export function
|
|
395
|
-
const names = {
|
|
652
|
+
/** The fields a step filled, as words: "the username and the password", "the one-time code, one character in each of its 6 boxes". */
|
|
653
|
+
export function filledNames(fill, opts = {}) {
|
|
654
|
+
const names = {
|
|
655
|
+
username: "the username",
|
|
656
|
+
password: "the password",
|
|
657
|
+
otp: opts.codeBoxes ? `the one-time code, one character in each of its ${opts.codeBoxes} boxes` : "the one-time code",
|
|
658
|
+
};
|
|
396
659
|
const list = fill.map((k) => names[k]);
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
660
|
+
return list.length <= 1 ? list.join("") : `${list.slice(0, -1).join(", ")} and ${list[list.length - 1]}`;
|
|
661
|
+
}
|
|
662
|
+
/** One line for what a step did, naming the kinds of field and how it ended, never a value. */
|
|
663
|
+
export function describeStep(fill, by, opts = {}) {
|
|
664
|
+
const joined = filledNames(fill, opts);
|
|
665
|
+
if (by === "page")
|
|
666
|
+
return `Filled ${joined}; the page submitted it by itself.`;
|
|
667
|
+
if (by === "more")
|
|
668
|
+
return `Filled ${joined}; the page then asked for another field before submitting.`;
|
|
669
|
+
const how = by === "enter" ? "pressed Enter" : `clicked "${by.button.trim().slice(0, 40)}"`;
|
|
670
|
+
// The code's own clause ends in a comma before "and", as the code is always the last field named.
|
|
671
|
+
return `Filled ${joined}${opts.codeBoxes ? "," : ""} and ${how}.`;
|
|
672
|
+
}
|
|
673
|
+
/**
|
|
674
|
+
* Page text fit to quote: whitespace collapsed, every credential redacted,
|
|
675
|
+
* then cut to `max` characters. Redaction comes first: a credential cut in
|
|
676
|
+
* half would no longer match it and would be printed in part.
|
|
677
|
+
*/
|
|
678
|
+
export function quotable(text, redact, max) {
|
|
679
|
+
return redact(text.replace(/\s+/g, " ").trim()).slice(0, max);
|
|
400
680
|
}
|