@botbuddy/cli 1.64.34 → 1.65.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/daemon/bin/darwin-arm64/botbuddy-daemon +0 -0
- package/daemon/bin/linux-x64/botbuddy-daemon +0 -0
- package/daemon-release/darwin-arm64.json +4 -4
- package/daemon-release/linux-x64.json +4 -4
- package/daemon-release/manifest.json +5 -5
- package/package.json +2 -2
- package/src/pw/staging-auth.mjs +10 -4
- package/src/staging-pool/binding.mjs +6 -1
- package/src/staging-pool/cli.mjs +70 -15
- package/src/staging-pool/errors.mjs +96 -2
- package/src/staging-pool/fixtures/2026-10-14-okta-sso-signin.json +68 -0
- package/src/staging-pool/health.mjs +11 -2
- package/src/staging-pool/help.mjs +4 -4
- package/src/staging-pool/okta-steps.mjs +120 -0
- package/src/staging-pool/policy.mjs +4 -0
- package/src/staging-pool/pool.mjs +135 -23
- package/src/staging-pool/sign-in-kinds.mjs +87 -0
- package/src/staging-pool/signin-dispatch.mjs +31 -0
- package/src/staging-pool/signin-okta-saml.mjs +530 -0
- package/src/staging-pool/state-file.mjs +7 -1
- package/src/staging-pool/test-helpers/bindings.mjs +30 -0
- package/src/staging-pool/test-helpers/fake-okta-browser.mjs +239 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// BOT-3145 (ported from supplyspark-ent PR #3534, ENT-5553): the real Okta Identity Engine
|
|
2
|
+
// sign-in steps the staging session pool drives for an `okta-saml` persona
|
|
3
|
+
// (signin-okta-saml.mjs). Node-stdlib-only and self-contained: `page` is any Playwright Page
|
|
4
|
+
// (duck-typed: locator().first(), url()).
|
|
5
|
+
//
|
|
6
|
+
// Markup: Okta Sign-In Widget 7.50.0 on integrator-4707698.okta.com (IDX pipeline),
|
|
7
|
+
// captured read-only on 2026-10-14 (fixtures/2026-10-14-okta-sso-signin.json). The same
|
|
8
|
+
// selectors drive Supply Guard's daily real-Okta CI lane (e2e/sso/okta-login-driver.ts,
|
|
9
|
+
// green every day through 2026-11-10). Recapture the fixture when the widget version changes.
|
|
10
|
+
|
|
11
|
+
export const OKTA_SELECTORS = Object.freeze({
|
|
12
|
+
identifier: 'input[name="identifier"], input[name="username"], input#okta-signin-username',
|
|
13
|
+
password: 'input[type="password"], input[name="credentials.passcode"]',
|
|
14
|
+
submit: 'input[type="submit"], button[type="submit"]',
|
|
15
|
+
// The widget's form-level error callout (e.g. a rejected password).
|
|
16
|
+
formError: '[data-se="o-form-error-container"] .o-form-error-container, [data-se="o-form-error-container"], .okta-form-infobox-error',
|
|
17
|
+
heading: '[data-se="o-form-head"]',
|
|
18
|
+
// A challenge or enrolment for a factor other than the password (Okta Verify, email, phone,
|
|
19
|
+
// a TOTP code): the app's Okta policy is password-only 1FA, so any of these is a misconfig.
|
|
20
|
+
authenticatorList: '[data-se="authenticator-verify-list"], [data-se="authenticator-enroll-list"], .authenticator-verify-list, .authenticator-enroll-list',
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
/** Best-effort resilient driver for the real Okta Identity Engine sign-in form.
|
|
24
|
+
* Handles both the single-page and the two-step (identifier → password) layouts.
|
|
25
|
+
* Deliberately tolerant: a real external IdP's markup is outside our control, so
|
|
26
|
+
* the assertion that the login actually worked is the minted staging session. */
|
|
27
|
+
export async function loginToOkta(page, username, password, expectedHost, { onPasswordSubmit, beforePasswordFill } = {}) {
|
|
28
|
+
// Fail closed before ever entering the credential: only ever type it on the EXACT
|
|
29
|
+
// registered Okta tenant host, so a misconfigured/compromised provider that 302s to a
|
|
30
|
+
// look-alike (e.g. okta.com.attacker.example) cannot harvest it.
|
|
31
|
+
const assertOnOktaHost = (stage) => {
|
|
32
|
+
let host = "";
|
|
33
|
+
try {
|
|
34
|
+
const url = new URL(page.url());
|
|
35
|
+
// BOT-3145: https only, so the password never goes into a plaintext page on the right host.
|
|
36
|
+
host = url.protocol === "https:" ? url.hostname : `${url.protocol}//${url.hostname}`;
|
|
37
|
+
} catch {
|
|
38
|
+
/* handled below */
|
|
39
|
+
}
|
|
40
|
+
if (host !== expectedHost) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
`refusing to enter Okta credentials at ${stage}: page host ${host || "(unparseable)"} is not the registered Okta host ${expectedHost}.`,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const identifier = page.locator(OKTA_SELECTORS.identifier).first();
|
|
48
|
+
await identifier.waitFor({ state: "visible", timeout: 45_000 });
|
|
49
|
+
assertOnOktaHost("identifier");
|
|
50
|
+
await identifier.fill(username);
|
|
51
|
+
|
|
52
|
+
const passwordField = page.locator(OKTA_SELECTORS.password).first();
|
|
53
|
+
if (!(await passwordField.isVisible().catch(() => false))) {
|
|
54
|
+
// Two-step layout: advance past the identifier page.
|
|
55
|
+
await page.locator(OKTA_SELECTORS.submit).first().click();
|
|
56
|
+
await passwordField.waitFor({ state: "visible", timeout: 45_000 });
|
|
57
|
+
}
|
|
58
|
+
assertOnOktaHost("password");
|
|
59
|
+
// BOT-3145: the caller persists a pending credential hold here, BEFORE the password is typed, so a
|
|
60
|
+
// crash after the submit can never lose it; a failure to persist aborts with nothing typed.
|
|
61
|
+
if (beforePasswordFill) {
|
|
62
|
+
await beforePasswordFill();
|
|
63
|
+
assertOnOktaHost("password");
|
|
64
|
+
}
|
|
65
|
+
await passwordField.fill(password);
|
|
66
|
+
// From here the password counts as submitted, even if the click itself throws: the pool
|
|
67
|
+
// never re-submits it within a mint (ENT-5553 HF-01).
|
|
68
|
+
onPasswordSubmit?.();
|
|
69
|
+
await page.locator(OKTA_SELECTORS.submit).first().click();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function visibleText(page, selector) {
|
|
73
|
+
const locator = page.locator(selector).first();
|
|
74
|
+
if (!(await locator.isVisible().catch(() => false))) return null;
|
|
75
|
+
const text = await locator.innerText().catch(() => "");
|
|
76
|
+
return String(text ?? "").replace(/\s+/g, " ").trim().slice(0, 200);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A read-only, value-free snapshot of what the browser shows after the password submit.
|
|
80
|
+
* Never reads input values; text is the widget's own heading / error copy. */
|
|
81
|
+
export async function readOktaPageState(page) {
|
|
82
|
+
let host = "";
|
|
83
|
+
let path = "";
|
|
84
|
+
let protocol = "";
|
|
85
|
+
try {
|
|
86
|
+
const url = new URL(page.url());
|
|
87
|
+
host = url.hostname;
|
|
88
|
+
path = url.pathname;
|
|
89
|
+
protocol = url.protocol;
|
|
90
|
+
} catch {
|
|
91
|
+
/* unparseable: classified as unknown */
|
|
92
|
+
}
|
|
93
|
+
const [passwordVisible, identifierVisible, authenticatorListVisible, errorText, headingText] = await Promise.all([
|
|
94
|
+
page.locator(OKTA_SELECTORS.password).first().isVisible().catch(() => false),
|
|
95
|
+
page.locator(OKTA_SELECTORS.identifier).first().isVisible().catch(() => false),
|
|
96
|
+
page.locator(OKTA_SELECTORS.authenticatorList).first().isVisible().catch(() => false),
|
|
97
|
+
visibleText(page, OKTA_SELECTORS.formError),
|
|
98
|
+
visibleText(page, OKTA_SELECTORS.heading),
|
|
99
|
+
]);
|
|
100
|
+
return { host, path, protocol, passwordVisible, identifierVisible, authenticatorListVisible, errorText, headingText };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const LOCKED_OUT = /locked|suspended|deactivated/i;
|
|
104
|
+
const MFA_HEADING = /security method|okta verify|verify with (?!your password)|enter (a|the|your) code|set up|enroll|authenticator|push notification|phone|email/i;
|
|
105
|
+
|
|
106
|
+
/** Pure classification of a page state on the Okta host after the password submit.
|
|
107
|
+
* Returns one of: "pending" (still working — keep waiting), "password_rejected",
|
|
108
|
+
* "account_locked", "mfa_prompt", "off_okta" (not on the Okta host). */
|
|
109
|
+
export function classifyOktaPageState(state, { expectedHost }) {
|
|
110
|
+
if (!state || state.host !== expectedHost) return "off_okta";
|
|
111
|
+
if (state.errorText && LOCKED_OUT.test(state.errorText)) return "account_locked";
|
|
112
|
+
// A factor challenge reuses the passcode input name, so the heading decides first: the
|
|
113
|
+
// password step's own heading ("Verify with your password") never matches.
|
|
114
|
+
if (state.authenticatorListVisible) return "mfa_prompt";
|
|
115
|
+
if (state.headingText && MFA_HEADING.test(state.headingText)) return "mfa_prompt";
|
|
116
|
+
// Any form-level error while the password step is still on screen is a rejected
|
|
117
|
+
// credential (the widget words it "Password is incorrect" / "Unable to sign in").
|
|
118
|
+
if (state.errorText && (state.passwordVisible || state.identifierVisible)) return "password_rejected";
|
|
119
|
+
return "pending";
|
|
120
|
+
}
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
|
|
11
11
|
import { execFileSync } from "node:child_process";
|
|
12
12
|
|
|
13
|
+
import { TOTP_SIGN_IN } from "./sign-in-kinds.mjs";
|
|
14
|
+
|
|
13
15
|
export class MintRefusedError extends Error {
|
|
14
16
|
constructor(code, message) {
|
|
15
17
|
super(message);
|
|
@@ -75,6 +77,8 @@ export function stagingTargetFromBinding(binding) {
|
|
|
75
77
|
appSlug: binding.appSlug,
|
|
76
78
|
tenant: binding.tenant,
|
|
77
79
|
personaEmails: Object.freeze(Object.values(binding.personas).map((persona) => persona.email)),
|
|
80
|
+
// BOT-3145: each persona's sign-in kind, from the binding only (state-file claim checks).
|
|
81
|
+
personaSignIns: Object.freeze(Object.fromEntries(Object.values(binding.personas).map((persona) => [persona.email, persona.signIn ?? TOTP_SIGN_IN]))),
|
|
78
82
|
loopback: false,
|
|
79
83
|
});
|
|
80
84
|
}
|
|
@@ -88,12 +88,98 @@ export function createSessionPool({
|
|
|
88
88
|
const lastTickPath = join(poolDir, "last-tick.json");
|
|
89
89
|
const healthPath = join(poolDir, "health.json");
|
|
90
90
|
|
|
91
|
-
|
|
91
|
+
// strict (tick, mint-one): only a missing file reads as empty. Anything else would silently drop a
|
|
92
|
+
// credential hold (BOT-3145), so it fails closed with health_unreadable. status stays lenient.
|
|
93
|
+
// Strict only for a pool that serves an okta-saml persona (review round 2, P3-2): a TOTP-only
|
|
94
|
+
// pool keeps its old self-healing rewrite. status marks an unreadable file so --check names it.
|
|
95
|
+
const holdsCredentials = Object.values(target.personaSignIns ?? {}).some((spec) => spec?.kind === "okta-saml");
|
|
96
|
+
async function readHealth({ strict = false } = {}) {
|
|
92
97
|
try {
|
|
93
98
|
const health = await readJson(healthPath);
|
|
94
99
|
return { lastSuccessfulTickAt: health.lastSuccessfulTickAt ?? null, personas: health.personas ?? {} };
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (error?.code === "ENOENT") return { lastSuccessfulTickAt: null, personas: {} };
|
|
102
|
+
if (strict && holdsCredentials) {
|
|
103
|
+
throw Object.assign(new Error(`${healthPath} is unreadable (${error?.code ?? error?.name ?? "error"}); a credential hold may be in it.`), { code: "health_unreadable" });
|
|
104
|
+
}
|
|
105
|
+
return { lastSuccessfulTickAt: null, personas: {}, ...(holdsCredentials ? { unreadable: true } : {}) };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function writeHealth(health) {
|
|
110
|
+
await replaceFile(healthPath, `${JSON.stringify(health, null, 2)}\n`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Review round 2, P2-1: the pending hold is on disk BEFORE the password is typed, so a crash,
|
|
114
|
+
// reboot or uninstall mid-sign-in can never lose it. Cleared below when the outcome proves the
|
|
115
|
+
// password was accepted or never submitted.
|
|
116
|
+
function holdPersister(health, entry) {
|
|
117
|
+
return async (holdKey) => {
|
|
118
|
+
entry.credentialHold = holdKey;
|
|
119
|
+
entry.credentialHoldPending = true;
|
|
120
|
+
await writeHealth(health);
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// A pending hold is released only when the adapter vouched the outcome is not a refusal
|
|
125
|
+
// (credentialChecked). Anything else — a crash, an unclassified error — keeps it (fail closed).
|
|
126
|
+
function recordMintFailureHold(entry, error) {
|
|
127
|
+
if (typeof error?.holdKey === "string" && error.holdKey) entry.credentialHold = error.holdKey;
|
|
128
|
+
else if (entry.credentialHoldPending && error?.credentialChecked === true) entry.credentialHold = null;
|
|
129
|
+
delete entry.credentialHoldPending;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function healthEntry(health, persona) {
|
|
133
|
+
const entry = (health.personas[persona] ??= {});
|
|
134
|
+
entry.belowTargetTicks ??= 0;
|
|
135
|
+
entry.consecutiveMintFailures ??= 0;
|
|
136
|
+
entry.backoffMintFailures ??= 0;
|
|
137
|
+
entry.nextMintAttemptAt ??= null;
|
|
138
|
+
entry.lastMintError ??= null;
|
|
139
|
+
return entry;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// BOT-3145: true while the persona's held credential is still the one it would sign in with (or
|
|
143
|
+
// that cannot be told); a changed credential clears the hold.
|
|
144
|
+
async function credentialStillHeld(entry, persona) {
|
|
145
|
+
if (!entry.credentialHold) return false;
|
|
146
|
+
const holdKey = await currentHoldKey(persona);
|
|
147
|
+
// "<key id>:<fingerprint>": a replaced hold key (another key id) keeps the hold (round 2, P3-3).
|
|
148
|
+
const keyId = (value) => String(value).split(":")[0];
|
|
149
|
+
if (holdKey !== null && keyId(holdKey) === keyId(entry.credentialHold) && holdKey !== entry.credentialHold) {
|
|
150
|
+
entry.credentialHold = null;
|
|
151
|
+
logger({ event: "credential_hold_cleared", persona });
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function recordMintSuccess(entry) {
|
|
158
|
+
entry.consecutiveMintFailures = 0;
|
|
159
|
+
entry.backoffMintFailures = 0;
|
|
160
|
+
entry.nextMintAttemptAt = null;
|
|
161
|
+
entry.lastMintError = null;
|
|
162
|
+
entry.credentialHold = null;
|
|
163
|
+
delete entry.credentialHoldPending;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// BOT-3145: status shows only that a persona's credential is held, never its fingerprint.
|
|
167
|
+
function publicHealth(health) {
|
|
168
|
+
const personas = {};
|
|
169
|
+
for (const [persona, entry] of Object.entries(health.personas)) {
|
|
170
|
+
const { credentialHold, credentialHoldPending: _pending, ...rest } = entry ?? {};
|
|
171
|
+
personas[persona] = credentialHold ? { ...rest, credentialHeld: true } : rest;
|
|
172
|
+
}
|
|
173
|
+
return { ...health, personas };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// BOT-3145: the fingerprint of the credential this persona would sign in with now, or null.
|
|
177
|
+
async function currentHoldKey(persona) {
|
|
178
|
+
if (typeof signIn.credentialHoldKey !== "function") return null;
|
|
179
|
+
try {
|
|
180
|
+
return (await signIn.credentialHoldKey(persona)) ?? null;
|
|
95
181
|
} catch {
|
|
96
|
-
return
|
|
182
|
+
return null;
|
|
97
183
|
}
|
|
98
184
|
}
|
|
99
185
|
|
|
@@ -315,11 +401,11 @@ export function createSessionPool({
|
|
|
315
401
|
}
|
|
316
402
|
|
|
317
403
|
// Caller holds the tick lock.
|
|
318
|
-
async function mintIntoReady(persona) {
|
|
404
|
+
async function mintIntoReady(persona, { persistHold } = {}) {
|
|
319
405
|
const mintedAt = new Date(now()).toISOString();
|
|
320
406
|
const slotId = `${personaKey(persona)}~${now()}~${randomId()}`;
|
|
321
407
|
const workDir = join(dirs.work, slotId);
|
|
322
|
-
const storedSession = await signIn.mint({ target, persona });
|
|
408
|
+
const storedSession = await signIn.mint({ target, persona, ...(persistHold ? { persistHold } : {}) });
|
|
323
409
|
await ensureDir(workDir);
|
|
324
410
|
try {
|
|
325
411
|
const meta = buildMeta({ persona, slotId, storedSession, mintedAt, refreshCount: 0 });
|
|
@@ -477,20 +563,22 @@ export function createSessionPool({
|
|
|
477
563
|
}
|
|
478
564
|
|
|
479
565
|
const counts = await countReady(plan.map(([persona]) => persona));
|
|
480
|
-
const health = await readHealth();
|
|
566
|
+
const health = await readHealth({ strict: true });
|
|
481
567
|
summary.backoffSkips = 0;
|
|
568
|
+
summary.heldSkips = 0;
|
|
482
569
|
for (const [persona, want] of plan) {
|
|
483
|
-
const entry = (health
|
|
484
|
-
belowTargetTicks: 0,
|
|
485
|
-
consecutiveMintFailures: 0,
|
|
486
|
-
backoffMintFailures: 0,
|
|
487
|
-
nextMintAttemptAt: null,
|
|
488
|
-
lastMintError: null,
|
|
489
|
-
});
|
|
570
|
+
const entry = healthEntry(health, persona);
|
|
490
571
|
// Every refill sign-in's MFA verify deletes the persona's AAL1 sessions on
|
|
491
572
|
// staging (ENT-4994), so repeated failures back off instead of retrying
|
|
492
573
|
// every tick.
|
|
493
|
-
|
|
574
|
+
// BOT-3145: a persona whose last mint refused (or could not confirm) its IdP password is
|
|
575
|
+
// held until that password changes: no sign-in may submit it again (Okta lockout).
|
|
576
|
+
// Re-checked every tick, full or not, so a fixed password clears a stale hold (P2-c).
|
|
577
|
+
const held = await credentialStillHeld(entry, persona);
|
|
578
|
+
if (held && counts[persona] < want) {
|
|
579
|
+
summary.heldSkips += 1;
|
|
580
|
+
logger({ event: "mint_held_skip", persona, lastMintError: entry.lastMintError });
|
|
581
|
+
} else if (counts[persona] < want && entry.nextMintAttemptAt && Date.parse(entry.nextMintAttemptAt) > now()) {
|
|
494
582
|
summary.backoffSkips += 1;
|
|
495
583
|
logger({
|
|
496
584
|
event: "mint_backoff_skip",
|
|
@@ -503,16 +591,14 @@ export function createSessionPool({
|
|
|
503
591
|
while (counts[persona] < want) {
|
|
504
592
|
summary.signIns += 1;
|
|
505
593
|
try {
|
|
506
|
-
await mintIntoReady(persona);
|
|
594
|
+
await mintIntoReady(persona, { persistHold: holdPersister(health, entry) });
|
|
507
595
|
counts[persona] += 1;
|
|
508
|
-
entry
|
|
509
|
-
entry.backoffMintFailures = 0;
|
|
510
|
-
entry.nextMintAttemptAt = null;
|
|
511
|
-
entry.lastMintError = null;
|
|
596
|
+
recordMintSuccess(entry);
|
|
512
597
|
} catch (error) {
|
|
513
598
|
summary.mintFailures += 1;
|
|
514
599
|
entry.consecutiveMintFailures += 1;
|
|
515
600
|
entry.lastMintError = error?.code ?? "error";
|
|
601
|
+
recordMintFailureHold(entry, error);
|
|
516
602
|
// BOT-2952: an outage that never got a password grant created no AAL1 session and
|
|
517
603
|
// ran no MFA verify, so it retries on the next tick and does not lengthen a later
|
|
518
604
|
// backoff. A 429 is Auth asking us to slow down, so it backs off like any other
|
|
@@ -544,10 +630,15 @@ export function createSessionPool({
|
|
|
544
630
|
}
|
|
545
631
|
// A persona the config no longer serves (removed or 0) drops its backoff and streaks, so
|
|
546
632
|
// re-enabling it after a repair mints on the next tick (Codex R13).
|
|
633
|
+
// BOT-3145: a credential hold outlives the persona being switched off, so re-enabling it never
|
|
634
|
+
// re-submits a refused password.
|
|
547
635
|
for (const persona of Object.keys(health.personas)) {
|
|
548
|
-
if (
|
|
636
|
+
if (wanted.get(persona) > 0) continue;
|
|
637
|
+
const hold = health.personas[persona]?.credentialHold;
|
|
638
|
+
if (hold) health.personas[persona] = { credentialHold: hold };
|
|
639
|
+
else delete health.personas[persona];
|
|
549
640
|
}
|
|
550
|
-
if (summary.mintFailures === 0 && summary.backoffSkips === 0) health.lastSuccessfulTickAt = summary.at;
|
|
641
|
+
if (summary.mintFailures === 0 && summary.backoffSkips === 0 && summary.heldSkips === 0) health.lastSuccessfulTickAt = summary.at;
|
|
551
642
|
await replaceFile(healthPath, `${JSON.stringify(health, null, 2)}\n`);
|
|
552
643
|
summary.ready = counts;
|
|
553
644
|
logger({ event: "tick", ...summary });
|
|
@@ -556,10 +647,29 @@ export function createSessionPool({
|
|
|
556
647
|
});
|
|
557
648
|
}
|
|
558
649
|
|
|
559
|
-
|
|
650
|
+
// BOT-3145: mint-one honours and records credential holds like a tick. retryHeld is the human's
|
|
651
|
+
// deliberate single re-submission of a held credential (after unlocking or checking the IdP user).
|
|
652
|
+
async function mintOne({ persona, retryHeld = false }) {
|
|
560
653
|
assertPersonaAllowed(target, persona);
|
|
561
654
|
const result = await withTickLock(async () => {
|
|
562
|
-
const
|
|
655
|
+
const health = await readHealth({ strict: true });
|
|
656
|
+
const entry = healthEntry(health, persona);
|
|
657
|
+
if (!retryHeld && (await credentialStillHeld(entry, persona))) {
|
|
658
|
+
await writeHealth(health);
|
|
659
|
+
throw Object.assign(new Error(`${persona} is held: its IdP refused, or never confirmed, the configured password.`), { code: "credential_held" });
|
|
660
|
+
}
|
|
661
|
+
let minted;
|
|
662
|
+
try {
|
|
663
|
+
minted = await mintIntoReady(persona, { persistHold: holdPersister(health, entry) });
|
|
664
|
+
} catch (error) {
|
|
665
|
+
entry.consecutiveMintFailures += 1;
|
|
666
|
+
entry.lastMintError = error?.code ?? "error";
|
|
667
|
+
recordMintFailureHold(entry, error);
|
|
668
|
+
await writeHealth(health);
|
|
669
|
+
throw error;
|
|
670
|
+
}
|
|
671
|
+
recordMintSuccess(entry);
|
|
672
|
+
await writeHealth(health);
|
|
563
673
|
logger({ event: "mint_one", persona, slotId: minted.slotId, signIns: 1 });
|
|
564
674
|
return minted;
|
|
565
675
|
});
|
|
@@ -650,6 +760,8 @@ export function createSessionPool({
|
|
|
650
760
|
slotId,
|
|
651
761
|
persona: meta.persona,
|
|
652
762
|
aal: meta.aal,
|
|
763
|
+
// BOT-3145: the auth methods (e.g. totp, sso/saml) — metadata, never session material.
|
|
764
|
+
amr: Array.isArray(meta.amr) ? meta.amr : [],
|
|
653
765
|
sessionRef: meta.sessionRef,
|
|
654
766
|
ageSeconds: Math.round((now() - Date.parse(meta.mintedAt)) / 1000),
|
|
655
767
|
verifiedAgeSeconds: Math.round((now() - Date.parse(meta.verifiedAt)) / 1000),
|
|
@@ -676,7 +788,7 @@ export function createSessionPool({
|
|
|
676
788
|
return {
|
|
677
789
|
poolDir,
|
|
678
790
|
target: target.name,
|
|
679
|
-
health: await readHealth(),
|
|
791
|
+
health: publicHealth(await readHealth()),
|
|
680
792
|
ready: await collect(dirs.ready),
|
|
681
793
|
taken: (await list(dirs.taken)).length,
|
|
682
794
|
work: (await list(dirs.work)).length,
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// BOT-3145: how a pool persona signs in, and which session claims prove it.
|
|
2
|
+
//
|
|
3
|
+
// A persona with no `sign_in` block signs in with password + TOTP (BOT-2084) and must
|
|
4
|
+
// hold an aal2 session. A persona with `sign_in: { kind: "okta-saml", ... }` signs in
|
|
5
|
+
// through its tenant's Okta IdP over SAML (signin-okta-saml.mjs) and must hold a
|
|
6
|
+
// session whose amr carries `sso/saml` (GoTrue issues it at aal1: the IdP owns the
|
|
7
|
+
// second factor). The kind comes only from the tenant's authenticated binding, so
|
|
8
|
+
// nothing an agent passes can turn a TOTP persona into an SSO one.
|
|
9
|
+
//
|
|
10
|
+
// Node-stdlib-only and self-contained (the installed job copies this directory).
|
|
11
|
+
|
|
12
|
+
export const SIGN_IN_KINDS = Object.freeze({ TOTP: "gotrue-totp", OKTA_SAML: "okta-saml" });
|
|
13
|
+
|
|
14
|
+
const SIGN_IN_KEYS = new Set(["kind", "sso_domain", "idp_host", "admission_function", "admission_route"]);
|
|
15
|
+
// A bare DNS hostname: labels of [a-z0-9-], at least one dot, no port, path or scheme.
|
|
16
|
+
const HOSTNAME_RE = /^(?=.{4,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i;
|
|
17
|
+
// An Edge Function slug on the binding's own supabase_url, never a URL.
|
|
18
|
+
const FUNCTION_SLUG_RE = /^[a-z0-9][a-z0-9_-]{0,62}$/;
|
|
19
|
+
// The app path the admission function must answer with for an admitted member (e.g. "/dashboard").
|
|
20
|
+
const ROUTE_RE = /^\/[A-Za-z0-9/_-]{0,100}$/;
|
|
21
|
+
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
|
|
22
|
+
|
|
23
|
+
export const TOTP_SIGN_IN = Object.freeze({ kind: SIGN_IN_KINDS.TOTP });
|
|
24
|
+
|
|
25
|
+
// raw: a persona's `sign_in` value from the binding (undefined → TOTP). Returns the
|
|
26
|
+
// frozen camelCase spec, or a { field, message } problem. Shared by the CLI's binding
|
|
27
|
+
// parser; the control-plane registry (stagingSessionPoolRegistry.ts) mirrors these rules, and
|
|
28
|
+
// both test suites carry the same refusal cases.
|
|
29
|
+
export function parseSignIn(raw) {
|
|
30
|
+
if (raw === undefined) return { ok: true, signIn: TOTP_SIGN_IN };
|
|
31
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return problem("sign_in", "sign_in must be an object");
|
|
32
|
+
for (const key of Object.keys(raw)) {
|
|
33
|
+
if (!SIGN_IN_KEYS.has(key)) return problem("sign_in", `unknown sign_in key ${JSON.stringify(key.slice(0, 40))}`);
|
|
34
|
+
}
|
|
35
|
+
if (raw.kind !== SIGN_IN_KINDS.OKTA_SAML) return problem("sign_in.kind", `sign_in.kind must be "${SIGN_IN_KINDS.OKTA_SAML}" (omit sign_in for password + TOTP)`);
|
|
36
|
+
if (!isHostname(raw.sso_domain)) return problem("sign_in.sso_domain", "sign_in.sso_domain must be the SSO provider's email domain");
|
|
37
|
+
if (!isHostname(raw.idp_host) || LOOPBACK_HOSTS.has(String(raw.idp_host).toLowerCase())) {
|
|
38
|
+
return problem("sign_in.idp_host", "sign_in.idp_host must be the IdP's bare public hostname (credentials are typed only there)");
|
|
39
|
+
}
|
|
40
|
+
if (raw.admission_function !== undefined && (typeof raw.admission_function !== "string" || !FUNCTION_SLUG_RE.test(raw.admission_function))) {
|
|
41
|
+
return problem("sign_in.admission_function", "sign_in.admission_function must be an Edge Function slug");
|
|
42
|
+
}
|
|
43
|
+
if (raw.admission_route !== undefined && (raw.admission_function === undefined || typeof raw.admission_route !== "string" || !ROUTE_RE.test(raw.admission_route))) {
|
|
44
|
+
return problem("sign_in.admission_route", "sign_in.admission_route must be an app path, and needs admission_function");
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
ok: true,
|
|
48
|
+
signIn: Object.freeze({
|
|
49
|
+
kind: SIGN_IN_KINDS.OKTA_SAML,
|
|
50
|
+
ssoDomain: raw.sso_domain.toLowerCase(),
|
|
51
|
+
idpHost: raw.idp_host.toLowerCase(),
|
|
52
|
+
admissionFunction: raw.admission_function ?? null,
|
|
53
|
+
admissionRoute: raw.admission_route ?? null,
|
|
54
|
+
}),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isHostname(value) {
|
|
59
|
+
return typeof value === "string" && HOSTNAME_RE.test(value);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function problem(field, message) {
|
|
63
|
+
return { ok: false, field, message };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function amrMethods(claims) {
|
|
67
|
+
return Array.isArray(claims?.amr) ? claims.amr.map((entry) => entry?.method ?? String(entry)) : [];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Why a session's claims are wrong for a persona of this sign-in kind, or null.
|
|
71
|
+
// TOTP: aal2. okta-saml: amr carries sso/saml at aal1 or aal2. Email always matches.
|
|
72
|
+
export function sessionClaimsProblem(claims, { email, signIn = TOTP_SIGN_IN } = {}) {
|
|
73
|
+
if (String(claims?.email ?? "").toLowerCase() !== String(email ?? "").toLowerCase()) return "persona_mismatch";
|
|
74
|
+
if (signIn.kind === SIGN_IN_KINDS.OKTA_SAML) {
|
|
75
|
+
if (!amrMethods(claims).includes("sso/saml")) return "not_sso_session";
|
|
76
|
+
if (claims.aal !== "aal1" && claims.aal !== "aal2") return "aal_unknown";
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
if (claims?.aal !== "aal2") return "aal_downgraded";
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// The take side's requirement for a persona, in a form the lane daemon can check
|
|
84
|
+
// without importing this module (pw/staging-auth.mjs installCode).
|
|
85
|
+
export function requiredAuthFor(signIn = TOTP_SIGN_IN) {
|
|
86
|
+
return signIn.kind === SIGN_IN_KINDS.OKTA_SAML ? Object.freeze({ amr: "sso/saml" }) : Object.freeze({ aal: "aal2" });
|
|
87
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// BOT-3145: routes each pool persona to its sign-in adapter by the binding's `sign_in` kind
|
|
2
|
+
// (sign-in-kinds.mjs): password + TOTP (signin-gotrue-totp.mjs) or Okta SAML
|
|
3
|
+
// (signin-okta-saml.mjs). The pool stays kind-agnostic; it sees one { mint, refresh,
|
|
4
|
+
// credentialHoldKey } recipe. Node-stdlib-only and self-contained.
|
|
5
|
+
|
|
6
|
+
import { SIGN_IN_KINDS, TOTP_SIGN_IN } from "./sign-in-kinds.mjs";
|
|
7
|
+
import { SignInError } from "./signin-gotrue-totp.mjs";
|
|
8
|
+
|
|
9
|
+
export function signInLookup(binding) {
|
|
10
|
+
const byEmail = new Map(Object.values(binding?.personas ?? {}).map((persona) => [persona.email, persona.signIn ?? TOTP_SIGN_IN]));
|
|
11
|
+
return (persona) => byEmail.get(persona) ?? TOTP_SIGN_IN;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function createBindingSignIn({ binding, totp, okta }) {
|
|
15
|
+
if (!totp) throw new Error("createBindingSignIn needs the TOTP adapter.");
|
|
16
|
+
const signInFor = signInLookup(binding);
|
|
17
|
+
const adapterFor = (persona) => {
|
|
18
|
+
if (signInFor(persona).kind !== SIGN_IN_KINDS.OKTA_SAML) return totp;
|
|
19
|
+
if (!okta) throw new SignInError("okta_adapter_unavailable", `${persona} signs in through Okta, and this job has no Okta adapter.`);
|
|
20
|
+
return okta;
|
|
21
|
+
};
|
|
22
|
+
return {
|
|
23
|
+
mint: async (args) => adapterFor(args.persona).mint(args),
|
|
24
|
+
refresh: async (args) => adapterFor(args.persona).refresh(args),
|
|
25
|
+
// A fingerprint of the credential a held persona would sign in with; null for kinds with no hold.
|
|
26
|
+
async credentialHoldKey(persona) {
|
|
27
|
+
const adapter = adapterFor(persona);
|
|
28
|
+
return typeof adapter.credentialHoldKey === "function" ? adapter.credentialHoldKey(persona) : null;
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|