Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions app/auth_oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,18 @@ def loads_strict(text):
return json.loads(text, parse_constant=_reject_constant)


class AuthTokenTypeError(ValueError):
"""A credential token field is not a plaintext string (e.g. an encrypted envelope)."""


def ensure_plaintext_tokens(auth: dict) -> None:
"""Reject non-string token fields so encrypted envelope objects never reach upstream headers."""
for field in ("accessToken", "refreshToken"):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Guard token aliases during direct directory scans

Although import validation now checks aliases, the documented direct-copy path scans .info files without calling validation or normalization, and this runtime guard checks only canonical names. Consequently, a file with a plaintext accessToken and object-valued refresh_token is admitted to the pool; it serves until refresh, when _refresh_locked() reads the absent refreshToken and sends an empty refresh header instead of rejecting and auditing the envelope. Include access_token, token, and refresh_token here (or normalize scanned credentials before summary()).

Useful? React with 👍 / 👎.

value = auth.get(field)
if value is not None and not isinstance(value, str):
Comment thread
maiphucgiang marked this conversation as resolved.
raise AuthTokenTypeError(
f"auth.{field} 不是明文字符串(可能为加密信封格式),请改用 OAuth 登录获取明文凭据")

def validate_cred_data(data) -> tuple[str | None, str | None]:
"""Validate credentials and return either the account UID or a safe failure reason."""
if not isinstance(data, dict):
Expand All @@ -85,6 +97,11 @@ def validate_cred_data(data) -> tuple[str | None, str | None]:
token = auth.get("accessToken") or auth.get("access_token") or auth.get("token")
if not isinstance(token, str) or not token:
return None, "缺少有效的 accessToken"
# Envelope objects must be rejected before persistence, including token aliases.
for field in ("accessToken", "access_token", "token", "refreshToken", "refresh_token"):
value = auth.get(field)
if value is not None and not isinstance(value, str):
return None, f"{field} 不是明文字符串(可能为加密信封格式)"
for field in ("expiresAt", "lastRefreshTime"):
value = auth.get(field)
if value is None:
Expand Down
52 changes: 50 additions & 2 deletions converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,12 @@ def _session(self) -> dict:
self._load_if_stale()
if self._cached is None:
raise RuntimeError(f"无法读取 auth 文件:{self.path}")
return self._cached
s = self._cached
auth = s.get("auth")
if isinstance(auth, dict):
# Encrypted envelope tokens must fail fast instead of leaking into upstream headers.
auth_oauth.ensure_plaintext_tokens(auth)
return s

def _is_expired(self) -> bool:
s = self._session()
Expand Down Expand Up @@ -517,7 +522,10 @@ def __init__(self, paths: list[Path] | None = None, scan: bool = False,
self._ledger = None # Prefer credits expiring sooner.
self._capacity = AccountCapacity()
self._scan = scan # Rescan credentials before selection.
# Explicit paths must survive rejection or eviction so a repaired file re-enters.
self._configured: list[str] = []
self._ignored_duplicates: set[str] = set()
self._ignored_invalid: dict[str, str | None] = {}
self._sync_pending: set[str] = set()
self._syncing: set[str] = set()
self._sync_event = threading.Event()
Expand All @@ -535,6 +543,8 @@ def reload(self, paths: list[Path], *, reset: bool = True):
for entry in self._entries if entry.get("uid")}
for path in paths:
cid = str(Path(path).resolve())
if cid not in self._configured:
self._configured.append(cid)
Comment on lines +546 to +547

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Track only genuinely configured credential paths

When running with --auth-file (scan=False), every path passed to reload() is now permanently treated as configured, including credentials added later through the admin/OAuth import paths. If such a dynamically added credential is deleted, remove_file() never removes it from _configured, so every request continues probing that tombstone and an unrelated external file recreated under the same name is silently admitted even though explicit mode is supposed to use only configured files. Over credential churn this list and the per-request filesystem work also grow without bound; retain constructor paths separately or remove non-explicit paths on intentional deletion.

Useful? React with 👍 / 👎.

