Skip to content

fix(oauth): handle missing expires_in in OAuth token response (supersedes #3992)#4447

Merged
jonpspri merged 3 commits intomainfrom
fix/oauth-missing-expires-in-3992
Apr 26, 2026
Merged

fix(oauth): handle missing expires_in in OAuth token response (supersedes #3992)#4447
jonpspri merged 3 commits intomainfrom
fix/oauth-missing-expires-in-3992

Conversation

@jonpspri
Copy link
Copy Markdown
Collaborator

🔗 Related Issue

Closes #3989
Supersedes #3992 (couldn't push amendments to the contributor's fork)


📝 Summary

Picks up @ecthelion77's original fix in #3992 for the `TypeError` raised when an OAuth provider omits `expires_in` from its token response (RFC 6749 §5.1 marks it RECOMMENDED, not REQUIRED — e.g. GitHub OAuth Apps), then layers in review-driven hardening:

  • Original fix (commit 1, by @ecthelion77): when `expires_in` is absent, store `expires_at` as `NULL` instead of crashing.
  • Hardening (commit 2):
    • New `parse_expires_in()` validator that rejects negative, malformed, or non-scalar values with a clean `OAuthError` instead of letting `ValueError` bubble.
    • Refresh path uses the same parser, so initial-auth and refresh agree on what "missing `expires_in`" means (was hardcoded to `3600` fallback, creating path-dependent semantics).
    • `cleanup_expired_tokens` now ages out `NULL`-expiry rows so they don't accumulate forever (covers the SQL three-valued logic gotcha — `NULL < cutoff` evaluates to `NULL`).
    • `_is_token_expired` docstring documents the `NULL` contract and cross-references the cleanup policy.
    • Removed now-dead `OAUTH_DEFAULT_TIMEOUT` from `config.py`, `.env.example`, helm chart (`values.yaml`/`values.schema.json`/`README.md`), 4 docs files, and 3 test mocks.
    • Lazy `%`-style logging on new lines; dropped redundant `int()` cast.
  • Second-pass review fixes (commit 3):
    • Refresh path now preserves prior TTL (`expires_at - updated_at`) when the refresh response omits `expires_in`, instead of clearing `expires_at` to `NULL` and silently stopping all future proactive refreshes. Falls back to `NULL` only when there was no prior TTL to preserve (genuine no-expiry providers).
    • `cleanup_expired_tokens` ages `NULL`-expiry rows by `updated_at` (advances on re-auth), not `created_at` (which would delete recently re-authorized tokens).
    • `parse_expires_in` rejects non-integer floats per RFC, and sign-checks the original numeric before `int()` truncation (so `-0.5` correctly raises "negative" instead of silently becoming `0`).

🏷️ Type of Change

  • Bug fix
  • Feature / Enhancement
  • Documentation
  • Refactor
  • Chore (deps, CI, tooling)

🧪 Verification

Check Command Status
OAuth scope unit tests `pytest tests/unit/mcpgateway/services/test_token_storage_service.py tests/unit/mcpgateway/services/test_oauth_manager.py tests/unit/mcpgateway/services/test_oauth_manager_pkce.py tests/unit/mcpgateway/test_oauth_manager.py` ✅ 390 passed
Plugin scope unit tests `pytest tests/integration/test_encoded_exfil.py tests/integration/test_rate_limiter.py tests/unit/mcpgateway/plugins/...` ✅ 292 passed, 63 skipped (integration), 2 xfail
Doctests `pytest --doctest-modules mcpgateway/services/oauth_manager.py mcpgateway/services/token_storage_service.py` ✅ 12 passed
black / isort `make black isort` ✅ clean
ruff `uv tool run ruff check` on touched files ✅ no new findings (4 pre-existing F841 in untouched test code)
bandit `uv tool run bandit -ll` on touched files ✅ no new findings
pylint `uv tool run pylint` on touched files ✅ score 7.04 → 9.72

✅ Checklist

  • Code formatted (`make black isort pre-commit`)
  • Tests added/updated for changes (~17 new unit + integration tests, including parametrized edge cases for `parse_expires_in` and the new TTL-preservation refresh path)
  • Documentation updated (4 docs files updated to remove dead `OAUTH_DEFAULT_TIMEOUT` references; `_is_token_expired` and `cleanup_expired_tokens` docstrings expanded with the new `NULL`-expiry contract)
  • No secrets or credentials committed

📓 Notes

Original author credit: The first commit is signed by @ecthelion77 from #3992 — please give them the merge attribution. The branch is rebased onto current `main`; the second and third commits are the review-driven refinements described above.

Why a new PR? PR #3992 came from a fork I lack push access to (`maintainerCanModify` is set on the PR, but cross-repo push requires either SSH collaborator access on the fork or HTTPS-token auth that the upstream maintainer permission alone doesn't grant). Rather than ask @ecthelion77 to apply the changes manually, I'm opening this PR with the rebased + amended history. Once this is reviewed/merged, #3992 should be closed in favor of it.

Behavior changes worth highlighting in the changelog (none are silent breaking changes for callers, but operators should know):

  • `OAUTH_DEFAULT_TIMEOUT` config knob removed (was dead after the fix).
  • Tokens with no provider-supplied `expires_in` are now stored with `expires_at = NULL` (treated as never-expires locally) and aged out of `oauth_tokens` after `max_age_days` from `updated_at`.
  • Refresh responses missing `expires_in` now preserve the prior TTL instead of either crashing (pre-fix) or clearing the expiry to `NULL` (intermediate state never released).

Olivier Gintrand and others added 2 commits April 26, 2026 16:52
Signed-off-by: Olivier Gintrand <olivier.gintrand@forterro.com>
Builds on the missing-expires_in fix (issue #3989) with review feedback:

- Add parse_expires_in() helper (oauth_manager.py) that validates
  absent/numeric/string/zero values and raises OAuthError on negative,
  non-numeric, or non-scalar inputs
- Refresh path now uses the same helper so initial-auth and refresh
  agree on what 'missing expires_in' means (was hardcoded 3600s)
- cleanup_expired_tokens ages out NULL-expires_at rows via created_at
- _is_token_expired docstring documents the NULL-expiry contract
- Remove now-dead OAUTH_DEFAULT_TIMEOUT from config, env example,
  helm chart, docs, and test mocks
- Lazy %-style logging; drop redundant int() cast
- Add unit + integration tests for all new branches

Refs: #3989
Signed-off-by: Jonathan Springer <jps@s390x.com>
…ional floats

Addresses Oracle second-pass review findings on dd3c25f9:

- _refresh_access_token: when the refresh response omits expires_in,
  preserve the prior TTL by computing (expires_at - updated_at) from the
  existing record instead of clearing expires_at to NULL. Clearing it
  caused proactive refresh to stop after the first such response, since
  _is_token_expired returns False for NULL. Falls back to NULL only when
  the token had no prior expiry (genuine no-expiry providers like GitHub
  OAuth Apps). New _preserve_prior_ttl() module-level helper handles
  timezone-naive timestamps and non-positive deltas.

- cleanup_expired_tokens: NULL-expiry rows now age via updated_at, not
  created_at. store_tokens advances updated_at on re-authorization, so
  using created_at would delete tokens that were re-authorized recently
  but originally created more than max_age_days ago.

- parse_expires_in: tighten validation against int() truncation. Previously
  expires_in=-0.5 would silently pass as 0 because int(-0.5) == 0 (Python
  truncates toward zero). Now: sign-check the original numeric before
  conversion, and reject non-integer floats (RFC 6749 §5.1 specifies
  integer seconds). Order matters - sign check fires first since 'negative'
  is the more fundamental violation.

Tests:
- test_refresh_without_expires_in_clears_expiry replaced by:
  test_refresh_without_expires_in_preserves_prior_ttl (asserts new TTL
  ~= prior TTL after refresh) and
  test_refresh_without_expires_in_no_prior_ttl_stays_none.
- test_cleanup_expired_tokens_targets_null_expires_at now asserts the
  query uses updated_at AND does NOT use created_at.
- test_parse_expires_in_negative_raises extended with -0.5 and -3600.7.
- New test_parse_expires_in_non_integer_float_raises covers 3600.5, 0.5,
  3600.7.

Signed-off-by: Jonathan Springer <jps@s390x.com>
@jonpspri jonpspri force-pushed the fix/oauth-missing-expires-in-3992 branch from f379dfa to a611f35 Compare April 26, 2026 16:17
@jonpspri jonpspri merged commit c87de35 into main Apr 26, 2026
32 checks passed
@jonpspri jonpspri deleted the fix/oauth-missing-expires-in-3992 branch April 26, 2026 16:27
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.

[BUG]: OAuth token exchange fails when provider omits expires_in from token response

1 participant