Skip to content

fix(core): validate oauth expires_in values#5356

Merged
wenshao merged 1 commit into
QwenLM:mainfrom
tt-a1i:fix/oauth-expires-in-zero
Jun 18, 2026
Merged

fix(core): validate oauth expires_in values#5356
wenshao merged 1 commit into
QwenLM:mainfrom
tt-a1i:fix/oauth-expires-in-zero

Conversation

@tt-a1i

@tt-a1i tt-a1i commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Fixes #5355

Summary

  • preserve expires_in=0 as an immediate OAuth token expiry instead of dropping expiresAt
  • normalize expires_in from JSON and form-urlencoded token responses through one parser
  • reject malformed expires_in values that parseInt previously truncated

Test plan

  • npx vitest run src/mcp/oauth-provider.test.ts
  • npm run typecheck --workspace=packages/core
  • npm run build --workspace=packages/core
  • npx prettier --check packages/core/src/mcp/oauth-provider.ts packages/core/src/mcp/oauth-provider.test.ts
  • git diff --check
  • npm run lint

AI Assistance Disclosure

I used Codex to review the changes, sanity-check the implementation against existing patterns, and help spot potential edge cases.

@tt-a1i
tt-a1i force-pushed the fix/oauth-expires-in-zero branch from 528d67f to 3457f4e Compare June 18, 2026 19:08
@tt-a1i
tt-a1i marked this pull request as ready for review June 18, 2026 19:35
@wenshao

wenshao commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR, @tt-a1i!

Template: the PR body uses custom headings ("Summary", "Test plan") instead of the template sections. Missing: "Why it's needed", structured "Reviewer Test Plan" (How to verify / Evidence / Tested on table), "Risk & Scope", and "中文说明". The essentials are there — "Fixes #5355" links the issue, and the test commands are listed — but please align with the template so reviewers get the context they need. Not blocking the review on this, but worth fixing.

Direction: clearly aligned. Issue #5355 describes a real correctness bug — expires_in=0 silently treated as non-expiring, and parseInt accepting malformed values like "3600abc". This is a straightforward MCP OAuth bug that affects token lifecycle. No CHANGELOG reference needed for a fix this scoped.

Approach: minimal and focused. Extract a parseExpiresIn helper, apply it consistently across both parse paths (JSON + form-urlencoded) and both call sites (authenticate + refreshAccessToken), preserve 0 as valid, reject malformed strings. +215 is mostly tests (5 new test cases), which is the right ratio. No scope creep.

Moving on to code review. 🔍

中文说明

感谢贡献,@tt-a1i

模板: PR 使用了自定义标题("Summary"、"Test plan"),与模板要求的章节不一致。缺少:"Why it's needed"、结构化的 "Reviewer Test Plan"(验证步骤 / 证据 / 测试平台表格)、"Risk & Scope" 和 "中文说明"。核心信息都有——"Fixes #5355" 关联了 issue,测试命令也列出来了——但请对齐模板以便审查者获取所需上下文。不阻塞审查,但建议修正。

方向: 明确对齐。Issue #5355 描述了一个真实的正确性 bug——expires_in=0 被静默当作永不过期,parseInt 接受了 "3600abc" 等畸形值。这是 MCP OAuth 中影响令牌生命周期的实际 bug。

方案: 最小化且聚焦。提取 parseExpiresIn 辅助函数,在两条解析路径(JSON + form-urlencoded)和两个调用点(authenticate + refreshAccessToken)统一应用,保留 0 为合法值,拒绝畸形字符串。+215 行主要是测试(5 个新用例),比例合理。无范围蔓延。

进入代码审查 🔍

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Code Review

Independent proposal (before reading the diff): given the bug — if (tokenResponse.expires_in) drops 0, and parseInt is too lenient — I'd extract a small validator that (a) treats undefined/null as "no expiry", (b) accepts 0 as valid, (c) rejects non-numeric strings. Then replace every expires_in consumption site with it. Two files max: the provider and its tests.