if not os.path.exists(cid):
continue
entry = by_id.get(cid)
Expand All @@ -543,6 +553,13 @@ def reload(self, paths: list[Path], *, reset: bool = True):
entry["cm"].invalidate()
try:
summary = entry["cm"].summary()
except auth_oauth.AuthTokenTypeError:
# An envelope replacement must not keep serving the stale identity.
by_id.pop(cid, None)
if entry.get("uid"):
have_uids.pop(entry.get("account_key"), None)
self._drop_entry(entry)
continue
except Exception:
continue # A damaged file must not block other credentials.
generation = entry["cm"]._generation
Expand Down Expand Up @@ -576,6 +593,18 @@ def reload(self, paths: list[Path], *, reset: bool = True):
manager = CredentialManager(Path(cid))
try:
summary = manager.summary()
except auth_oauth.AuthTokenTypeError as error:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Evict existing entries that become invalid

When an already-pooled .info file is externally replaced with an envelope credential, reload() takes the existing-entry branch, whose broad exception handler returns before this new rejection logic runs. The stale entry therefore remains indefinitely with its old UID and identity, produces no rejection log, and can cause find_by_uid() to reject a valid repair imported under another filename as an account conflict. Handle AuthTokenTypeError in the existing-entry branch too and remove or invalidate that entry.

Useful? React with 👍 / 👎.

# Envelope tokens cannot be refreshed or used; keep such files out of the pool.
try:
digest = hashlib.sha256(Path(cid).read_bytes()).hexdigest()
except OSError:
digest = None
if self._ignored_invalid.get(cid) != digest:
# Suppression is content-keyed so a different invalid credential
# reusing the path warns again even without a scan while missing.
_log(f"[cred] 拒绝入池(入库校验失败:{error}): {Path(cid).name}")
self._ignored_invalid[cid] = digest
continue

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Retain rejected explicit paths for later rescans

When the pool is constructed with --auth-file (scan=False) and a configured file contains an envelope token, this continue leaves the path out of _entries; _rescan() derives its paths solely from the remaining entries in that mode. Consequently, replacing the rejected file with a valid plaintext credential is never detected, and a sole credential remains unavailable until the process restarts. The same permanent loss occurs when an existing explicit entry is evicted by the new AuthTokenTypeError branch, so configured paths need to be tracked independently and retried.

Useful? React with 👍 / 👎.

except Exception:
summary = {}
uid = summary.get("uid")
Expand All @@ -594,6 +623,7 @@ def reload(self, paths: list[Path], *, reset: bool = True):
self._entries.append(entry)
by_id[cid] = entry
self._ignored_duplicates.discard(cid)
self._ignored_invalid.pop(cid, None)
if uid:
have_uids[identity_key] = cid
self._queue_sync(cid)
Expand Down Expand Up @@ -664,10 +694,27 @@ def apply_if_current(self, cm, generation, update):
update()
return True


def _drop_entry(self, entry):
"""Purge one entry whose file became unusable, mirroring prune()'s cleanup."""
cid = entry["id"]
if self._ledger is not None:
self._ledger.remove(cid)
self.forget_credential_state(entry)
self._entries = [e for e in self._entries if e is not entry]
self._model_fail = {key: until for key, until in self._model_fail.items() if key[0] != cid}
self._sticky = OrderedDict((key, value) for key, value in self._sticky.items() if value[0] != cid)
self._sync_pending.discard(cid)
self._syncing.discard(cid)
self._sync_retry.pop(cid, None)
self._sync_attempts.pop(cid, None)
invalidate_model_table()
def prune(self):
"""Remove missing credential files and their session bindings."""
with self._lock:
self._ignored_duplicates = {p for p in self._ignored_duplicates if os.path.exists(p)}
# A path whose invalid file vanished must warn again when a new file reuses it.
self._ignored_invalid = {p: v for p, v in self._ignored_invalid.items() if os.path.exists(p)}
before = len(self._entries)
removed = [e for e in self._entries if not os.path.exists(e["id"])]
for entry in removed:
Expand Down Expand Up @@ -861,7 +908,8 @@ def _expiry_rank(self, e: dict) -> tuple:
return (exp is None, exp or 0.0)
def _rescan(self):
self.prune()
paths = find_auth_files() if self._scan else [Path(entry["id"]) for entry in self.entries()]
# Explicit mode retries configured paths, including rejected or evicted ones.
paths = find_auth_files() if self._scan else [Path(cid) for cid in self._configured]
self.reload(paths, reset=False)

def _healthy(self, e: dict) -> bool:
Expand Down
30 changes: 30 additions & 0 deletions tests/test_auth_oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,35 @@ def test_validate_cred_data():
print("✅ test_validate_cred_data")


