fix: handle missing expires_in in OAuth token response#3992
fix: handle missing expires_in in OAuth token response#3992ecthelion77 wants to merge 1 commit intoIBM:mainfrom
Conversation
e4c8753 to
7f4b8f3
Compare
|
Suggested labels: |
7f4b8f3 to
a8afade
Compare
7cecdb3 to
ce0704b
Compare
Signed-off-by: Olivier Gintrand <olivier.gintrand@forterro.com>
ce0704b to
85b93c8
Compare
|
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 Rather than ask you to manually apply the changes, I've opened #4447 against Closing this PR in favor of #4447. The fix you wrote will land — just under a different PR number. Really appreciate the contribution! |
…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>
🐛 Bug-fix PR
📌 Summary
When an OAuth2 provider omits the
expires_infield from the token response, the gateway crashes with aTypeErrorduring token storage. Theexpires_inparameter 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
expires_inin the token responseTypeError: 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 returnsexpires_in: nullor omits it entirely and the fallback default is alsoNone, the code intoken_storage_service.pytries to computedatetime.now() + timedelta(seconds=int(None))which raisesTypeError.More importantly, the current code papers over the absence of
expires_inby substitutingoauth_default_timeout, which may not reflect the actual token lifetime — the token could be valid indefinitely.💡 Fix Description
oauth_manager.py: Extractexpires_inwith.get()and explicitly convert tointonly when present. PassNonewhen the provider does not specify expiration.token_storage_service.py:expires_inparameter type frominttoOptional[int]expires_in is None, setexpires_at = Noneand log an informational messageexpires_inis provided, computeexpires_atas beforeThe existing
_is_token_expired()method already handlesexpires_at=Nonecorrectly (returnsFalse, treating the token as non-expired).🧪 Verification
make lintmake testmake coverage📐 MCP Compliance (if relevant)
✅ Checklist