@mega-yfue/eufy-sdk 0.2.0-beta.15 → 0.2.0-beta.16

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.
@@ -5,6 +5,14 @@ import type { RegionShard } from "../transport/http/mega-client.js";
5
5
  */
6
6
  export interface PersistedSession {
7
7
  userId: string;
8
+ /**
9
+ * The eufy account's own `user_id` — the id the `gtoken` header is hashed from, which is not always the
10
+ * `ap_cloud_user_id` that `userId` prefers.
11
+ *
12
+ * Always written, equal to `userId` when the login reply carried only the one id. Absent therefore means a
13
+ * record from before this was tracked, which {@link isSessionValid} refuses rather than restore.
14
+ */
15
+ accountUserId?: string;
8
16
  authToken: string;
9
17
  geoKey?: string;
10
18
  region: RegionShard;
@@ -50,5 +58,14 @@ export declare class FileSessionStore<T = PersistedSession> implements SessionSt
50
58
  * (e.g. Solix) whose session shape differs but whose freshness rule is identical.
51
59
  */
52
60
  export declare function tokenNotExpired(tokenExpiresAt: number | undefined, skewSec?: number): boolean;
53
- /** A persisted session is usable if it has a token that isn't (near-)expired. */
61
+ /**
62
+ * A persisted session is usable if it carries a complete credential — token, bound ECDH key, and the account
63
+ * id the `gtoken` header is hashed from — whose token isn't (near-)expired.
64
+ *
65
+ * `accountUserId` is part of the credential, not an optional extra: a record without it was written before
66
+ * that id was tracked, so it can only be restored as the other id, which is what the gateway rejects the
67
+ * header on. Nothing in a restored session can recover it either — the id arrives with a login reply. So such
68
+ * a record is refused and one login re-establishes it, rather than reinstating a session whose every
69
+ * authenticated call fails identically.
70
+ */
54
71
  export declare function isSessionValid(s: PersistedSession | null, skewSec?: number): boolean;
package/dist/index.js CHANGED
@@ -153,7 +153,7 @@ function tokenNotExpired(tokenExpiresAt, skewSec = 300) {
153
153
  return true;
154
154
  }
155
155
  function isSessionValid(s, skewSec = 300) {
156
- if (!s?.authToken || !s.shareKey || !s.keyIdent)
156
+ if (!s?.authToken || !s.shareKey || !s.keyIdent || !s.accountUserId)
157
157
  return false;
158
158
  return tokenNotExpired(s.tokenExpiresAt, skewSec);
159
159
  }
@@ -1135,7 +1135,7 @@ var REAUTH_HOLD_OFF_CAP_MS = 30 * 6e4;
1135
1135
  var REAUTH_STABLE_MS = 10 * 6e4;
1136
1136
  var CONTENDED_SESSION_HINT = "another client may be signed in with the same account and device identity, and each login displaces the other's session; give each client its own openudid";
1137
1137
  function tokenRejected(code, msg) {
1138
- return code === EufyCloudErrorCode.SESSION_KICKED || /user_id is empty|invalid.*token|token.*(expired|error|not exist)|kicked|(?:token|session).*does not exist|unauthor/i.test(msg ?? "");
1138
+ return code === EufyCloudErrorCode.SESSION_KICKED || /user_id is empty|invalid[^,]*\btoken\b|\btoken\b[^,]*(expired|error|not exist)|kicked|(?:\btoken\b|\bsession\b)[^,]*does not exist|unauthor/i.test(msg ?? "");
1139
1139
  }
1140
1140
  function withoutTokenEcho(text2) {
1141
1141
  return text2.replace(/token\s*[=:]\s*"?[A-Za-z0-9._-]{8,}"?/gi, "token = <redacted>");
@@ -1147,6 +1147,13 @@ var MegaHttpClient = class {
1147
1147
  sessionKey;
1148
1148
  /** Per-host ECDH session keys for non-mega gateways (e.g. eufylife) keyed by host. */
1149
1149
  sessionKeys = /* @__PURE__ */ new Map();
1150
+ /**
1151
+ * The held credential. `userId` is the login reply's `ap_cloud_user_id` where it has one — the Anker
1152
+ * Passport cloud's id — while `accountUserId` is the eufy account's own `user_id`.
1153
+ *
1154
+ * The `gtoken` header is hashed from `accountUserId`: that is the id the gateway recomputes the header
1155
+ * from, rejecting a disagreement with `"gtoken not equal userid error"`.
1156
+ */
1150
1157
  auth_;
1151
1158
  /** captcha_id of an in-flight challenge, held between login() and solveCaptcha(). */
1152
1159
  pendingCaptchaId;
@@ -1212,7 +1219,12 @@ var MegaHttpClient = class {
1212
1219
  if (!isSessionValid(saved) || !saved)
1213
1220
  return void 0;
1214
1221
  this.region = saved.region;
1215
- this.auth_ = { userId: saved.userId, authToken: saved.authToken, geoKey: saved.geoKey };
1222
+ this.auth_ = {
1223
+ userId: saved.userId,
1224
+ accountUserId: saved.accountUserId,
1225
+ authToken: saved.authToken,
1226
+ geoKey: saved.geoKey
1227
+ };
1216
1228
  this.tokenExpiresAt = saved.tokenExpiresAt;
1217
1229
  this.sessionKey = {
1218
1230
  keyIdent: saved.keyIdent,
@@ -1275,7 +1287,16 @@ var MegaHttpClient = class {
1275
1287
  };
1276
1288
  }
1277
1289
  /**
1278
- * The account-credential headers every authed call carries — `x-auth-token` + `gtoken` (`md5(userId)`).
1290
+ * The id `gtoken` is hashed from — the account's own `user_id`, which is what the gateway recomputes the
1291
+ * header from. One place so the two header paths cannot drift on which of the session's ids that is.
1292
+ *
1293
+ * Call only where `auth_` is already established; every header path guards it.
1294
+ */
1295
+ gtokenUserId() {
1296
+ return this.auth_.accountUserId ?? this.auth_.userId;
1297
+ }
1298
+ /**
1299
+ * The account-credential headers every authed call carries — `x-auth-token` + `gtoken`.
1279
1300
  * One place so the signed path, the key-exchange and the bearer path can't drift on what "authed" means.
1280
1301
  */
1281
1302
  authTokenHeaders() {
@@ -1284,7 +1305,7 @@ var MegaHttpClient = class {
1284
1305
  return {
1285
1306
  "x-auth-token": this.auth_.authToken,
1286
1307
  authorization: this.auth_.authToken,
1287
- gtoken: gtoken(this.auth_.userId)
1308
+ gtoken: gtoken(this.gtokenUserId())
1288
1309
  };
1289
1310
  }
1290
1311
  /**
@@ -1588,7 +1609,7 @@ var MegaHttpClient = class {
1588
1609
  try {
1589
1610
  return await downloadMediaResource(url, {
1590
1611
  "x-auth-token": this.auth_.authToken,
1591
- gtoken: gtoken(this.auth_.userId),
1612
+ gtoken: gtoken(this.gtokenUserId()),
1592
1613
  "app-name": "eufy_mega",
1593
1614
  "model-type": "PHONE",
1594
1615
  "user-agent": this.mediaUserAgent
@@ -1890,17 +1911,19 @@ var MegaHttpClient = class {
1890
1911
  {
1891
1912
  const cap = Object.keys(res).filter((k) => /captcha|answer|picture|image|fa_/i.test(k));
1892
1913
  this.logger.debug("[mega] login resp keys:", Object.keys(res).join(","));
1914
+ this.logger.debug("[mega] ids agree:", res.ap_cloud_user_id === res.user_id);
1893
1915
  if (cap.length)
1894
1916
  this.logger.debug("[mega] captcha/fa:", JSON.stringify(Object.fromEntries(cap.map((k) => [k, res[k]]))));
1895
1917
  }
1896
1918
  const userId = res.ap_cloud_user_id ?? res.user_id ?? res.userId;
1919
+ const accountUserId = res.user_id ?? res.userId;
1897
1920
  const authToken = res.auth_token ?? res.token;
1898
1921
  if (!userId || !authToken)
1899
1922
  throw new Error(`login returned no session: ${JSON.stringify(res).slice(0, 200)}`);
1900
1923
  const faInfo = res.fa_info ?? {};
1901
1924
  const needs2fa = !isVerify && !!faInfo.info;
1902
1925
  if (needs2fa) {
1903
- this.auth_ = { userId, authToken, geoKey: res.geo_key };
1926
+ this.auth_ = { userId, accountUserId, authToken, geoKey: res.geo_key };
1904
1927
  this.pendingCaptchaId = void 0;
1905
1928
  this.pending2fa = true;
1906
1929
  await this.sendVerifyCode(messageType);
@@ -1908,7 +1931,7 @@ var MegaHttpClient = class {
1908
1931
  }
1909
1932
  this.pendingCaptchaId = void 0;
1910
1933
  this.pending2fa = false;
1911
- this.auth_ = { userId, authToken, geoKey: res.geo_key };
1934
+ this.auth_ = { userId, accountUserId, authToken, geoKey: res.geo_key };
1912
1935
  this.tokenExpiresAt = Number(res.token_expires_at ?? 0) || 0;
1913
1936
  this.sessionKey = void 0;
1914
1937
  await this.ensureSessionKey();
@@ -1921,6 +1944,7 @@ var MegaHttpClient = class {
1921
1944
  return;
1922
1945
  this.store.save({
1923
1946
  userId: this.auth_.userId,
1947
+ accountUserId: this.auth_.accountUserId ?? this.auth_.userId,
1924
1948
  authToken: this.auth_.authToken,
1925
1949
  geoKey: this.auth_.geoKey,
1926
1950
  region: this.region,
@@ -26406,6 +26430,11 @@ var SolixClient = class {
26406
26430
  * Turn a decrypted `/passport/login` payload into an `ok`/`2fa` result, establishing the session on
26407
26431
  * `ok`. The passport marks a pending 2FA with a non-empty `fa_info.info`, and empties it once the code
26408
26432
  * has been satisfied.
26433
+ *
26434
+ * `gtoken` is hashed from `ap_cloud_user_id` where the reply carries one, `user_id` otherwise. Whether
26435
+ * this gateway recomputes the header from `user_id` specifically — as the mega gateway does, rejecting a
26436
+ * disagreement with `"gtoken not equal userid error"` — is unverified here: no Solix response has been
26437
+ * observed refusing the header, which is consistent with the two ids agreeing on the accounts seen.
26409
26438
  */
26410
26439
  classifyLogin(data, isVerify) {
26411
26440
  const userId = data.ap_cloud_user_id ?? data.user_id;