Skip to content

fix: handle missing expires_in in OAuth token response#3992

Closed
ecthelion77 wants to merge 1 commit intoIBM:mainfrom
forterro:fix/oauth-no-expiry-fallback-upstream
Closed

fix: handle missing expires_in in OAuth token response#3992
ecthelion77 wants to merge 1 commit intoIBM:mainfrom
forterro:fix/oauth-no-expiry-fallback-upstream

Conversation

@ecthelion77
Copy link
Copy Markdown
Contributor

🐛 Bug-fix PR

📌 Summary

When an OAuth2 provider omits the expires_in field from the token response, the gateway crashes with a TypeError during token storage. The expires_in parameter is RECOMMENDED but not REQUIRED per RFC 6749 Section 5.1, so providers may legitimately omit it (e.g., GitHub OAuth Apps, some Azure AD configurations).

Fixes #3989

🔁 Reproduction Steps

  1. Configure a gateway with OAuth2 pointing to a provider that does not return expires_in in the token response
  2. Trigger the OAuth authorization code flow
  3. Token exchange succeeds, but storing the token fails with TypeError: unsupported operand type(s) for +: 'datetime' and 'NoneType'

🐞 Root Cause

In oauth_manager.py, token_response.get("expires_in", self.settings.oauth_default_timeout) always returns a value, which hides the issue. However, when the provider returns expires_in: null or omits it entirely and the fallback default is also None, the code in token_storage_service.py tries to compute datetime.now() + timedelta(seconds=int(None)) which raises TypeError.

More importantly, the current code papers over the absence of expires_in by substituting oauth_default_timeout, which may not reflect the actual token lifetime — the token could be valid indefinitely.

💡 Fix Description

oauth_manager.py: Extract expires_in with .get() and explicitly convert to int only when present. Pass None when the provider does not specify expiration.

token_storage_service.py:

  • Change expires_in parameter type from int to Optional[int]
  • When expires_in is None, set expires_at = None and log an informational message
  • When expires_in is provided, compute expires_at as before

The existing _is_token_expired() method already handles expires_at=None correctly (returns False, treating the token as non-expired).

🧪 Verification

Check Command Status
Lint suite make lint ⚠️ Not run (no local dev env)
Unit tests make test ⚠️ Not run (no local dev env)
Coverage ≥ 80 % make coverage ⚠️ Not run (no local dev env)
Manual regression no longer fails Tested in production (Kubernetes) for 2+ weeks with Freshservice OAuth (which returns expires_in) and verified graceful handling when expires_in is absent

📐 MCP Compliance (if relevant)

  • Matches current MCP spec
  • No breaking change to MCP clients

✅ Checklist

  • Code formatted
  • No secrets/credentials committed
  • DCO Signed-off-by included

@jonpspri jonpspri added bug Something isn't working MUST P1: Non-negotiable, critical requirements without which the product is non-functional or unsafe labels Apr 9, 2026
@ecthelion77 ecthelion77 force-pushed the fix/oauth-no-expiry-fallback-upstream branch from e4c8753 to 7f4b8f3 Compare April 13, 2026 10:25
@ecthelion77
Copy link
Copy Markdown
Contributor Author

Suggested labels: python, security

@ecthelion77 ecthelion77 force-pushed the fix/oauth-no-expiry-fallback-upstream branch from 7f4b8f3 to a8afade Compare April 14, 2026 12:46
@ecthelion77 ecthelion77 force-pushed the fix/oauth-no-expiry-fallback-upstream branch 3 times, most recently from 7cecdb3 to ce0704b Compare April 14, 2026 15:45
Signed-off-by: Olivier Gintrand <olivier.gintrand@forterro.com>
@jonpspri
Copy link
Copy Markdown
Collaborator

Hi @ecthelion77 — thanks again for catching this and shipping the original fix. After working through review feedback (refresh-path consistency, NULL-row cleanup, edge-case validation, dead-config cleanup), the diff grew enough that I wanted to share a tightened version with you, but I don't have push access to your fork (the maintainerCanModify flag only enables cross-repo push for users with SSH-collaborator access on the fork itself, not for upstream-only maintainers like me).

Rather than ask you to manually apply the changes, I've opened #4447 against main with your original commit preserved as the first commit (signed by you, full DCO intact) and the review fixes layered on top as separate commits so the attribution stays clear.

Closing this PR in favor of #4447. The fix you wrote will land — just under a different PR number. Really appreciate the contribution!

@jonpspri jonpspri closed this Apr 26, 2026
jonpspri added a commit that referenced this pull request Apr 26, 2026
…edes #3992) (#4447)

* fix(oauth): handle missing expires_in in OAuth token response

Signed-off-by: Olivier Gintrand <olivier.gintrand@forterro.com>

* fix(oauth): tighten expires_in handling and prune dead config

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>

* fix(oauth): preserve TTL on refresh, age via updated_at, reject fractional 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>

---------

Signed-off-by: Olivier Gintrand <olivier.gintrand@forterro.com>
Signed-off-by: Jonathan Springer <jps@s390x.com>
Co-authored-by: Olivier Gintrand <olivier.gintrand@forterro.com>
@ecthelion77 ecthelion77 deleted the fix/oauth-no-expiry-fallback-upstream branch April 27, 2026 08:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working MUST P1: Non-negotiable, critical requirements without which the product is non-functional or unsafe

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

2 participants