@botbuddy/cli 1.64.34 → 1.66.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
- package/src/wait-core.mjs +12 -0
- package/src/wait.mjs +4 -0
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
// BOT-3145 (ported from supplyspark-ent PR #3534, ENT-5553): the "okta-saml" sign-in kind
|
|
2
|
+
// for pool personas whose binding carries `sign_in: { kind: "okta-saml", ... }`.
|
|
3
|
+
//
|
|
4
|
+
// The same path a tenant's real-Okta CI drives, end to end:
|
|
5
|
+
// 1. POST /auth/v1/sso {domain: sign_in.sso_domain, skip_http_redirect, redirect_to: app origin}
|
|
6
|
+
// → the IdP URL, pinned to the exact registered sign_in.idp_host;
|
|
7
|
+
// 2. a fresh headless browser runs the Okta steps (okta-steps.mjs: username → Next → password →
|
|
8
|
+
// Verify); Okta posts the SAML assertion to GoTrue's ACS, which redirects to the app origin
|
|
9
|
+
// with the session in the URL fragment;
|
|
10
|
+
// 3. the ACS POST is intercepted: Playwright performs it with redirects off, reads the 302's
|
|
11
|
+
// Location (the app origin + session fragment) and answers the browser with a blank page,
|
|
12
|
+
// so the app never boots and never rotates the refresh token this adapter is about to store;
|
|
13
|
+
// 4. GET /auth/v1/user for the user object, then (when the binding names one) POST the tenant's
|
|
14
|
+
// admission Edge Function (Supply Guard: sso-claim-membership, ENT-4508 — without it the
|
|
15
|
+
// app rejects an unclaimed SAML session). For an existing member it is idempotent.
|
|
16
|
+
// The result is the six-field supabase-js session (aal1, amr sso/saml), like the TOTP adapter's.
|
|
17
|
+
// GoTrue maps the IdP subject to the same auth user on every sign-in, so a mint never creates a
|
|
18
|
+
// user (no new SSO MAU per mint, ENT-5017).
|
|
19
|
+
//
|
|
20
|
+
// SAML RelayState has a short validity window, so the browser is launched BEFORE the SSO begin
|
|
21
|
+
// and the Okta steps run immediately after it (2026-10-14 staging: begin → ACS in 4.4 s).
|
|
22
|
+
//
|
|
23
|
+
// Lockout protection (#3534 HF-01): the password is submitted at most once per mint, and a
|
|
24
|
+
// rejected or unconfirmed password carries `holdKey` — a keyed fingerprint of the password tried —
|
|
25
|
+
// so the pool stops signing in with it until the human changes the value (repeated bad passwords
|
|
26
|
+
// would lock the shared Okta test user out, and with it the tenant's CI).
|
|
27
|
+
|
|
28
|
+
import { createHmac, randomBytes } from "node:crypto";
|
|
29
|
+
import { chmodSync, readFileSync, writeFileSync } from "node:fs";
|
|
30
|
+
|
|
31
|
+
import { classifyOktaPageState, loginToOkta, readOktaPageState } from "./okta-steps.mjs";
|
|
32
|
+
import { SIGN_IN_KINDS, sessionClaimsProblem } from "./sign-in-kinds.mjs";
|
|
33
|
+
import { SignInError } from "./signin-gotrue-totp.mjs";
|
|
34
|
+
import { decodeJwtClaims, toStoredSession } from "./state-file.mjs";
|
|
35
|
+
|
|
36
|
+
const SSO_SESSION_FRAGMENT_KEYS = ["access_token", "refresh_token"];
|
|
37
|
+
// 520-524: Cloudflare answering for an unreachable origin (BOT-2947).
|
|
38
|
+
const RETRYABLE_STATUSES = new Set([408, 429, 500, 502, 503, 504, 520, 521, 522, 523, 524]);
|
|
39
|
+
const BLANK_PAGE = "<!doctype html><title>bb staging-pool</title>";
|
|
40
|
+
// GoTrue's SAML Assertion Consumer Service, on whatever host staging's auth is served from
|
|
41
|
+
// (Supply Guard 2026-10-14: https://staging-api.supply-guard.io/auth/v1/sso/saml/acs).
|
|
42
|
+
export const ACS_ROUTE = "**/auth/v1/sso/saml/acs*";
|
|
43
|
+
// Failures that mean "this password is wrong, or may be": the pool holds the persona until the
|
|
44
|
+
// value changes, so the shared Okta test user is not locked out by repeated submissions.
|
|
45
|
+
export const HELD_CODES = new Set(["okta_password_rejected", "okta_password_unconfirmed"]);
|
|
46
|
+
|
|
47
|
+
// BOT-3145 review: a password that was submitted but never confirmed is held too, unless the
|
|
48
|
+
// outcome proves Okta accepted it: its ACS answered, the browser left the Okta host, or Okta asked
|
|
49
|
+
// for a second factor. Every other submitted-and-unconfirmed outcome could be a refusal.
|
|
50
|
+
export function holdable(error, progress) {
|
|
51
|
+
if (HELD_CODES.has(error.code)) return true;
|
|
52
|
+
if (!progress.passwordSubmitted || progress.acsAnswered || progress.leftOkta) return false;
|
|
53
|
+
return error.code !== "okta_mfa_prompt" && error.code !== "okta_saml_relay_expired";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// A keyed fingerprint (HMAC-SHA256 under a random per-install key kept 0600 in the pool home):
|
|
57
|
+
// enough to notice the human changed the password, useless as an offline verifier to anyone
|
|
58
|
+
// without the key. Stored in health.json; `status` shows only `held: true` (#3534 HF-02).
|
|
59
|
+
// "<key id>:<fingerprint>": the key id (a hash of the key, not of any password) lets the pool tell a
|
|
60
|
+
// changed password from a replaced key, so deleting credential-hold.key never clears a hold.
|
|
61
|
+
export function credentialFingerprint(password, holdSecret) {
|
|
62
|
+
if (!holdSecret || holdSecret.length < 16) throw new Error("credentialFingerprint needs the pool's hold key.");
|
|
63
|
+
const keyId = createHmac("sha256", holdSecret).update("bb staging-pool okta credential hold key id").digest("hex").slice(0, 8);
|
|
64
|
+
const fingerprint = createHmac("sha256", holdSecret).update(`bb staging-pool okta credential hold\0${password}`).digest("hex").slice(0, 16);
|
|
65
|
+
return `${keyId}:${fingerprint}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Loads (or creates once, 0600) the pool's random hold key.
|
|
69
|
+
// A short or unreadable key is never replaced silently: that would orphan every recorded hold, so it
|
|
70
|
+
// throws and the mint fails closed (credential_hold_unavailable) before any IdP contact.
|
|
71
|
+
const HOLD_KEY_BYTES = 32;
|
|
72
|
+
function checkedHoldKey(key, path) {
|
|
73
|
+
if (!key || key.length < HOLD_KEY_BYTES) throw new Error(`The credential hold key ${path} is corrupt (under ${HOLD_KEY_BYTES} bytes).`);
|
|
74
|
+
return key;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function loadOrCreateHoldKey(path, { fs = { readFileSync, writeFileSync, chmodSync }, random = () => randomBytes(HOLD_KEY_BYTES) } = {}) {
|
|
78
|
+
try {
|
|
79
|
+
return checkedHoldKey(fs.readFileSync(path), path);
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if (error?.code !== "ENOENT") throw error;
|
|
82
|
+
}
|
|
83
|
+
const key = random();
|
|
84
|
+
try {
|
|
85
|
+
fs.writeFileSync(path, key, { mode: 0o600, flag: "wx" });
|
|
86
|
+
} catch (error) {
|
|
87
|
+
if (error?.code !== "EEXIST") throw error;
|
|
88
|
+
return checkedHoldKey(fs.readFileSync(path), path); // a concurrent first use won the race
|
|
89
|
+
}
|
|
90
|
+
fs.chmodSync(path, 0o600);
|
|
91
|
+
return checkedHoldKey(key, path);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// The first line of a browser error, with every URL query removed: Playwright messages carry
|
|
95
|
+
// the IdP URL (SAMLRequest, RelayState) and a multi-line call log (#3534 HF-04).
|
|
96
|
+
export function browserErrorSummary(error) {
|
|
97
|
+
return String(error?.message ?? error)
|
|
98
|
+
.split("\n")[0]
|
|
99
|
+
.replace(/(https?:\/\/[^\s?#]+)[?#]\S*/g, "$1?…")
|
|
100
|
+
.slice(0, 200);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function sanitizeState(state) {
|
|
104
|
+
if (!state) return null;
|
|
105
|
+
return {
|
|
106
|
+
host: state.host,
|
|
107
|
+
path: state.path,
|
|
108
|
+
heading: state.headingText ?? null,
|
|
109
|
+
error: state.errorText ?? null,
|
|
110
|
+
passwordVisible: Boolean(state.passwordVisible),
|
|
111
|
+
authenticatorListVisible: Boolean(state.authenticatorListVisible),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function createOktaSamlSignIn({
|
|
116
|
+
fetchImpl = globalThis.fetch,
|
|
117
|
+
launchBrowser,
|
|
118
|
+
passwordFor,
|
|
119
|
+
signInFor,
|
|
120
|
+
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
121
|
+
nowMs = () => Date.now(),
|
|
122
|
+
logger = () => {},
|
|
123
|
+
maxAttempts = 2,
|
|
124
|
+
landingTimeoutMs = 90_000,
|
|
125
|
+
pollMs = 1_000,
|
|
126
|
+
backoffMs = 5_000,
|
|
127
|
+
holdSecret,
|
|
128
|
+
} = {}) {
|
|
129
|
+
if (typeof launchBrowser !== "function") throw new Error("createOktaSamlSignIn needs launchBrowser.");
|
|
130
|
+
if (typeof passwordFor !== "function") throw new Error("createOktaSamlSignIn needs passwordFor.");
|
|
131
|
+
if (typeof signInFor !== "function") throw new Error("createOktaSamlSignIn needs signInFor (the binding's per-persona sign_in).");
|
|
132
|
+
if (typeof holdSecret !== "function") throw new Error("createOktaSamlSignIn needs holdSecret (the pool's hold key).");
|
|
133
|
+
|
|
134
|
+
async function call(url, { method = "POST", headers, body } = {}) {
|
|
135
|
+
let response;
|
|
136
|
+
try {
|
|
137
|
+
response = await fetchImpl(url, { method, headers, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
|
|
138
|
+
} catch (error) {
|
|
139
|
+
return { status: 0, body: null, networkError: error?.message ?? String(error) };
|
|
140
|
+
}
|
|
141
|
+
let parsed = null;
|
|
142
|
+
try {
|
|
143
|
+
parsed = await response.json();
|
|
144
|
+
} catch {
|
|
145
|
+
parsed = null;
|
|
146
|
+
}
|
|
147
|
+
return { status: response.status, body: parsed };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const describe = (response) =>
|
|
151
|
+
`HTTP ${response.status} ${response.body?.error_code ?? response.body?.errorCode ?? response.body?.code ?? response.body?.error ?? "unknown"}`;
|
|
152
|
+
const isTransient = (response) => response.status === 0 || RETRYABLE_STATUSES.has(response.status);
|
|
153
|
+
|
|
154
|
+
async function beginSso(target, spec) {
|
|
155
|
+
const begin = await call(`${target.supabaseUrl}/auth/v1/sso`, {
|
|
156
|
+
headers: { apikey: target.publishableKey, "Content-Type": "application/json" },
|
|
157
|
+
body: { domain: spec.ssoDomain, skip_http_redirect: true, redirect_to: target.appOrigin },
|
|
158
|
+
});
|
|
159
|
+
if (begin.status !== 200) {
|
|
160
|
+
throw new SignInError("sso_begin_failed", `GoTrue SSO begin for ${spec.ssoDomain} refused: ${describe(begin)}.`, {
|
|
161
|
+
transient: isTransient(begin),
|
|
162
|
+
status: begin.status,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
let idpHost = "";
|
|
166
|
+
try {
|
|
167
|
+
const parsed = new URL(begin.body?.url);
|
|
168
|
+
// https only: the password is never typed into a plaintext page, even on the right host.
|
|
169
|
+
idpHost = parsed.protocol === "https:" ? parsed.hostname : `${parsed.protocol}//${parsed.hostname}`;
|
|
170
|
+
} catch {
|
|
171
|
+
idpHost = "";
|
|
172
|
+
}
|
|
173
|
+
if (idpHost !== spec.idpHost) {
|
|
174
|
+
throw new SignInError(
|
|
175
|
+
"okta_unexpected_host",
|
|
176
|
+
`GoTrue routed ${spec.ssoDomain} to ${idpHost || "(no URL)"}, not the registered Okta host ${spec.idpHost}.`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
return begin.body.url;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Runs the Okta steps and intercepts GoTrue's ACS. Returns the ACS redirect target (its
|
|
183
|
+
// fragment carries the session or a GoTrue error).
|
|
184
|
+
//
|
|
185
|
+
// The ACS POST is fetched by Playwright with redirects off and answered with a blank page, so
|
|
186
|
+
// the browser never follows the 302 to the app. Routing the app origin instead does NOT work:
|
|
187
|
+
// Playwright never routes a request that is the result of a redirect (proven in real Chromium
|
|
188
|
+
// on 2026-10-14, supplyspark-ent ENT-5553), so the app would boot and could rotate the refresh token.
|
|
189
|
+
//
|
|
190
|
+
// `progress` records whether the password was submitted and whether GoTrue's ACS answered, so
|
|
191
|
+
// mint() never re-submits a password whose outcome it could not confirm (#3534 HF-01).
|
|
192
|
+
async function driveOkta({ browser, target, spec, persona, password, idpUrl, progress, beforePasswordFill }) {
|
|
193
|
+
const unreachable = (error) =>
|
|
194
|
+
new SignInError("okta_unreachable", `The browser could not reach Okta: ${browserErrorSummary(error)}`, { transient: true });
|
|
195
|
+
let context;
|
|
196
|
+
try {
|
|
197
|
+
context = await browser.newContext();
|
|
198
|
+
} catch (error) {
|
|
199
|
+
throw unreachable(error);
|
|
200
|
+
}
|
|
201
|
+
let landedUrl = "";
|
|
202
|
+
let acsFailure = null;
|
|
203
|
+
let acsIntercepted = false;
|
|
204
|
+
const onAppOrigin = (url) => {
|
|
205
|
+
try {
|
|
206
|
+
return new URL(url).origin === target.appOrigin;
|
|
207
|
+
} catch {
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
const onAcs = async (route) => {
|
|
212
|
+
acsIntercepted = true;
|
|
213
|
+
try {
|
|
214
|
+
const response = await route.fetch({ maxRedirects: 0 });
|
|
215
|
+
const location = response.headers()?.location ?? "";
|
|
216
|
+
// Success is a 302 to the app with the session in the fragment; GoTrue answers an ACS
|
|
217
|
+
// error with a 303 redirect carrying error params (both seen on staging, 2026-10-14).
|
|
218
|
+
if (onAppOrigin(location) && /[#&?]access_token=/.test(location)) {
|
|
219
|
+
landedUrl = location;
|
|
220
|
+
} else {
|
|
221
|
+
const body = await response.text().catch(() => "");
|
|
222
|
+
acsFailure = { status: response.status(), location, body: String(body).slice(0, 400) };
|
|
223
|
+
}
|
|
224
|
+
progress.acsAnswered = true;
|
|
225
|
+
} catch (error) {
|
|
226
|
+
acsFailure = { status: 0, location: "", body: browserErrorSummary(error) };
|
|
227
|
+
}
|
|
228
|
+
await route.fulfill({ status: 200, contentType: "text/html", body: BLANK_PAGE });
|
|
229
|
+
};
|
|
230
|
+
let page;
|
|
231
|
+
try {
|
|
232
|
+
await context.route(ACS_ROUTE, onAcs);
|
|
233
|
+
page = await context.newPage();
|
|
234
|
+
} catch (error) {
|
|
235
|
+
throw unreachable(error);
|
|
236
|
+
}
|
|
237
|
+
let reachedApp = false;
|
|
238
|
+
page.on("framenavigated", (frame) => {
|
|
239
|
+
if (frame !== page.mainFrame()) return;
|
|
240
|
+
if (onAppOrigin(frame.url())) {
|
|
241
|
+
reachedApp = true;
|
|
242
|
+
progress.leftOkta = true;
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
const settled = () => {
|
|
246
|
+
if (landedUrl) return landedUrl;
|
|
247
|
+
if (acsFailure) {
|
|
248
|
+
let code = null;
|
|
249
|
+
try {
|
|
250
|
+
const failed = new URL(acsFailure.location);
|
|
251
|
+
code = new URLSearchParams(failed.hash.replace(/^#/, "")).get("error_code") ?? failed.searchParams.get("error_code");
|
|
252
|
+
} catch {
|
|
253
|
+
code = null;
|
|
254
|
+
}
|
|
255
|
+
code ??= /"?error_code"?\s*[:=]\s*"?([a-z_]+)/i.exec(acsFailure.body)?.[1] ?? `http_${acsFailure.status}`;
|
|
256
|
+
return `${target.appOrigin}/#error_code=${encodeURIComponent(code)}&error=acs_error`;
|
|
257
|
+
}
|
|
258
|
+
if (reachedApp && !acsIntercepted) {
|
|
259
|
+
throw new SignInError(
|
|
260
|
+
"sso_acs_not_intercepted",
|
|
261
|
+
`The SAML callback reached ${target.appOrigin} without passing ${ACS_ROUTE}; refusing a session the app may already have rotated.`,
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
return "";
|
|
265
|
+
};
|
|
266
|
+
try {
|
|
267
|
+
await page.goto(idpUrl, { waitUntil: "commit" });
|
|
268
|
+
} catch (error) {
|
|
269
|
+
throw unreachable(error);
|
|
270
|
+
}
|
|
271
|
+
try {
|
|
272
|
+
await loginToOkta(page, persona, password, spec.idpHost, {
|
|
273
|
+
beforePasswordFill,
|
|
274
|
+
onPasswordSubmit: () => {
|
|
275
|
+
progress.passwordSubmitted = true;
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
} catch (error) {
|
|
279
|
+
if (error instanceof SignInError) throw error;
|
|
280
|
+
if (settled()) return settled();
|
|
281
|
+
if (/refusing to enter Okta credentials/.test(String(error?.message))) {
|
|
282
|
+
throw new SignInError("okta_unexpected_host", browserErrorSummary(error));
|
|
283
|
+
}
|
|
284
|
+
const state = await readOktaPageState(page).catch(() => null);
|
|
285
|
+
logger({ event: "okta_page_state", persona, stage: "steps", state: sanitizeState(state) });
|
|
286
|
+
throw new SignInError("okta_steps_failed", `Okta sign-in steps did not complete: ${browserErrorSummary(error)}`, {
|
|
287
|
+
transient: true,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
const deadline = nowMs() + landingTimeoutMs;
|
|
291
|
+
let lastState = null;
|
|
292
|
+
for (;;) {
|
|
293
|
+
if (settled()) return settled();
|
|
294
|
+
if (nowMs() >= deadline) {
|
|
295
|
+
logger({ event: "okta_page_state", persona, stage: "timeout", state: sanitizeState(lastState) });
|
|
296
|
+
// Okta kept its sign-in form after Verify with no error we recognise: the password may
|
|
297
|
+
// have been refused in markup we never captured. Hold it like a rejection (#3534 HF-01).
|
|
298
|
+
if (
|
|
299
|
+
progress.passwordSubmitted &&
|
|
300
|
+
lastState?.host === spec.idpHost &&
|
|
301
|
+
(lastState.passwordVisible || lastState.identifierVisible)
|
|
302
|
+
) {
|
|
303
|
+
throw new SignInError(
|
|
304
|
+
"okta_password_unconfirmed",
|
|
305
|
+
`Okta still showed its sign-in form ${Math.round(landingTimeoutMs / 1000)} s after ${persona}'s password was submitted; treating its configured password as rejected.`,
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
throw new SignInError("okta_timeout", `No SAML callback reached GoTrue's ACS within ${Math.round(landingTimeoutMs / 1000)} s.`, {
|
|
309
|
+
transient: true,
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
await sleep(pollMs);
|
|
313
|
+
if (settled()) return settled();
|
|
314
|
+
const state = await readOktaPageState(page).catch(() => null);
|
|
315
|
+
if (!state) continue;
|
|
316
|
+
lastState = state;
|
|
317
|
+
const verdict = classifyOktaPageState(state, { expectedHost: spec.idpHost });
|
|
318
|
+
// Off the Okta host after Verify: Okta accepted the password and moved on (no hold).
|
|
319
|
+
if (verdict === "off_okta") {
|
|
320
|
+
// Only a real https page on another host proves Okta moved on; about:blank or a browser
|
|
321
|
+
// error page proves nothing (review round 2, P3-1).
|
|
322
|
+
if (progress.passwordSubmitted && state.protocol === "https:" && state.host && state.host !== spec.idpHost) progress.leftOkta = true;
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
if (verdict === "pending") continue;
|
|
326
|
+
logger({ event: "okta_page_state", persona, stage: verdict, state: sanitizeState(state) });
|
|
327
|
+
if (verdict === "password_rejected") {
|
|
328
|
+
throw new SignInError(
|
|
329
|
+
"okta_password_rejected",
|
|
330
|
+
`Okta rejected ${persona}'s sign-in with its configured password (${state.errorText ?? "form error"}).`,
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
if (verdict === "account_locked") {
|
|
334
|
+
throw new SignInError("okta_account_locked", `Okta reports ${persona} as locked or suspended (${state.errorText}).`);
|
|
335
|
+
}
|
|
336
|
+
throw new SignInError(
|
|
337
|
+
"okta_mfa_prompt",
|
|
338
|
+
`Okta asked ${persona} for a second factor (${state.headingText ?? "authenticator challenge"}); the app's Okta policy should be password-only.`,
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async function sessionFromLanding({ target, spec, persona, landedUrl }) {
|
|
344
|
+
const url = new URL(landedUrl);
|
|
345
|
+
const params = new URLSearchParams(url.hash.replace(/^#/, ""));
|
|
346
|
+
for (const [key, value] of new URLSearchParams(url.search)) if (!params.has(key)) params.set(key, value);
|
|
347
|
+
const errorCode = params.get("error_code");
|
|
348
|
+
if (errorCode || params.get("error")) {
|
|
349
|
+
if (errorCode === "saml_relay_state_expired" || errorCode === "saml_relay_state_not_found") {
|
|
350
|
+
throw new SignInError("okta_saml_relay_expired", `GoTrue refused the SAML callback: ${errorCode}.`, { transient: true });
|
|
351
|
+
}
|
|
352
|
+
throw new SignInError("sso_callback_error", `GoTrue refused the SAML callback: ${errorCode ?? params.get("error")}.`);
|
|
353
|
+
}
|
|
354
|
+
if (!SSO_SESSION_FRAGMENT_KEYS.every((key) => params.get(key))) {
|
|
355
|
+
throw new SignInError("sso_callback_error", "The SAML callback carried no session.");
|
|
356
|
+
}
|
|
357
|
+
const accessToken = params.get("access_token");
|
|
358
|
+
const user = await call(`${target.supabaseUrl}/auth/v1/user`, {
|
|
359
|
+
method: "GET",
|
|
360
|
+
headers: { apikey: target.publishableKey, Authorization: `Bearer ${accessToken}` },
|
|
361
|
+
});
|
|
362
|
+
if (user.status !== 200 || !user.body?.id) {
|
|
363
|
+
throw new SignInError("sso_user_unreadable", `GoTrue /user refused the new SSO session: ${describe(user)}.`, {
|
|
364
|
+
transient: isTransient(user),
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
let storedSession;
|
|
368
|
+
try {
|
|
369
|
+
storedSession = toStoredSession({
|
|
370
|
+
access_token: accessToken,
|
|
371
|
+
refresh_token: params.get("refresh_token"),
|
|
372
|
+
token_type: params.get("token_type") ?? "bearer",
|
|
373
|
+
expires_in: params.get("expires_in") ? Number(params.get("expires_in")) : undefined,
|
|
374
|
+
user: user.body,
|
|
375
|
+
});
|
|
376
|
+
} catch (error) {
|
|
377
|
+
throw new SignInError("sso_callback_error", error.message);
|
|
378
|
+
}
|
|
379
|
+
const problem = sessionClaimsProblem(decodeJwtClaims(storedSession.access_token), { email: persona, signIn: spec });
|
|
380
|
+
if (problem) throw new SignInError(problem === "persona_mismatch" ? "persona_mismatch" : "not_sso_session", `The SAML session is wrong for ${persona} (${problem}).`);
|
|
381
|
+
return storedSession;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// The tenant's admission writer for a fresh SAML session (sign_in.admission_function), when the
|
|
385
|
+
// binding names one. Admitted = HTTP 200, success not false, no re-authentication asked for,
|
|
386
|
+
// and (when sign_in.admission_route is set) the member route, e.g. Supply Guard's /dashboard
|
|
387
|
+
// rather than /access-pending.
|
|
388
|
+
async function admit({ target, spec, persona, storedSession }) {
|
|
389
|
+
if (!spec.admissionFunction) return;
|
|
390
|
+
const claim = await call(`${target.supabaseUrl}/functions/v1/${spec.admissionFunction}`, {
|
|
391
|
+
headers: {
|
|
392
|
+
apikey: target.publishableKey,
|
|
393
|
+
Authorization: `Bearer ${storedSession.access_token}`,
|
|
394
|
+
"Content-Type": "application/json",
|
|
395
|
+
},
|
|
396
|
+
body: {},
|
|
397
|
+
});
|
|
398
|
+
const admitted =
|
|
399
|
+
claim.status === 200 &&
|
|
400
|
+
claim.body?.success !== false &&
|
|
401
|
+
claim.body?.reauthenticate !== true &&
|
|
402
|
+
(!spec.admissionRoute || claim.body?.route === spec.admissionRoute);
|
|
403
|
+
if (admitted) {
|
|
404
|
+
logger({ event: "sso_admitted", persona, outcome: claim.body?.outcome ?? null });
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
const outcome = claim.body?.outcome ?? claim.body?.errorCode ?? claim.body?.error_code ?? null;
|
|
408
|
+
throw new SignInError(
|
|
409
|
+
"sso_admission_failed",
|
|
410
|
+
`${spec.admissionFunction} did not admit ${persona}: HTTP ${claim.status} ${outcome ?? "unknown"} route ${claim.body?.route ?? "none"}.`,
|
|
411
|
+
{ transient: isTransient(claim) || claim.body?.reauthenticate === true, status: claim.status },
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function oktaSpec(persona) {
|
|
416
|
+
const spec = signInFor(persona);
|
|
417
|
+
if (spec?.kind !== SIGN_IN_KINDS.OKTA_SAML) {
|
|
418
|
+
throw new SignInError("wrong_sign_in_kind", `${persona} does not sign in through Okta.`);
|
|
419
|
+
}
|
|
420
|
+
return spec;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// persistHold(holdKey), from the pool: saves a pending credential hold before the password is
|
|
424
|
+
// typed (review round 2, P2-1). If it throws, nothing is typed.
|
|
425
|
+
async function mint({ target, persona, persistHold }) {
|
|
426
|
+
const spec = oktaSpec(persona);
|
|
427
|
+
if (target.loopback) {
|
|
428
|
+
throw new SignInError("okta_target_unsupported", "Okta SAML personas sign in on staging only (local GoTrue has no SAML SP).");
|
|
429
|
+
}
|
|
430
|
+
// password-source.mjs reads the persona's password_env from the binding's 0600 file and
|
|
431
|
+
// fails closed with its own password_source_* code.
|
|
432
|
+
const password = await passwordFor({ persona });
|
|
433
|
+
// The hold fingerprint is computed BEFORE any IdP contact: if it cannot be, nothing is
|
|
434
|
+
// submitted (a refused password must always be holdable, fail closed).
|
|
435
|
+
let holdKey;
|
|
436
|
+
try {
|
|
437
|
+
holdKey = credentialFingerprint(password, holdSecret());
|
|
438
|
+
} catch (error) {
|
|
439
|
+
throw new SignInError("credential_hold_unavailable", `The pool's credential hold key is unusable, so ${persona} is not signed in: ${String(error?.message ?? error).slice(0, 200)}`);
|
|
440
|
+
}
|
|
441
|
+
let lastFailure = "no attempt made";
|
|
442
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
443
|
+
let browser;
|
|
444
|
+
const progress = { passwordSubmitted: false, acsAnswered: false, leftOkta: false };
|
|
445
|
+
try {
|
|
446
|
+
try {
|
|
447
|
+
browser = await launchBrowser();
|
|
448
|
+
} catch (error) {
|
|
449
|
+
throw new SignInError("okta_browser_unavailable", `Could not start headless Chromium: ${browserErrorSummary(error)}`);
|
|
450
|
+
}
|
|
451
|
+
const idpUrl = await beginSso(target, spec);
|
|
452
|
+
const beforePasswordFill = persistHold
|
|
453
|
+
? async () => {
|
|
454
|
+
try {
|
|
455
|
+
await persistHold(holdKey);
|
|
456
|
+
} catch (error) {
|
|
457
|
+
throw new SignInError("credential_hold_unavailable", `The pool could not save a pending credential hold for ${persona}, so nothing was typed: ${String(error?.message ?? error).slice(0, 200)}`);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
: undefined;
|
|
461
|
+
const landedUrl = await driveOkta({ browser, target, spec, persona, password, idpUrl, progress, beforePasswordFill });
|
|
462
|
+
const storedSession = await sessionFromLanding({ target, spec, persona, landedUrl });
|
|
463
|
+
await admit({ target, spec, persona, storedSession });
|
|
464
|
+
logger({ event: "mint_ok", persona, attempt, signIn: SIGN_IN_KINDS.OKTA_SAML });
|
|
465
|
+
return storedSession;
|
|
466
|
+
} catch (error) {
|
|
467
|
+
// Anything thrown after the password went in is a failed sign-in step, so it can be held.
|
|
468
|
+
if (!(error instanceof SignInError) && !progress.passwordSubmitted) throw error;
|
|
469
|
+
const failure = error instanceof SignInError ? error : new SignInError("okta_steps_failed", `Okta sign-in failed after the password was submitted: ${browserErrorSummary(error)}`);
|
|
470
|
+
if (holdable(failure, progress)) failure.holdKey = holdKey;
|
|
471
|
+
// The pool may release a pending hold only on this adapter's explicit verdict.
|
|
472
|
+
failure.credentialChecked = true;
|
|
473
|
+
error = failure;
|
|
474
|
+
// #3534 HF-01: once the password was submitted, only an answered ACS (Okta accepted it) or
|
|
475
|
+
// an expired RelayState (Okta posted back, so it accepted it) may re-submit it in this mint.
|
|
476
|
+
if (progress.passwordSubmitted && !progress.acsAnswered && error.code !== "okta_saml_relay_expired") {
|
|
477
|
+
error.transient = false;
|
|
478
|
+
}
|
|
479
|
+
if (!error.transient || attempt >= maxAttempts) throw error;
|
|
480
|
+
lastFailure = `${error.code}: ${error.message}`;
|
|
481
|
+
logger({ event: "mint_retry", persona, attempt, reason: error.code });
|
|
482
|
+
await sleep(backoffMs * attempt);
|
|
483
|
+
} finally {
|
|
484
|
+
await browser?.close().catch(() => {});
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
// Only transient, non-holdable attempts are retried (a holdable one is never transient), so this
|
|
488
|
+
// verdict is "checked: nothing refused".
|
|
489
|
+
throw Object.assign(new SignInError("mint_retries_exhausted", `Mint gave up after ${maxAttempts} attempts (last: ${lastFailure}).`, {
|
|
490
|
+
transient: true,
|
|
491
|
+
}), { credentialChecked: true });
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// Liveness for a slot this caller exclusively holds: the refresh grant answers from the
|
|
495
|
+
// session's current server-side state, and the result must still be this persona's SAML session.
|
|
496
|
+
async function refresh({ target, storedSession, persona }) {
|
|
497
|
+
const response = await call(`${target.supabaseUrl}/auth/v1/token?grant_type=refresh_token`, {
|
|
498
|
+
headers: { apikey: target.publishableKey, "Content-Type": "application/json" },
|
|
499
|
+
body: { refresh_token: storedSession.refresh_token },
|
|
500
|
+
});
|
|
501
|
+
if (response.status !== 200) {
|
|
502
|
+
if (isTransient(response)) return { ok: false, transient: true, reason: describe(response) };
|
|
503
|
+
return { ok: false, transient: false, reason: response.body?.error_code ?? `http_${response.status}` };
|
|
504
|
+
}
|
|
505
|
+
let next;
|
|
506
|
+
try {
|
|
507
|
+
next = toStoredSession(response.body);
|
|
508
|
+
} catch {
|
|
509
|
+
return { ok: false, transient: false, reason: "refresh_response_invalid" };
|
|
510
|
+
}
|
|
511
|
+
const problem = sessionClaimsProblem(decodeJwtClaims(next.access_token), { email: persona, signIn: oktaSpec(persona) });
|
|
512
|
+
if (problem) return { ok: false, transient: false, reason: problem };
|
|
513
|
+
return { ok: true, storedSession: next };
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// The pool skips a persona whose last mint was refused for THIS credential. Null when the
|
|
517
|
+
// password cannot be read (the mint then fails with the password source's own code).
|
|
518
|
+
async function credentialHoldKey(persona) {
|
|
519
|
+
oktaSpec(persona);
|
|
520
|
+
let password;
|
|
521
|
+
try {
|
|
522
|
+
password = await passwordFor({ persona });
|
|
523
|
+
} catch {
|
|
524
|
+
return null;
|
|
525
|
+
}
|
|
526
|
+
return password ? credentialFingerprint(password, holdSecret()) : null;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
return { mint, refresh, credentialHoldKey };
|
|
530
|
+
}
|
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
|
|
8
8
|
import { createHash } from "node:crypto";
|
|
9
9
|
|
|
10
|
+
import { TOTP_SIGN_IN, sessionClaimsProblem } from "./sign-in-kinds.mjs";
|
|
11
|
+
|
|
10
12
|
export class StateFileRejectedError extends Error {
|
|
11
13
|
constructor(message) {
|
|
12
14
|
super(message);
|
|
@@ -105,10 +107,14 @@ export function assertStorageStateClean(state, target) {
|
|
|
105
107
|
} catch (error) {
|
|
106
108
|
throw new StateFileRejectedError(error.message);
|
|
107
109
|
}
|
|
108
|
-
if (claims.aal !== "aal2") throw new StateFileRejectedError(`Session is ${claims.aal ?? "unknown"}, not aal2.`);
|
|
109
110
|
if (!target.personaEmails?.includes(claims.email)) {
|
|
110
111
|
throw new StateFileRejectedError("Session persona is not an allowlisted seeded persona.");
|
|
111
112
|
}
|
|
113
|
+
// BOT-3145: TOTP personas hold aal2; okta-saml personas hold an sso/saml session.
|
|
114
|
+
const signIn = target.personaSignIns?.[claims.email] ?? TOTP_SIGN_IN;
|
|
115
|
+
const problem = sessionClaimsProblem(claims, { email: claims.email, signIn });
|
|
116
|
+
if (problem === "aal_downgraded") throw new StateFileRejectedError(`Session is ${claims.aal ?? "unknown"}, not aal2.`);
|
|
117
|
+
if (problem) throw new StateFileRejectedError(`Session claims are wrong for its ${signIn.kind} persona (${problem}).`);
|
|
112
118
|
return session;
|
|
113
119
|
}
|
|
114
120
|
|
|
@@ -39,3 +39,33 @@ export const SG_TARGET = stagingTargetFromBinding(SG_BINDING);
|
|
|
39
39
|
const { _comment: _note, ...BOTBUDDY_REGISTRATION } = JSON.parse(readFileSync(new URL("../../../../scripts/staging-session-pool.botbuddy.json", import.meta.url), "utf8"));
|
|
40
40
|
export const BOTBUDDY_RAW_BINDING = Object.freeze({ schema_version: 1, ...BOTBUDDY_REGISTRATION });
|
|
41
41
|
export const BOTBUDDY_BINDING = parsePoolBinding(BOTBUDDY_RAW_BINDING);
|
|
42
|
+
|
|
43
|
+
// BOT-3145: Supply Guard's binding with its Okta E2E org admin as an okta-saml persona
|
|
44
|
+
// (the values ENT-5553 registers; the 2026-10-14 fixture's IdP host and SSO domain).
|
|
45
|
+
export const OKTA_IDP_HOST = "integrator-4707698.okta.com";
|
|
46
|
+
export const OKTA_SSO_DOMAIN = "okta-e2e.supplyguard.test";
|
|
47
|
+
export const OKTA_PERSONA = "sso-convert-acceptance@okta-e2e.supplyguard.test";
|
|
48
|
+
export const OKTA_PASSWORD_ENV = "OKTA_E2E_USER_PASSWORD";
|
|
49
|
+
export const SG_OKTA_RAW_BINDING = Object.freeze({
|
|
50
|
+
...SG_RAW_BINDING,
|
|
51
|
+
staging_session_pool: {
|
|
52
|
+
...SG_RAW_BINDING.staging_session_pool,
|
|
53
|
+
personas: {
|
|
54
|
+
...SG_RAW_BINDING.staging_session_pool.personas,
|
|
55
|
+
"okta-org-admin": {
|
|
56
|
+
email: OKTA_PERSONA,
|
|
57
|
+
password_env: OKTA_PASSWORD_ENV,
|
|
58
|
+
default_count: 1,
|
|
59
|
+
sign_in: {
|
|
60
|
+
kind: "okta-saml",
|
|
61
|
+
sso_domain: OKTA_SSO_DOMAIN,
|
|
62
|
+
idp_host: OKTA_IDP_HOST,
|
|
63
|
+
admission_function: "sso-claim-membership",
|
|
64
|
+
admission_route: "/dashboard",
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
export const SG_OKTA_BINDING = parsePoolBinding(SG_OKTA_RAW_BINDING);
|
|
71
|
+
export const SG_OKTA_TARGET = stagingTargetFromBinding(SG_OKTA_BINDING);
|