Comparison: the PR does exactly this. parseExpiresIn handles all three cases cleanly: undefined/nullundefined, digits-only string → Number(), anything else → NaN → throw. normalizeTokenResponse wraps it for the parse-response path. The re-parse in authenticate() and getValidToken() via parseExpiresIn(tokenResponse.expires_in) is redundant (already parsed) but harmless — idempotent on numbers.

No correctness bugs, no security issues, no AGENTS.md violations. The error re-throw in the JSON catch block (checking error.message.startsWith('Invalid expires_in value')) is slightly fragile but functional and only needed to prevent the JSON parse error handler from swallowing validation errors.

Test Results

$ cd packages/core && npx vitest run src/mcp/oauth-provider.test.ts

 RUN  v3.2.4 packages/core

 ✓ src/mcp/oauth-provider.test.ts (27 tests) 188ms

 Test Files  1 passed (1)
      Tests  27 passed (27)
   Duration  4.40s

$ npx tsc --noEmit --project packages/core/tsconfig.json
(clean — no output)

$ npx eslint packages/core/src/mcp/oauth-provider.ts
(clean — no output)

All 27 tests pass (22 existing + 5 new). Typecheck and lint clean.

Real-Scenario Testing

N/A for this fix — the expires_in parsing is internal token-handling logic, not user-observable at the TUI level. Reproducing it would require a live MCP server configured to return expires_in=0, which isn't feasible in a CLI smoke test. Unit tests are the correct verification layer here and they cover all edge cases: expires_in=0 in both auth and refresh flows, JSON string "3600", malformed "3600abc" in both JSON and form-urlencoded formats, and expires_in=0 during getValidToken refresh.

中文说明

代码审查

独立方案(阅读 diff 之前): 针对 if (tokenResponse.expires_in) 丢弃 0parseInt 过于宽松的问题——提取一个小型验证函数:(a) undefined/null → 无过期时间,(b) 0 为合法值,(c) 拒绝非数字字符串。最多改两个文件:provider 和测试。

对比: PR 完全符合这个方案。parseExpiresIn 干净地处理了三种情况。无正确性 bug、无安全问题、无 AGENTS.md 违规。

测试结果

27 个测试全部通过(22 个已有 + 5 个新增)。Typecheck 和 lint 均通过。

真实场景测试

不适用——expires_in 解析是内部令牌处理逻辑,不在 TUI 层面可观察。需要配置了 expires_in=0 的实际 MCP 服务器才能复现,CLI 冒烟测试不可行。单元测试是正确的验证层,覆盖了所有边界情况。

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

This is a clean, well-scoped bug fix. The parseExpiresIn helper is exactly what the code needed — it replaces a falsy check that dropped 0 and a parseInt that accepted garbage, with a single function that gets both right. The diff is minimal (two files, mostly tests), follows project conventions, and every new test targets a specific edge case from the issue.

My independent proposal matched the PR's approach almost exactly, which gives me confidence this is the right solution. The only minor observation is that parseExpiresIn gets called redundantly on already-parsed values in authenticate() and getValidToken() — harmless, and arguably defensive in a good way.

Template compliance should be tightened up (see Stage 1), but that's cosmetic. The substance is solid.

Approving. ✅

中文说明

这是一个干净、范围合理的 bug 修复。parseExpiresIn 辅助函数正是代码所需要的——它替换了丢弃 0 的真值检查和接受垃圾值的 parseInt,用一个函数正确处理了两种情况。Diff 最小化(两个文件,主要是测试),遵循项目规范,每个新测试都针对 issue 中的具体边界情况。

我的独立方案与 PR 方案几乎完全一致,这使我对这是正确的解决方案有信心。唯一的小观察是 parseExpiresInauthenticate()getValidToken() 中对已解析值有冗余调用——无害,且可以说是好的防御性编程。

模板合规性需要改进(见 Stage 1),但那是表面问题。实质内容扎实。

批准 ✅

Qwen Code · qwen3.7-max

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM — clean, minimal bug fix with solid test coverage. Template compliance could be tightened (noted in review), but the substance is ready to ship. ✅

@wenshao

wenshao commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

