Skip to content

fix: harden OAuth token-response parsing and refresh-error classification#181

Open
santhil-cyber wants to merge 1 commit into
githits-com:mainfrom
santhil-cyber:fix/harden-token-parsing
Open

fix: harden OAuth token-response parsing and refresh-error classification#181
santhil-cyber wants to merge 1 commit into
githits-com:mainfrom
santhil-cyber:fix/harden-token-parsing

Conversation

@santhil-cyber

Copy link
Copy Markdown

Tightens how OAuth token-endpoint responses are parsed and how terminal refresh failures are classified, all in src/services/auth-service.ts. These are edge-case hardening fixes in a path that runs on every authenticated command, not a reproduced production failure.

What changed

Token responses validated with Zod. parseTokenResponse / parseRefreshTokenResponse used as string casts behind a truthiness check, so a truthy non-string access_token (number, object) slipped through and was mistyped. Replaced with .safeParse schemas, matching the existing pattern in auth-config.ts. The "Token response missing required fields" error is preserved.

expires_in coerced safely. (d.expires_in as number) || 3600 handles a missing value, but a non-numeric string like "soon" becomes NaN, and Date.now() + NaN * 1000 throws a RangeError when building the expiry in login.ts. The schema now accepts numeric strings and falls back to the default lifetime for non-finite, zero, or negative values.

invalid_grant classified by code, not wording. classifyTerminalRefreshError required invalid_grant plus a specific phrase in the error description to detect a dead refresh token. Per RFC 6749 §5.2, invalid_grant on a refresh request already means the token is expired/revoked/reused, so a reworded description would silently break detection. It now trusts the structured code, keeping the substring matches as a fallback.

Tests

Added cases for numeric-string expires_in, non-positive/non-numeric fallback, rejection of non-string access_token, and invalid_grant classification independent of description text.

Local: full bun test green (2276 pass), bun run typecheck clean, bun run build succeeds, smoke:cli/smoke:mcp pass on the unauthenticated path, biome clean.

Closes #180

…tion

The token endpoint responses were read with plain `as string` casts and a
`(d.expires_in as number) || 3600` fallback. That mostly works, but leaves a
few real gaps:

- A truthy non-string access_token (a number, an object) passes the presence
  check and gets mistyped as a string, surfacing later as a confusing
  downstream failure instead of a clear parse error.
- A non-numeric or negative expires_in (e.g. "soon", -5) flows into
  `Date.now() + expiresIn * 1000`, producing NaN and a RangeError when we build
  the expiry date.

Both token responses are now validated with Zod (the same .safeParse pattern
used in auth-config.ts) and expires_in is coerced safely: numeric strings are
accepted, anything non-finite or non-positive falls back to the default
lifetime. The existing "Token response missing required fields" error and the
omitted-expires_in default are preserved.

Separately, classifyTerminalRefreshError required both an invalid_grant code
and a specific phrase in the error description to flag a dead refresh token. Per
RFC 6749 §5.2, invalid_grant on a refresh request already means the token is
expired/revoked/reused, so a backend rewording its description would silently
break detection. It now classifies on the structured code, keeping the
substring checks as a fallback for servers that don't send a clean code.

Closes githits-com#180
@santhil-cyber

Copy link
Copy Markdown
Author

Noticed you're not taking external contributions yet... happy to leave this as reference, close whenever @jlitola @skvark

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.

Harden OAuth token-response parsing and refresh-error classification

1 participant