def test_plaintext_token_guard():
from app.auth_oauth import AuthTokenTypeError, ensure_plaintext_tokens
ensure_plaintext_tokens({})
ensure_plaintext_tokens({"accessToken": "t", "refreshToken": "r"})
for field in ("accessToken", "refreshToken"):
for bad in ({"$wbEncrypted": 1, "envelope": "x"}, 5, ["x"], True):
try:
ensure_plaintext_tokens({field: bad})
raise AssertionError((field, bad))
except AuthTokenTypeError as e:
assert field in str(e)
# Import validation keeps rejecting envelope files before persistence,
# including a plaintext accessToken paired with an envelope refreshToken or alias.
envelope = _cred(domain="www.workbuddy.ai")
envelope["auth"]["accessToken"] = {"$wbEncrypted": 1, "envelope": "x"}
uid, err = validate_cred_data(envelope)
assert uid is None and "accessToken" in err
for field in ("refreshToken", "refresh_token", "access_token", "token"):
c = _cred(domain="www.workbuddy.ai")
c["auth"][field] = {"$wbEncrypted": 1, "envelope": "x"}
uid, err = validate_cred_data(c)
assert uid is None and field in err, field
alias = _cred(domain="www.workbuddy.ai")
alias["auth"].pop("accessToken")
alias["auth"]["access_token"] = "plain-token"
assert validate_cred_data(alias) == ("u1", None)
print("✅ test_plaintext_token_guard")


def test_helpers():
assert _normalize_origin("www.codebuddy.cn") == "https://www.codebuddy.cn"
assert _normalize_origin("https://WWW.workbuddy.ai/x") == "https://www.workbuddy.ai"
Expand Down Expand Up @@ -361,6 +390,7 @@ def test_oauth_endpoint_import(tmp_path=None):

if __name__ == "__main__":
test_validate_cred_data()
test_plaintext_token_guard()
test_helpers()
test_build_auth_file()
test_merge_existing_accounts()
Expand Down
110 changes: 110 additions & 0 deletions tests/test_credential_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,116 @@ def test_directory_changes_still_add_and_remove_accounts(self):
pool._rescan()
self.assertEqual([e["id"] for e in pool.entries()], [str(second)])

def test_encrypted_envelope_tokens_fail_fast_and_stay_out_of_pool(self):
valid = self.credential("valid.info", uid="valid", age=0)
envelope = self.root / "envelope.info"
envelope.write_text(json.dumps({"account": {"uid": "envelope"}, "auth": {
"accessToken": {"$wbEncrypted": 1, "envelope": "c3lu"},
"refreshToken": {"$wbEncrypted": 1, "envelope": "c3lu"},
"domain": "www.workbuddy.ai", "expiresAt": (time.time() + 86400) * 1000}}),
encoding="utf-8")
pool = converter.CredentialPool([valid, envelope])
self.assertEqual([Path(e["id"]).name for e in pool.entries()], ["valid.info"])
rejected = [c for c in self.logs.call_args_list if "拒绝入池" in c.args[0]]
self.assertEqual(len(rejected), 1)
self.assertIn("accessToken", rejected[0].args[0])
pool.reload([valid, envelope], reset=False) # Repeat reloads neither re-log nor admit.
self.assertEqual([Path(e["id"]).name for e in pool.entries()], ["valid.info"])
self.assertEqual(len([c for c in self.logs.call_args_list if "拒绝入池" in c.args[0]]), 1)
with self.assertRaises(converter.auth_oauth.AuthTokenTypeError): # Direct use fails fast.
converter.CredentialManager(envelope).get_headers()

def test_envelope_refresh_token_alone_is_rejected(self):
path = self.credential("rt.info", uid="rt", age=0)
data = json.loads(path.read_text(encoding="utf-8"))
data["auth"]["refreshToken"] = {"$wbEncrypted": 1, "envelope": "c3lu"}
path.write_text(json.dumps(data), encoding="utf-8")
pool = converter.CredentialPool([path])
self.assertEqual(pool.entries(), [])
rejected = [c for c in self.logs.call_args_list if "拒绝入池" in c.args[0]]
self.assertEqual(len(rejected), 1)
self.assertIn("refreshToken", rejected[0].args[0])