✅ Local runtime verification (real tmux, live OAuth token server) — PR #5356

Verdict: fixes #5355 as intended, no regressions, type-checks & lints clean — safe to merge.

I drove the real MCPOAuthProvider against a real local HTTP token endpoint (no fetch/http mocks) plus the real MCPOAuthTokenStorage (plain-JSON backend, HOME redirected to a throwaway dir), inside an isolated tmux session. A/B baseline is origin/main (the PR is based on current main — 0 commits behind, clean apply).

What the PR does

Adds parseExpiresIn: accepts a non-negative safe integer (or a ^\d+$ string), otherwise throws Invalid expires_in value. It runs through normalizeTokenResponse on JSON responses and directly on the form-urlencoded path (replacing parseInt), and the token-save guard changes from truthy if (expires_in) to if (expiresIn !== undefined) so expires_in=0 produces an immediate expiresAt instead of being dropped.

Evidence — PR head

Static checks

vitest oauth-provider.test.ts → 27 passed (5 new)   VITEST_EXIT=0
eslint (both changed files)   → ESLINT_EXIT=0
typecheck core (tsc --noEmit) → TYPECHECK_CORE_EXIT=0

Real runtime — live token endpoint http://127.0.0.1:<port>/token:

refreshAccessToken (real HTTP):
  [json_number_3600] OK    expires_in=3600   (type=number)
  [json_string_3600] OK    expires_in=3600   (type=number)   <- "3600" normalized
  [json_bad_3600abc] THROW Invalid expires_in value: 3600abc
  [form_bad_3600abc] THROW Invalid expires_in value: 3600abc

getValidToken + expires_in=0 (real storage, refresh an expired token):
  saved_token_expiresAt        = <≈ Date.now()>
  EXPIRESAT_SET                = true
  EXPIRESAT_IS_IMMEDIATE       = true
  ISTOKENEXPIRED_AFTER_REFRESH = true          <- correctly expired, not "valid forever"

A/B vs origin/main (same harness, same live server)

                              PR head                 origin/main (base)
