@kweaver-ai/kweaver-sdk 0.6.3 → 0.6.5

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.
@@ -1,3 +1,5 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { createPublicKey } from "node:crypto";
1
3
  import { isNoAuth } from "../config/no-auth.js";
2
4
  import { deleteClientConfig, getCurrentPlatform, loadClientConfig, loadTokenConfig, loadUserTokenConfig, resolveUserId, saveClientConfig, saveNoAuthPlatform, saveTokenConfig, setCurrentPlatform, } from "../config/store.js";
3
5
  import { HttpError, NetworkRequestError, fetchWithRetry } from "../utils/http.js";
@@ -6,6 +8,229 @@ const TOKEN_TTL_SECONDS = 3600;
6
8
  const REFRESH_THRESHOLD_SEC = 60;
7
9
  const DEFAULT_REDIRECT_PORT = 9010;
8
10
  const DEFAULT_SCOPE = "openid offline all";
11
+ /**
12
+ * Studioweb hardcoded LOGIN public key (PEM) — the single key used for HTTP `/oauth2/signin`.
13
+ * Source: kweaver-ai/kweaver `deploy/auto_cofig/auto_config.sh` `LOGIN_PUBLIC_KEY`.
14
+ */
15
+ export const STUDIOWEB_LOGIN_PUBLIC_KEY_PEM = `-----BEGIN PUBLIC KEY-----
16
+ MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsyOstgbYuubBi2PUqeVj
17
+ GKlkwVUY6w1Y8d4k116dI2SkZI8fxcjHALv77kItO4jYLVplk9gO4HAtsisnNE2o
18
+ wlYIqdmyEPMwupaeFFFcg751oiTXJiYbtX7ABzU5KQYPjRSEjMq6i5qu/mL67XTk
19
+ hvKwrC83zme66qaKApmKupDODPb0RRkutK/zHfd1zL7sciBQ6psnNadh8pE24w8O
20
+ 2XVy1v2bgSNkGHABgncR7seyIg81JQ3c/Axxd6GsTztjLnlvGAlmT1TphE84mi99
21
+ fUaGD2A1u1qdIuNc+XuisFeNcUW6fct0+x97eS2eEGRr/7qxWmO/P20sFVzXc2bF
22
+ 1QIDAQAB
23
+ -----END PUBLIC KEY-----`;
24
+ /**
25
+ * Default RSA modulus (hex) for `/oauth2/signin` when `__NEXT_DATA__` has no `publicKey` / `modulus`.
26
+ * DIP / EACP / AnyShare-style deployments use the ISFWeb `core/auth` PUBLIC_KEY (1024-bit, exp 65537).
27
+ * Prefer key material from the sign-in page when present.
28
+ */
29
+ export const DEFAULT_SIGNIN_RSA_MODULUS_HEX = "C1D9F84B95AF6B331FBA2D64D76A39CAD7529DA79DB4B3543E4DF3DF21723FEC6F7E2F6602E11037339AE0462DF6B39F94150FC256A505A8CA95BB3699E25C3FB84764D6A1DC3F483A2C1DC4F70925D85725151D0CFBF1EB5A6C4FA0E37ED32FED150C717CD82C528745CDB761D17635AC855421B3CBBEE7D405B2CA5C70CFA7";
30
+ /**
31
+ * Default PEM for HTTP `/oauth2/signin`: **the fixed `STUDIOWEB_LOGIN_PUBLIC_KEY_PEM`** (matches
32
+ * `kweaver-ai/kweaver/deploy/auto_cofig/auto_config.sh` `LOGIN_PUBLIC_KEY`). KWeaver platforms have
33
+ * standardized on this single key — no fallback list, no probing of `__NEXT_DATA__` page key, no
34
+ * `trying next candidate…` noise. Override with `--signin-public-key-file` /
35
+ * `KWEAVER_SIGNIN_RSA_PUBLIC_KEY` if a deployment ever ships a different public key.
36
+ */
37
+ function buildHttpSigninPemCandidates(_parsedMaterial) {
38
+ return [STUDIOWEB_LOGIN_PUBLIC_KEY_PEM];
39
+ }
40
+ /**
41
+ * Build an SPKI PEM from an RSA modulus (hex) and public exponent (default 65537 / 0x10001).
42
+ */
43
+ export function rsaModulusHexToSpkiPem(modulusHex, exponent = 65537) {
44
+ const hex = modulusHex.replace(/\s+/g, "");
45
+ if (!/^[0-9a-fA-F]+$/.test(hex) || hex.length % 2 !== 0) {
46
+ throw new Error("RSA modulus must be an even-length hex string.");
47
+ }
48
+ const nBuf = Buffer.from(hex, "hex");
49
+ const eBytes = [];
50
+ let exp = exponent;
51
+ while (exp > 0) {
52
+ eBytes.unshift(exp & 0xff);
53
+ exp >>= 8;
54
+ }
55
+ const eBuf = Buffer.from(eBytes);
56
+ const key = createPublicKey({
57
+ key: {
58
+ kty: "RSA",
59
+ n: nBuf.toString("base64url"),
60
+ e: eBuf.toString("base64url"),
61
+ },
62
+ format: "jwk",
63
+ });
64
+ return key.export({ type: "spki", format: "pem" });
65
+ }
66
+ /**
67
+ * Import SPKI DER from Base64 (no PEM headers) — same shape as af-agent `RSA.importKey(base64.b64decode(...))`.
68
+ */
69
+ function tryDerSpkiBase64ToPem(material) {
70
+ const trimmed = material.replace(/\s+/g, "");
71
+ if (trimmed.length < 80 || !/^[A-Za-z0-9+/]+=*$/.test(trimmed)) {
72
+ return null;
73
+ }
74
+ try {
75
+ const buf = Buffer.from(trimmed, "base64");
76
+ const key = createPublicKey({ key: buf, format: "der", type: "spki" });
77
+ return key.export({ type: "spki", format: "pem" });
78
+ }
79
+ catch {
80
+ return null;
81
+ }
82
+ }
83
+ function isLikelyRsaHexModulusString(s) {
84
+ const h = s.replace(/\s+/g, "");
85
+ return h.length >= 128 && h.length % 2 === 0 && /^[0-9a-fA-F]+$/.test(h);
86
+ }
87
+ function isLikelySpkiBase64String(s) {
88
+ const t = s.replace(/\s+/g, "");
89
+ if (t.length < 200 || !/^[A-Za-z0-9+/]+=*$/.test(t)) {
90
+ return false;
91
+ }
92
+ return tryDerSpkiBase64ToPem(t) !== null;
93
+ }
94
+ /**
95
+ * PEM from page (`BEGIN PUBLIC KEY` / `BEGIN RSA PUBLIC KEY`), hex modulus, or Base64 SPKI DER.
96
+ * When `material` is missing, uses the built-in modulus (`DEFAULT_SIGNIN_RSA_MODULUS_HEX`) so
97
+ * `--http-signin` does not require extra CLI flags. Opt out with `KWEAVER_SIGNIN_DISALLOW_BUILTIN_MODULUS=1`.
98
+ */
99
+ function resolveSigninPublicKeyPem(material, opts) {
100
+ const disallowBuiltin = opts?.allowBuiltinModulus === false || process.env.KWEAVER_SIGNIN_DISALLOW_BUILTIN_MODULUS === "1";
101
+ if (material?.trim()) {
102
+ const m = material.trim();
103
+ if (m.includes("BEGIN PUBLIC KEY") || m.includes("BEGIN RSA PUBLIC KEY")) {
104
+ return m;
105
+ }
106
+ const hex = m.replace(/\s+/g, "");
107
+ if (/^[0-9a-fA-F]+$/.test(hex) && hex.length % 2 === 0) {
108
+ return rsaModulusHexToSpkiPem(hex);
109
+ }
110
+ const fromDer = tryDerSpkiBase64ToPem(m);
111
+ if (fromDer) {
112
+ return fromDer;
113
+ }
114
+ throw new Error("RSA public key material is present but could not be parsed (expected PEM, hex modulus, or Base64 SPKI).");
115
+ }
116
+ if (disallowBuiltin) {
117
+ throw new Error("No RSA public key in sign-in HTML and built-in modulus disabled (KWEAVER_SIGNIN_DISALLOW_BUILTIN_MODULUS=1). " +
118
+ "Use --signin-public-key-file or KWEAVER_SIGNIN_RSA_PUBLIC_KEY.");
119
+ }
120
+ return rsaModulusHexToSpkiPem(DEFAULT_SIGNIN_RSA_MODULUS_HEX.replace(/\s+/g, ""));
121
+ }
122
+ /**
123
+ * Recursively find a string that looks like PEM, hex modulus, or Base64 SPKI in `pageProps` (nested configs).
124
+ */
125
+ function deepFindSigninRsaMaterial(obj, depth, seen) {
126
+ if (depth < 0 || obj === null || obj === undefined) {
127
+ return undefined;
128
+ }
129
+ if (typeof obj === "string") {
130
+ const t = obj.trim();
131
+ if (!t) {
132
+ return undefined;
133
+ }
134
+ if (t.includes("BEGIN PUBLIC KEY") || t.includes("BEGIN RSA PUBLIC KEY")) {
135
+ return t;
136
+ }
137
+ if (isLikelyRsaHexModulusString(t)) {
138
+ return t.replace(/\s+/g, "");
139
+ }
140
+ if (isLikelySpkiBase64String(t)) {
141
+ return t.replace(/\s+/g, "");
142
+ }
143
+ return undefined;
144
+ }
145
+ if (typeof obj !== "object") {
146
+ return undefined;
147
+ }
148
+ if (seen.has(obj)) {
149
+ return undefined;
150
+ }
151
+ seen.add(obj);
152
+ if (Array.isArray(obj)) {
153
+ for (const el of obj) {
154
+ const r = deepFindSigninRsaMaterial(el, depth - 1, seen);
155
+ if (r) {
156
+ return r;
157
+ }
158
+ }
159
+ return undefined;
160
+ }
161
+ const rec = obj;
162
+ for (const k of Object.keys(rec)) {
163
+ const r = deepFindSigninRsaMaterial(rec[k], depth - 1, seen);
164
+ if (r) {
165
+ return r;
166
+ }
167
+ }
168
+ return undefined;
169
+ }
170
+ /**
171
+ * Regex fallback when JSON path differs (escaped quotes, minified bundles, inline scripts).
172
+ * Some deployments put the SPKI Base64 as a raw substring (no JSON key).
173
+ */
174
+ function extractSigninRsaMaterialFromHtml(html) {
175
+ const pemPub = html.match(/-----BEGIN PUBLIC KEY-----[\s\S]*?-----END PUBLIC KEY-----/);
176
+ if (pemPub) {
177
+ return pemPub[0].trim();
178
+ }
179
+ const pemRsa = html.match(/-----BEGIN RSA PUBLIC KEY-----[\s\S]*?-----END RSA PUBLIC KEY-----/);
180
+ if (pemRsa) {
181
+ return pemRsa[0].trim();
182
+ }
183
+ const jsonPatterns = [
184
+ /"modulus"\s*:\s*"([0-9a-fA-F]{128,})"/,
185
+ /'modulus'\s*:\s*'([0-9a-fA-F]{128,})'/,
186
+ /"(?:publicKey|rsaPublicKey|public_key|encryptPublicKey|rsaModulus|passwordPublicKey|loginPublicKey|pwdPublicKey|encryptKey)"\s*:\s*"([A-Za-z0-9+/=\s]{200,})"/,
187
+ /'(?:publicKey|rsaPublicKey|public_key)'\s*:\s*'([A-Za-z0-9+/=]{200,})'/,
188
+ ];
189
+ for (const re of jsonPatterns) {
190
+ const m = html.match(re);
191
+ if (m?.[1]) {
192
+ return m[1].replace(/\s+/g, "");
193
+ }
194
+ }
195
+ // Raw SPKI Base64 blocks (2048-bit RSA SPKI often starts with MIIBIjAN…)
196
+ const rawSpki = html.match(/(MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA[A-Za-z0-9+/=]{80,800})/);
197
+ if (rawSpki) {
198
+ return rawSpki[1].replace(/\s+/g, "");
199
+ }
200
+ const rawSpki1024 = html.match(/(MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ[A-Za-z0-9+/=\s]{80,500})/);
201
+ if (rawSpki1024) {
202
+ return rawSpki1024[1].replace(/\s+/g, "");
203
+ }
204
+ return undefined;
205
+ }
206
+ /** Match `rsa.min` / Studio `rsaEncrypt`: base64 with newline every 64 chars (EACP may validate format). */
207
+ function formatPasswordBase64LikeRsaMin(b64) {
208
+ return b64.replace(/(.{64})/g, "$1\n");
209
+ }
210
+ function extractRsaPublicKeyMaterialFromPageProps(pageProps) {
211
+ const keys = [
212
+ "publicKey",
213
+ "rsaPublicKey",
214
+ "public_key",
215
+ "modulus",
216
+ "encryptPublicKey",
217
+ "pubKey",
218
+ "rsaModulus",
219
+ "passwordPublicKey",
220
+ "loginPublicKey",
221
+ "encryptKey",
222
+ "pwdPublicKey",
223
+ "modulusHex",
224
+ "rsaPublicKeyHex",
225
+ ];
226
+ for (const k of keys) {
227
+ const v = pageProps[k];
228
+ if (typeof v === "string" && v.trim()) {
229
+ return v.trim();
230
+ }
231
+ }
232
+ return deepFindSigninRsaMaterial(pageProps, 5, new Set());
233
+ }
9
234
  /** Best-effort fetch of display name via EACP userinfo (ShareServer). */