def test_replaced_envelope_file_at_same_path_logs_again(self):
envelope = self.root / "churn.info"
def write_envelope(uid):
envelope.write_text(json.dumps({"account": {"uid": uid}, "auth": {
"accessToken": {"$wbEncrypted": 1, "envelope": "c3lu"},
"domain": "www.workbuddy.ai", "expiresAt": (time.time() + 86400) * 1000}}),
encoding="utf-8")
def rejections():
return len([c for c in self.logs.call_args_list if "拒绝入池" in c.args[0]])
write_envelope("churn")
pool = converter.CredentialPool([envelope], scan=True)
self.assertEqual(rejections(), 1)
envelope.unlink()
write_envelope("other-account") # A different credential, no scan ran while missing.
pool._rescan()
self.assertEqual(pool.entries(), [])
self.assertEqual(rejections(), 2)
pool._rescan() # Identical content on the next scan stays silent.
self.assertEqual(rejections(), 2)
def test_recreated_envelope_file_at_same_path_logs_again(self):
envelope = self.root / "reuse.info"
def write_envelope():
envelope.write_text(json.dumps({"account": {"uid": "reuse"}, "auth": {
"accessToken": {"$wbEncrypted": 1, "envelope": "c3lu"},
"domain": "www.workbuddy.ai", "expiresAt": (time.time() + 86400) * 1000}}),
encoding="utf-8")
def rejections():
return len([c for c in self.logs.call_args_list if "拒绝入池" in c.args[0]])
write_envelope()
pool = converter.CredentialPool([envelope], scan=True)
self.assertEqual(pool.entries(), [])
self.assertEqual(rejections(), 1)
envelope.unlink()
pool._rescan() # Prune drops the suppressed path once the file is gone.
write_envelope()
pool._rescan() # A new invalid file at the same path must warn again.
self.assertEqual(pool.entries(), [])
self.assertEqual(rejections(), 2)

def test_swapped_envelope_file_fails_fast_on_next_use(self):
path = self.credential(age=0)
pool = converter.CredentialPool([path])
self.assertEqual(len(pool.entries()), 1)
data = json.loads(path.read_text(encoding="utf-8"))
data["auth"]["accessToken"] = {"$wbEncrypted": 1, "envelope": "c3lu"}
path.write_text(json.dumps(data), encoding="utf-8")
pool.reload([path], reset=True) # A damaged file must not block other credentials.
self.assertEqual(pool.entries(), []) # The stale identity is evicted, not retained.
with self.assertRaises(converter.auth_oauth.AuthTokenTypeError): # Direct use fails fast.
converter.CredentialManager(path).get_headers()
pool.reload([path], reset=False) # The next scan rejects it once like any new file.
self.assertEqual(pool.entries(), [])
rejected = [c for c in self.logs.call_args_list if "拒绝入池" in c.args[0]]
self.assertEqual(len(rejected), 1)

def test_rejected_explicit_path_recovers_when_repaired(self):
path = self.credential("explicit.info", uid="explicit", age=0)
data = json.loads(path.read_text(encoding="utf-8"))
data["auth"]["accessToken"] = {"$wbEncrypted": 1, "envelope": "c3lu"}
path.write_text(json.dumps(data), encoding="utf-8")
pool = converter.CredentialPool([path]) # Explicit mode (scan=False).
self.assertEqual(pool.entries(), [])
self.credential("explicit.info", uid="explicit", age=0) # Repair the same file.
pool._rescan()
self.assertEqual([Path(e["id"]).name for e in pool.entries()], ["explicit.info"])

def test_evicted_explicit_path_recovers_when_repaired(self):
path = self.credential("evicted.info", uid="evicted", age=0)
pool = converter.CredentialPool([path])
self.assertEqual(len(pool.entries()), 1)
data = json.loads(path.read_text(encoding="utf-8"))
data["auth"]["refreshToken"] = {"$wbEncrypted": 1, "envelope": "c3lu"}
path.write_text(json.dumps(data), encoding="utf-8")
pool.reload([path], reset=True) # The envelope replacement evicts the entry.
self.assertEqual(pool.entries(), [])
self.credential("evicted.info", uid="evicted", age=0) # Repair the same file.
pool._rescan()
self.assertEqual([Path(e["id"]).name for e in pool.entries()], ["evicted.info"])


if __name__ == "__main__":
unittest.main(verbosity=2)
Loading