refresh json string "3600"    3600  (number)          "3600" (string, not normalized)
refresh json  "3600abc"       THROW                   "3600abc" (string, silently accepted)
refresh form  "3600abc"       THROW                   3600   (parseInt truncated, accepted)
getValidToken expires_in=0:
  saved expiresAt             ≈ now (set)              undefined  (dropped)
  isTokenExpired(saved)       true                     false      <- non-expiring (#5355 bug)

The 5 new tests have teeth — all fail on base:

× authenticate      > should preserve expires_in=0 as an immediate expiry
× refreshAccessToken > should normalize JSON string expires_in values
× refreshAccessToken > should reject malformed JSON expires_in values
× refreshAccessToken > should reject malformed form-urlencoded expires_in values
× getValidToken     > should preserve expires_in=0 when refreshing an expired token
Tests 5 failed | 22 skipped (27)   TEETH_VITEST_EXIT=1

Observations (non-blocking)

  • Both response paths reject malformed values consistently: a non-JSON body fails JSON.parse (a SyntaxError, not Invalid expires_in value) and falls through to the form-urlencoded branch, where parseExpiresIn is applied directly — confirmed by form_bad_3600abc throwing.
  • The JSON path distinguishes a parseExpiresIn rejection from a genuine JSON parse error via error.message.startsWith('Invalid expires_in value'). It works (the message is internal/controlled); just note it's a string-based discriminator.
  • ^\d+$ + Number.isSafeInteger also rejects negatives, decimals (e.g. a JSON expires_in: 3.5), and oversized values — a reasonable tightening.
  • Verified on Linux.
🇨🇳 中文版(点击展开)

✅ 本地真实运行验证(真实 tmux + 真实 OAuth token 服务)— PR #5356

结论:按预期修复了 #5355,无回归,类型检查与 lint 均通过,可以合并。

我在隔离的 tmux 会话里,让真实的 MCPOAuthProvider 对接一个真实的本地 HTTP token endpoint(不 mock fetch/http),并配合真实的 MCPOAuthTokenStorage(plain-JSON 后端,HOME 重定向到临时目录)。A/B 基线是 origin/main(该 PR 基于当前 main——落后 0 个提交,可干净应用)。

这个 PR 做了什么

新增 parseExpiresIn:接受非负 safe integer(或 ^\d+$ 字符串),否则抛出 Invalid expires_in value。它在 JSON 响应上经由 normalizeTokenResponse 调用、在 form-urlencoded 路径上直接调用(替换 parseInt);保存 token 时的判断也从“真值” if (expires_in) 改为 if (expiresIn !== undefined),于是 expires_in=0 会产生一个“立即过期”的 expiresAt,而不是被丢弃。

证据 — PR head

静态检查

vitest oauth-provider.test.ts → 27 passed(5 个新增)  VITEST_EXIT=0
eslint(两个改动文件)        → ESLINT_EXIT=0
typecheck core (tsc --noEmit) → TYPECHECK_CORE_EXIT=0

真实运行 — 实时 token endpoint http://127.0.0.1:<port>/token

refreshAccessToken(真实 HTTP):
  [json_number_3600] OK    expires_in=3600   (type=number)
  [json_string_3600] OK    expires_in=3600   (type=number)   <- 字符串 "3600" 被规范化
  [json_bad_3600abc] THROW Invalid expires_in value: 3600abc
  [form_bad_3600abc] THROW Invalid expires_in value: 3600abc

getValidToken + expires_in=0(真实存储,刷新一个已过期的 token):
  saved_token_expiresAt        = <≈ Date.now()>
  EXPIRESAT_SET                = true
  EXPIRESAT_IS_IMMEDIATE       = true
  ISTOKENEXPIRED_AFTER_REFRESH = true          <- 正确地判定为已过期,而非“永久有效”

origin/main 的 A/B(同一套脚本、同一个实时服务)

                              PR head                 origin/main (base)
refresh json 字符串 "3600"     3600(number)          "3600"(string,未规范化)
refresh json  "3600abc"       THROW                   "3600abc"(string,被静默接受)
refresh form  "3600abc"       THROW                   3600(parseInt 截断后被接受)
getValidToken expires_in=0:
  保存的 expiresAt             ≈ now(已设置)          undefined(被丢弃)
  isTokenExpired(saved)       true                     false      <- 永不过期(即 #5355 bug)

5 个新增测试都是有效的(有“牙齿”)——在 base 上全部失败:

× authenticate      > should preserve expires_in=0 as an immediate expiry
× refreshAccessToken > should normalize JSON string expires_in values
× refreshAccessToken > should reject malformed JSON expires_in values
× refreshAccessToken > should reject malformed form-urlencoded expires_in values
× getValidToken     > should preserve expires_in=0 when refreshing an expired token
Tests 5 failed | 22 skipped (27)   TEETH_VITEST_EXIT=1

观察(非阻塞)

  • 两条响应解析路径都会一致地拒绝非法值:非 JSON 的响应体会让 JSON.parseSyntaxError(而非 Invalid expires_in value),从而落到 form-urlencoded 分支,在那里直接调用 parseExpiresIn——form_bad_3600abc 抛错即为佐证。
  • JSON 路径靠 error.message.startsWith('Invalid expires_in value') 来区分“parseExpiresIn 的拒绝”与“真正的 JSON 解析错误”。它能正常工作(该 message 是内部可控的),只是提醒一下这是基于字符串的判别方式。
  • ^\d+$ + Number.isSafeInteger 还会拒绝负数、小数(例如 JSON 里的 expires_in: 3.5)以及超大值——是一处合理的收紧。
  • 在 Linux 上验证。

@wenshao

wenshao commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

✅ Local verification report — PR #5356 fix(core): validate oauth expires_in values

Verdict: strong LGTM. This is a correct, well-scoped fix for a real OAuth bug. The new tests genuinely catch the bugs, the new parseExpiresIn parser is robust across a wide edge-case sweep, and there are no regressions or type/lint/format issues. One minor behavioral note for your awareness (not a blocker).

What it fixes

  1. expires_in=0 droppedmain used a falsy check (if (tokenResponse.expires_in)), so an immediate-expiry 0 left expiresAt unset → the token was treated as never expiring and never refreshed. The PR sets expiresAt = now.
  2. Silent parseInt truncationmain did parseInt(expiresIn, 10), so '3600abc' → 3600, '3.5' → 3, '0x10' → 0, '1e3' → 1 were silently accepted. The PR validates with a strict /^\d+$/ parser and throws Invalid expires_in value.
  3. Un-normalized JSON values — a JSON expires_in: '3600' (string) stayed a string on main; the PR routes both JSON and form-urlencoded responses through one parseExpiresIn so the value is always a normalized number (or undefined).

How it was verified

Real vitest under tmux in two isolated worktrees: AFTER = 3457f4e8 (PR head), BEFORE = a4797b9a (origin/main), repo node_modules symlinked. Node v22.22.2, vitest 3.2.4. Merges cleanly; target files identical between merge-base and main.

Check Result
PR oauth suite (oauth-provider.test.ts) 27/27 pass (22 pre-existing + 5 new)
Genuine-regression — PR's tests on BASE source exactly the 5 new tests fail, 22 pre-existing pass → tests truly catch the bugs
Adversarial parseExpiresIn probe (26 inputs, 11 contract assertions) ✅ all pass (see below)
tsc --noEmit core ✅ 0 errors, identical to base → 0 new type errors
npm run build core ✅ exit 0
prettier --check + eslint (both files) + git diff --check ✅ all exit 0

Genuine-regression detail (base failure signatures)

× preserve expires_in=0 as an immediate expiry      → expected undefined to be 1700000000000
× normalize JSON string expires_in values           → expected '3600' to be 3600
× reject malformed JSON expires_in values           → promise resolved instead of rejecting
× reject malformed form-urlencoded expires_in values→ promise resolved instead of rejecting
× preserve expires_in=0 when refreshing             → expiresAt not set

Adversarial parser sweep (PR vs base)

The new parser handles every edge case correctly, where main's parseInt silently corrupted many:

input PR parseExpiresIn base parseInt
0 (number) 0 (immediate expiry ✅) dropped (falsy) ❌
'3600' 3600 3600
' 3600 ', '007' 3600, 7 same
'3600abc' throws 3600 (truncated) ❌
'3.5' / '0x10' / '1e3' / '1e21' throws 3 / 0 / 1 / 1
-1, 3.5, MAX_SAFE+1, NaN, Infinity, true, {} throws various silent values ❌
null / undefined undefined undefined

Minor note (non-blocking)

An empty expires_in= in a form-urlencoded response now throws (Invalid expires_in value:), whereas main silently treated it as undefined (no expiry). An empty value is non-conformant (RFC 6749 says expires_in is optional → a compliant server omits it entirely, which still yields undefined correctly). So this is a defensible fail-closed tightening, but it does mean a buggy server that emits an explicit empty expires_in= would now have its token rejected rather than treated as non-expiring. Worth a mention; not a blocker.

Reproduce

git worktree add --detach /tmp/wt-after  $(gh pr view 5356 --json headRefOid -q .headRefOid)
git worktree add --detach /tmp/wt-before origin/main
for w in wt-after wt-before; do ln -s "$PWD/node_modules" /tmp/$w/node_modules; \
  ln -s "$PWD/packages/core/node_modules" /tmp/$w/packages/core/node_modules; done
(cd /tmp/wt-after/packages/core && ../../node_modules/.bin/vitest run --coverage.enabled=false src/mcp/oauth-provider.test.ts)  # 27/27
# Catch the bug: copy PR's test onto BASE source -> 5 fail
cp /tmp/wt-after/packages/core/src/mcp/oauth-provider.test.ts /tmp/wt-before/packages/core/src/mcp/oauth-provider.test.ts
(cd /tmp/wt-before/packages/core && ../../node_modules/.bin/vitest run --coverage.enabled=false src/mcp/oauth-provider.test.ts)
🇨🇳 中文版(点击展开)

✅ 本地验证报告 — PR #5356 fix(core): validate oauth expires_in values

结论:强烈建议合并。 这是针对一个真实 OAuth bug 的、范围聚焦且正确的修复。新测试确实能抓到这些 bug,新的 parseExpiresIn 解析器在大量边界用例下都稳健,且无回归、无类型/lint/格式问题。有一点小的行为变化供你知悉(非阻塞)。

修了什么

  1. expires_in=0 被丢弃main 用 falsy 判断(if (tokenResponse.expires_in)),所以“立即过期”的 0 不会设置 expiresAt → token 被当作永不过期、永不刷新。PR 改为设置 expiresAt = now
  2. parseInt 静默截断mainparseInt(expiresIn, 10),于是 '3600abc' → 3600'3.5' → 3'0x10' → 0'1e3' → 1 都被静默接受。PR 用严格的 /^\d+$/ 解析并对非法值 抛出 Invalid expires_in value
  3. JSON 值未归一化:JSON 里的 expires_in: '3600'(字符串)在 main 上仍是字符串;PR 让 JSON 和 form-urlencoded 两条路径都经过同一个 parseExpiresIn,最终始终是归一化后的数字(或 undefined)。

验证方式

tmux 中、两个隔离 worktree 里运行真实 vitest:AFTER = 3457f4e8(PR head),BEFORE = a4797b9aorigin/main),软链接复用仓库 node_modules。Node v22.22.2、vitest 3.2.4。可干净合并;目标文件在 merge-base 与 main 之间一致。

检查项 结果
PR oauth 测试oauth-provider.test.ts 27/27 通过(22 既有 + 5 新增)
回归测试有效性 —— PR 的测试跑 BASE 源码 恰好这 5 个新测试失败,22 个既有通过 → 测试确实抓住了 bug
对抗性 parseExpiresIn 探测(26 个输入、11 条契约断言) ✅ 全通过(见下表)
tsc --noEmit core ✅ 0 错误,与 base 一致 → 0 新增类型错误
npm run build core ✅ exit 0
prettier --check + eslint(两个文件)+ git diff --check ✅ 均 exit 0

回归细节(base 上的失败特征)

× expires_in=0 立即过期            → expected undefined to be 1700000000000
× 归一化 JSON 字符串 expires_in     → expected '3600' to be 3600
× 拒绝非法 JSON expires_in          → promise 被 resolve 而非 reject
× 拒绝非法 form-urlencoded expires_in→ promise 被 resolve 而非 reject
× 刷新时保留 expires_in=0           → expiresAt 未设置

解析器对抗性扫描(PR vs base)

新解析器对每个边界用例都处理正确,而 mainparseInt 会静默破坏很多:

输入 PR parseExpiresIn base parseInt
0(数字) 0(立即过期 ✅) 被丢弃(falsy)❌
'3600' 3600 3600
' 3600 ''007' 36007 同左
'3600abc' 抛错 3600(截断)❌
'3.5' / '0x10' / '1e3' / '1e21' 抛错 3 / 0 / 1 / 1
-13.5MAX_SAFE+1NaNInfinitytrue{} 抛错 各种静默值 ❌
null / undefined undefined undefined

小提示(非阻塞)

form-urlencoded 响应里空的 expires_in= 现在会抛错Invalid expires_in value:),而 main 会静默当作 undefined(不过期)。空值本身不符合规范(RFC 6749 中 expires_in 可选 → 合规服务器会直接省略它,这种情况仍正确得到 undefined)。因此这是一个合理的 fail-closed 收紧;但确实意味着:若某个有 bug 的服务器显式发送空的 expires_in=,其 token 现在会被拒绝而不是当作不过期。值得一提,但非阻塞。


Verified locally with real vitest under tmux on isolated worktrees of the PR head and origin/main; bug reality proven by running the PR's tests against base source; the parseExpiresIn parser audited with a 26-input adversarial sweep against the old parseInt behavior.

@wenshao
wenshao merged commit 7e1bdd9 into QwenLM:main Jun 18, 2026
31 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

MCP OAuth expires_in=0 is saved as non-expiring

3 participants