10
235
  async function fetchDisplayName(baseUrl, accessToken, tlsInsecure) {
11
236
  try {
@@ -115,7 +340,8 @@ export function normalizeBaseUrl(value) {
115
340
  * Temporarily disable TLS certificate verification for Node `fetch` (sets
116
341
  * NODE_TLS_REJECT_UNAUTHORIZED). Used for `--insecure` login and token refresh.
117
342
  */
118
- async function runWithTlsInsecure(tlsInsecure, fn) {
343
+ /** @internal Exported for CLI env-only identity resolution (`env-snapshot.ts`). */
344
+ export async function runWithTlsInsecure(tlsInsecure, fn) {
119
345
  if (!tlsInsecure) {
120
346
  return fn();
121
347
  }
@@ -697,6 +923,419 @@ export async function playwrightLogin(baseUrl, options) {
697
923
  return token;
698
924
  });
699
925
  }
926
+ function mergeCookieJarForSignin(existing, response) {
927
+ const setCookies = typeof response.headers.getSetCookie === "function"
928
+ ? response.headers.getSetCookie()
929
+ : (() => {
930
+ const raw = response.headers.get("set-cookie");
931
+ return raw ? [raw] : [];
932
+ })();
933
+ const map = new Map();
934
+ for (const part of existing
935
+ .split(";")
936
+ .map((s) => s.trim())
937
+ .filter(Boolean)) {
938
+ const eq = part.indexOf("=");
939
+ if (eq > 0)
940
+ map.set(part.slice(0, eq), part.slice(eq + 1));
941
+ }
942
+ for (const sc of setCookies) {
943
+ const first = sc.split(";")[0]?.trim() ?? "";
944
+ const eq = first.indexOf("=");
945
+ if (eq > 0)
946
+ map.set(first.slice(0, eq), first.slice(eq + 1));
947
+ }
948
+ return [...map.entries()].map(([k, v]) => `${k}=${v}`).join("; ");
949
+ }
950
+ /**
951
+ * Parse query parameters from a `Location` header value (absolute or relative URL).
952
+ */
953
+ function parseQueryFromLocationHeader(location) {
954
+ const q = location.includes("?") ? location.slice(location.indexOf("?")) : "";
955
+ const params = new URLSearchParams(q.startsWith("?") ? q.slice(1) : q);
956
+ const out = {};
957
+ params.forEach((v, k) => {
958
+ out[k] = v;
959
+ });
960
+ return out;
961
+ }
962
+ /**
963
+ * Parse Next.js `__NEXT_DATA__` from the OAuth2 sign-in HTML shell (CSRF + optional challenge/remember for POST /oauth2/signin).
964
+ * Hydra `login_challenge` may appear only in the sign-in URL; use that when `pageProps.challenge` is absent.
965
+ */
966
+ export function parseSigninPageHtmlProps(html) {
967
+ const m = html.match(/<script[^>]*\bid=["']__NEXT_DATA__["'][^>]*>([\s\S]*?)<\/script>/i);
968
+ if (!m) {
969
+ throw new Error("Could not find __NEXT_DATA__ on the sign-in page.");
970
+ }
971
+ const data = JSON.parse(m[1]);
972
+ const pageProps = data.props?.pageProps;
973
+ if (!pageProps) {
974
+ throw new Error("Invalid __NEXT_DATA__: missing pageProps.");
975
+ }
976
+ const challenge = pageProps.challenge;
977
+ const csrftoken = pageProps.csrftoken ?? pageProps._csrf;
978
+ if (typeof csrftoken !== "string") {
979
+ throw new Error("Sign-in page did not expose csrftoken (expected in __NEXT_DATA__.props.pageProps).");
980
+ }
981
+ const challengeStr = typeof challenge === "string" ? challenge : undefined;
982
+ const rememberRaw = pageProps.remember;
983
+ const remember = typeof rememberRaw === "boolean"
984
+ ? rememberRaw
985
+ : typeof rememberRaw === "string"
986
+ ? rememberRaw === "true"
987
+ : undefined;
988
+ let rsaPublicKeyMaterial = extractRsaPublicKeyMaterialFromPageProps(pageProps);
989
+ if (!rsaPublicKeyMaterial) {
990
+ rsaPublicKeyMaterial = deepFindSigninRsaMaterial(data, 10, new Set());
991
+ }
992
+ if (!rsaPublicKeyMaterial) {
993
+ rsaPublicKeyMaterial = extractSigninRsaMaterialFromHtml(html);
994
+ }
995
+ return { challenge: challengeStr, csrftoken, remember, rsaPublicKeyMaterial };
996
+ }
997
+ async function followSigninRedirectsUntilCallback(startUrl, initialJar, state, redirectUri, base, scope) {
998
+ let url = startUrl;
999
+ let jar = initialJar;
1000
+ const callbackHost = new URL(redirectUri).origin;
1001
+ const callbackPath = new URL(redirectUri).pathname;
1002
+ for (let hop = 0; hop < 40; hop++) {
1003
+ const resp = await fetch(url, {
1004
+ method: "GET",
1005
+ headers: {
1006
+ Cookie: jar,
1007
+ Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
1008
+ },
1009
+ redirect: "manual",
1010
+ });
1011
+ jar = mergeCookieJarForSignin(jar, resp);
1012
+ if (resp.status === 302 || resp.status === 303 || resp.status === 307 || resp.status === 308) {
1013
+ const loc = resp.headers.get("location");
1014
+ if (!loc) {
1015
+ throw new HttpError(resp.status, "Missing Location", "");
1016
+ }
1017
+ const next = new URL(loc, url);
1018
+ if (next.origin === callbackHost && next.pathname === callbackPath) {
1019
+ const code = next.searchParams.get("code");
1020
+ const st = next.searchParams.get("state");
1021
+ if (st !== state) {
1022
+ throw new Error("OAuth2 state mismatch — possible CSRF attack.");
1023
+ }
1024
+ const err = next.searchParams.get("error");
1025
+ if (err) {
1026
+ const desc = next.searchParams.get("error_description") ?? "";
1027
+ throw new Error(desc ? `Authorization failed: ${err} — ${desc}` : `Authorization failed: ${err}`);
1028
+ }
1029
+ if (!code) {
1030
+ throw new Error("Callback URL missing authorization code.");
1031
+ }
1032
+ return { code, jar };
1033
+ }
1034
+ url = next.href;
1035
+ continue;
1036
+ }
1037
+ if (resp.status === 200) {
1038
+ const html = await resp.text();
1039
+ const consentResult = await tryAcceptConsentAfterSignin(base, url, html, jar, scope, state, redirectUri);
1040
+ if (consentResult) {
1041
+ return consentResult;
1042
+ }
1043
+ throw new Error(`Unexpected OAuth page (HTTP 200) at ${url.slice(0, 120)}… ` +
1044
+ `If this is a consent or MFA screen, use browser login or Playwright.`);
1045
+ }
1046
+ const text = await resp.text().catch(() => "");
1047
+ throw new HttpError(resp.status, resp.statusText, text);
1048
+ }
1049
+ throw new Error("Too many OAuth redirects.");
1050
+ }
1051
+ async function tryAcceptConsentAfterSignin(base, pageUrl, html, jar, scope, state, redirectUri) {
1052
+ let data;
1053
+ try {
1054
+ const m = html.match(/<script[^>]*\bid=["']__NEXT_DATA__["'][^>]*>([\s\S]*?)<\/script>/i);
1055
+ if (!m) {
1056
+ return null;
1057
+ }
1058
+ data = JSON.parse(m[1]);
1059
+ }
1060
+ catch {
1061
+ return null;
1062
+ }
1063
+ const pageProps = data.props?.pageProps;
1064
+ if (!pageProps) {
1065
+ return null;
1066
+ }
1067
+ const consentChallenge = pageProps.consent_challenge;
1068
+ if (typeof consentChallenge !== "string") {
1069
+ return null;
1070
+ }
1071
+ const scopes = scope.split(/\s+/).filter(Boolean);
1072
+ const body = new URLSearchParams();
1073
+ body.set("consent_challenge", consentChallenge);
1074
+ for (const s of scopes) {
1075
+ body.append("grant_scope", s);
1076
+ }
1077
+ const resp = await fetch(`${base}/oauth2/consent`, {
1078
+ method: "POST",
1079
+ headers: {
1080
+ Cookie: jar,
1081
+ "Content-Type": "application/x-www-form-urlencoded",
1082
+ Accept: "text/html,application/xhtml+xml,application/json;q=0.9,*/*;q=0.8",
1083
+ },
1084
+ body: body.toString(),
1085
+ redirect: "manual",
1086
+ });
1087
+ const newJar = mergeCookieJarForSignin(jar, resp);
1088
+ if (resp.status === 302 || resp.status === 303 || resp.status === 307) {
1089
+ const loc = resp.headers.get("location");
1090
+ if (!loc) {
1091
+ throw new HttpError(resp.status, "Missing Location after consent", "");
1092
+ }
1093
+ return followSigninRedirectsUntilCallback(new URL(loc, pageUrl).href, newJar, state, redirectUri, base, scope);
1094
+ }
1095
+ return null;
1096
+ }
1097
+ const STUDIOWEB_SHELL_UNAVAILABLE_SNIPPETS = [
1098
+ "Studioweb signin endpoint not available",
1099
+ "Cannot reach studioweb signin endpoint",
1100
+ ];
1101
+ /**
1102
+ * True when {@link oauth2PasswordSigninLogin} failed because the Studio web sign-in shell
1103
+ * (`/interface/studioweb/login`) is missing or unreachable — callers may fall back to Playwright.
1104
+ */
1105
+ export function isStudiowebShellUnavailableError(err) {
1106
+ const msg = err instanceof Error ? err.message : String(err);
1107
+ return STUDIOWEB_SHELL_UNAVAILABLE_SNIPPETS.some((s) => msg.includes(s));
1108
+ }
1109
+ /**
1110
+ * OAuth2 Authorization Code login using HTTP **only**: `GET /oauth2/signin` (Next.js shell) and
1111
+ * `POST /oauth2/signin` with an RSA PKCS#1 v1.5–encrypted password (same as the browser `rsa.min` / Studio
1112
+ * `core/mediator/auth` path).
1113
+ *
1114
+ * `/oauth2/auth` uses `product` `adp` by default (KWeaver Studio shell); set `oauthProduct` or `KWEAVER_OAUTH_PRODUCT` for DIP (`dip`).
1115
+ * Password ciphertext defaults to **single-line base64** (PyCrypto-style); set `KWEAVER_SIGNIN_PASSWORD_B64_RSA_MIN=1` for rsa.min-style wrapped lines.
1116
+ */
1117
+ export async function oauth2PasswordSigninLogin(baseUrl, options) {
1118
+ return runWithTlsInsecure(options.tlsInsecure, async () => {
1119
+ const { publicEncrypt, constants: cryptoConstants } = await import("node:crypto");
1120
+ const { randomBytes } = await import("node:crypto");
1121
+ const base = normalizeBaseUrl(baseUrl);
1122
+ const port = options.port ?? DEFAULT_REDIRECT_PORT;
1123
+ const scope = options.scope ?? DEFAULT_SCOPE;
1124
+ const redirectUri = `http://127.0.0.1:${port}/callback`;
1125
+ const state = randomBytes(12).toString("hex");
1126
+ const oauthProduct = options.oauthProduct?.trim() ||
1127
+ (typeof process.env.KWEAVER_OAUTH_PRODUCT === "string" && process.env.KWEAVER_OAUTH_PRODUCT.trim()
1128
+ ? process.env.KWEAVER_OAUTH_PRODUCT.trim()
1129
+ : "adp");
1130
+ // Pre-flight: verify studioweb signin shell exists (same entry as deploy auto_config.sh get_token).
1131
+ // If the deployment lacks studioweb, abort before OAuth client registration.
1132
+ const studiowebProbeUrl = `${base}/interface/studioweb/login?lang=zh-cn&state=${encodeURIComponent(state)}` +
1133
+ `&x-forwarded-prefix=&integrated=false&product=${encodeURIComponent(oauthProduct)}&_t=${Date.now()}`;
1134
+ let probeResp;
1135
+ try {
1136
+ probeResp = await fetch(studiowebProbeUrl, { method: "GET", redirect: "manual" });
1137
+ }
1138
+ catch (cause) {
1139
+ throw new Error(`Cannot reach studioweb signin endpoint at ${base}/interface/studioweb/login. ` +
1140
+ `The deployment may not include studioweb. Use \`kweaver auth login ${base}\` ` +
1141
+ `(OAuth code flow) instead.\n Cause: ${cause instanceof Error ? cause.message : String(cause)}`);
1142
+ }
1143
+ const probeOk2xx = probeResp.status >= 200 && probeResp.status < 300;
1144
+ const probeOkRedirect = [301, 302, 303, 307, 308].includes(probeResp.status);
1145
+ await probeResp.text().catch(() => "");
1146
+ if (!probeOk2xx && !probeOkRedirect) {
1147
+ throw new Error(`Studioweb signin endpoint not available at ${base}/interface/studioweb/login ` +
1148
+ `(HTTP ${probeResp.status}). The deployment may not include studioweb. ` +
1149
+ `Use \`kweaver auth login ${base}\` (OAuth code flow) instead.`);
1150
+ }
1151
+ let client;
1152
+ try {
1153
+ client = await resolveOrRegisterClient(base, redirectUri, scope, {
1154
+ clientId: options.clientId,
1155
+ clientSecret: options.clientSecret,
1156
+ });
1157
+ }
1158
+ catch (e) {
1159
+ if (e instanceof HttpError && e.status === 404) {
1160
+ process.stderr.write("OAuth2 endpoint not found (404). Saving platform in no-auth mode.\n");
1161
+ return saveNoAuthPlatform(base, { tlsInsecure: options.tlsInsecure });
1162
+ }
1163
+ throw e;
1164
+ }
1165
+ const usePkce = !client.clientSecret;
1166
+ const pkce = usePkce ? await generatePkce() : null;
1167
+ const authParams = new URLSearchParams({
1168
+ redirect_uri: redirectUri,
1169
+ "x-forwarded-prefix": "",
1170
+ client_id: client.clientId,
1171
+ scope,
1172
+ response_type: "code",
1173
+ state,
1174
+ lang: "zh-cn",
1175
+ product: oauthProduct,
1176
+ });
1177
+ if (pkce) {
1178
+ authParams.set("code_challenge", pkce.challenge);
1179
+ authParams.set("code_challenge_method", "S256");
1180
+ }
1181
+ const authUrl = `${base}/oauth2/auth?${authParams.toString()}`;
1182
+ let jar = "";
1183
+ const authResp = await fetch(authUrl, { method: "GET", redirect: "manual" });
1184
+ jar = mergeCookieJarForSignin(jar, authResp);
1185
+ if (authResp.status !== 302 && authResp.status !== 303 && authResp.status !== 307) {
1186
+ const t = await authResp.text();
1187
+ throw new HttpError(authResp.status, authResp.statusText, t);
1188
+ }
1189
+ const authLoc = authResp.headers.get("location");
1190
+ if (!authLoc) {
1191
+ throw new HttpError(authResp.status, "Missing Location after /oauth2/auth", "");
1192
+ }
1193
+ const signinUrl = new URL(authLoc, base);
1194
+ if (!signinUrl.pathname.includes("signin")) {
1195
+ throw new Error(`Expected redirect to a sign-in page, got: ${authLoc}`);
1196
+ }
1197
+ const signinPageResp = await fetch(signinUrl.href, {
1198
+ method: "GET",
1199
+ headers: {
1200
+ Cookie: jar,
1201
+ Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
1202
+ },
1203
+ redirect: "manual",
1204
+ });
1205
+ jar = mergeCookieJarForSignin(jar, signinPageResp);
1206
+ if (signinPageResp.status !== 200) {
1207
+ const t = await signinPageResp.text();
1208
+ throw new HttpError(signinPageResp.status, signinPageResp.statusText, t);
1209
+ }
1210
+ const html = await signinPageResp.text();
1211
+ const parsed = parseSigninPageHtmlProps(html);
1212
+ const loginChallenge = signinUrl.searchParams.get("login_challenge")?.trim() || parsed.challenge?.trim();
1213
+ if (!loginChallenge) {
1214
+ throw new Error("Could not resolve login challenge: missing login_challenge in sign-in URL and __NEXT_DATA__.props.pageProps.challenge.");
1215
+ }
1216
+ const csrftoken = parsed.csrftoken;
1217
+ const remember = parsed.remember ?? false;
1218
+ const keyPath = options.signinPublicKeyPemPath?.trim() ||
1219
+ (typeof process.env.KWEAVER_SIGNIN_RSA_PUBLIC_KEY === "string"
1220
+ ? process.env.KWEAVER_SIGNIN_RSA_PUBLIC_KEY.trim()
1221
+ : "");
1222
+ const pems = keyPath
1223
+ ? [
1224
+ resolveSigninPublicKeyPem((await readFile(keyPath, "utf8")).trim(), {
1225
+ allowBuiltinModulus: false,
1226
+ }),
1227
+ ]
1228
+ : buildHttpSigninPemCandidates(parsed.rsaPublicKeyMaterial);
1229
+ const usePlainB64 = options.signinPasswordBase64Plain === true
1230
+ ? true
1231
+ : options.signinPasswordBase64Plain === false
1232
+ ? false
1233
+ : process.env.KWEAVER_SIGNIN_PASSWORD_B64_RSA_MIN !== "1";
1234
+ // Body shape matches browser `POST /oauth2/signin` (EACP / oauth2-ui); omitting vcode/dualfactorauthinfo
1235
+ // causes 400 from eachttpserver (invalid parameter).
1236
+ const postBody = {
1237
+ _csrf: csrftoken,
1238
+ challenge: loginChallenge,
1239
+ account: options.username,
1240
+ password: "",
1241
+ vcode: { id: "", content: "" },
1242
+ dualfactorauthinfo: {
1243
+ validcode: { vcode: "" },
1244
+ OTP: { OTP: "" },
1245
+ },
1246
+ remember,
1247
+ device: {
1248
+ name: "",
1249
+ description: "",
1250
+ client_type: "unknown",
1251
+ udids: [],
1252
+ },
1253
+ };
1254
+ const origin = new URL(base).origin;
1255
+ /** Some gateways (e.g. DIP) return HTTP 200 + `{"redirect":"..."}` instead of 3xx Location. */
1256
+ let signinRedirectFromJson;
1257
+ // Single fixed RSA public key (STUDIOWEB_LOGIN_PUBLIC_KEY_PEM) unless the caller overrides via
1258
+ // --signin-public-key-file / KWEAVER_SIGNIN_RSA_PUBLIC_KEY. No fallback list, no candidate noise.
1259
+ const pem = pems[0];
1260
+ const encrypted = publicEncrypt({ key: pem, padding: cryptoConstants.RSA_PKCS1_PADDING }, Buffer.from(options.password, "utf8"));
1261
+ const rawB64 = encrypted.toString("base64");
1262
+ const passwordB64 = usePlainB64 ? rawB64 : formatPasswordBase64LikeRsaMin(rawB64);
1263
+ postBody.password = passwordB64;
1264
+ const postResp = await fetch(`${base}/oauth2/signin`, {
1265
+ method: "POST",
1266
+ headers: {
1267
+ Cookie: jar,
1268
+ "Content-Type": "application/json",
1269
+ Accept: "application/json, text/plain, */*",
1270
+ Origin: origin,
1271
+ Referer: signinUrl.href,
1272
+ },
1273
+ body: JSON.stringify(postBody),
1274
+ redirect: "manual",
1275
+ });
1276
+ jar = mergeCookieJarForSignin(jar, postResp);
1277
+ if (postResp.status !== 302 && postResp.status !== 303 && postResp.status !== 307) {
1278
+ const bodyText = await postResp.text();
1279
+ if (/RSA_private_decrypt/i.test(bodyText)) {
1280
+ throw new Error("HTTP sign-in: RSA ciphertext rejected by server. The built-in STUDIOWEB_LOGIN_PUBLIC_KEY_PEM " +
1281
+ "does not match this deployment's `/oauth2/signin` public key. Provide the correct key via " +
1282
+ "--signin-public-key-file <pem> or KWEAVER_SIGNIN_RSA_PUBLIC_KEY=...");
1283
+ }
1284
+ if (postResp.status === 200) {
1285
+ const ct = postResp.headers.get("content-type") ?? "";
1286
+ const looksLikeJson = ct.includes("application/json") || /^\s*\{/.test(bodyText);
1287
+ if (looksLikeJson) {
1288
+ let j;
1289
+ try {
1290
+ j = JSON.parse(bodyText);
1291
+ }
1292
+ catch {
1293
+ throw new Error(`Sign-in failed: ${bodyText.slice(0, 500)}`);
1294
+ }
1295
+ const redir = j.redirect;
1296
+ if (typeof redir === "string" && redir.trim() !== "") {
1297
+ signinRedirectFromJson = redir.trim();
1298
+ }
1299
+ else {
1300
+ const msg = typeof j.message === "string"
1301
+ ? j.message
1302
+ : typeof j.error === "string"
1303
+ ? j.error
1304
+ : bodyText.slice(0, 500);
1305
+ throw new Error(`Sign-in failed: ${msg}`);
1306
+ }
1307
+ }
1308
+ else {
1309
+ throw new Error("Sign-in POST returned 200 without redirect. Check password, CSRF, or RSA public key PEM.");
1310
+ }
1311
+ }
1312
+ else {
1313
+ throw new HttpError(postResp.status, postResp.statusText, bodyText);
1314
+ }
1315
+ }
1316
+ let code;
1317
+ if (signinRedirectFromJson) {
1318
+ const out = await followSigninRedirectsUntilCallback(new URL(signinRedirectFromJson, base).href, jar, state, redirectUri, base, scope);
1319
+ code = out.code;
1320
+ }
1321
+ else if (postResp.status === 302 || postResp.status === 303 || postResp.status === 307) {
1322
+ const loc = postResp.headers.get("location");
1323
+ if (!loc) {
1324
+ throw new HttpError(postResp.status, "Missing Location after sign-in", "");
1325
+ }
1326
+ const out = await followSigninRedirectsUntilCallback(new URL(loc, base).href, jar, state, redirectUri, base, scope);
1327
+ code = out.code;
1328
+ }
1329
+ else {
1330
+ throw new Error("HTTP sign-in: exhausted RSA key candidates without redirect");
1331
+ }
1332
+ const token = await exchangeCodeForToken(base, code, client.clientId, client.clientSecret, redirectUri, pkce?.verifier, options.tlsInsecure);
1333
+ const copyCommand = buildCopyCommand(base, client.clientId, client.clientSecret, token.refreshToken, options.tlsInsecure);
1334
+ process.stderr.write("\nHTTP sign-in: copy this command for headless hosts:\n\n" + copyCommand + "\n\n");
1335
+ setCurrentPlatform(base);
1336
+ return token;
1337
+ });
1338
+ }
700
1339
  /**
701
1340
  * Log in on a headless machine using OAuth2 client credentials and a refresh token (no browser).
702
1341
  * Exchanges the refresh token for a new access token and persists ~/.kweaver/ state.
@@ -952,6 +1591,13 @@ export async function withTokenRetry(fn) {
952
1591
  throw error;
953
1592
  }
954
1593
  const platformUrl = normalizeBaseUrl(token.baseUrl);
1594
+ // env-sourced token: no refresh_token / OAuth client — refresh is impossible.
1595
+ // Surface an env-aware hint instead of telling the user to `auth login` (which writes to disk).
1596
+ if (process.env.KWEAVER_TOKEN && !token.refreshToken) {
1597
+ throw new Error(`Authentication failed (401) for ${platformUrl}. Your KWEAVER_TOKEN appears to be invalid or expired.\n` +
1598
+ ` - Refresh the token and re-export: export KWEAVER_TOKEN=<new-token>\n` +
1599
+ ` - Or run \`kweaver auth login ${platformUrl}\` to save a full session (with refresh_token) to ~/.kweaver/.`, { cause: error });
1600
+ }
955
1601
  const envUser = process.env.KWEAVER_USER;
956
1602
  let latest;
957
1603
  if (envUser) {