Skip to content

fix: treat Keycloak refresh_expires_in=0 as never-expires sentinel#4060

Open
strawgate wants to merge 10 commits into
mainfrom
codex/keycloak-fix
Open

fix: treat Keycloak refresh_expires_in=0 as never-expires sentinel#4060
strawgate wants to merge 10 commits into
mainfrom
codex/keycloak-fix

Conversation

@strawgate
Copy link
Copy Markdown
Collaborator

@strawgate strawgate commented Apr 26, 2026

Fixes handling of Keycloak's refresh_expires_in=0 for offline tokens.

The bug: Keycloak returns refresh_expires_in=0 for offline_access tokens to mean "this refresh token never expires." The original code used Python's int truthiness (if int(val):), so 0 was treated as falsy and fell through to the 1-year wall-clock fallback. On subsequent refresh cycles the code inherited the decaying remaining time from that original timestamp — after ~1 year the FastMCP RT would be issued with a near-zero TTL, forcing re-authentication even though the Keycloak offline token was still valid.

Why not handle this globally: refresh_expires_in is not defined by RFC 6749 — it's a Keycloak extension. No spec-compliant provider would send 0 to mean "never expires." Treating 0 as a special sentinel in the base OAuthProxy would change behavior for all providers in an undocumented way.

The fix: KeycloakOAuthProxy(OAuthProxy), a new subclass in providers/keycloak.py, overrides _upstream_refresh_token_never_expires() to return True for val == 0. This keeps all Keycloak-specific knowledge in the Keycloak provider and leaves OAuthProxy unchanged for every other provider. A refresh_token_never_expires: bool field on UpstreamTokenSet persists the intent in stored state so that all three refresh paths (initial exchange, exchange_refresh_token, transparent access-token refresh) consistently issue a fresh full-TTL FastMCP RT on every cycle.

KeycloakOAuthProxy also provides a convenience constructor: pass realm_url and it derives the authorization, token, and revocation endpoints automatically.

from fastmcp import FastMCP
from fastmcp.server.auth.providers.keycloak import KeycloakOAuthProxy

auth = KeycloakOAuthProxy(
    realm_url="https://keycloak.example.com/realms/myrealm",
    upstream_client_id="my-client",
    upstream_client_secret="my-secret",
    base_url="https://my-mcp-server.example.com",
    jwt_signing_key="some-secret",
)

mcp = FastMCP("My App", auth=auth)

Refs #4054 — fixes Bug 3 (Keycloak refresh_expires_in=0). Bugs 1, 2 and 4 are tracked separately on that issue. Note this also introduces KeycloakOAuthProxy as a first-class provider with realm_url-based endpoint derivation.

@marvin-context-protocol marvin-context-protocol Bot added bug Something isn't working. Reports of errors, unexpected behavior, or broken functionality. auth Related to authentication (Bearer, JWT, OAuth, WorkOS) for client or server. server Related to FastMCP server implementation or server-side functionality. labels Apr 26, 2026
chatgpt-codex-connector[bot]

This comment was marked as resolved.

@strawgate strawgate added the DON'T MERGE PR is not ready for merging. Used by authors to prevent premature merging. label Apr 26, 2026
strawgate and others added 2 commits May 12, 2026 22:39
The initial auth-code exchange path had a bug where val==0 kept
refresh_expires_in as None, causing the fallback to be applied
despite the Keycloak never-expires sentinel. Now uses the same
keycloak_never_expires flag pattern as the other two code paths.

Co-authored-by: Copilot <[email protected]>
@strawgate strawgate force-pushed the codex/keycloak-fix branch from 51f073e to 07e3808 Compare May 13, 2026 03:39
chatgpt-codex-connector[bot]

This comment was marked as resolved.

@marvin-context-protocol
Copy link
Copy Markdown
Contributor

marvin-context-protocol Bot commented May 13, 2026

Edited to reflect the latest CI run (#25815958614). The previous TTL test failure is fixed; a new static-analysis failure has appeared.

tl;dr: ruff check fails on tests/server/auth/oauth_proxy/test_tokens.py:1025 with F811: Redefinition of unused proxy from line 661 — two @pytest.fixtures named proxy exist inside the same TestUpstreamTokenStorageTTL class (line 644). ruff format also wants to reformat the same file.

Root Cause: Both fixture definitions live inside TestUpstreamTokenStorageTTL — the original at tests/server/auth/oauth_proxy/test_tokens.py:661 (def proxy(self, jwt_verifier)) and a newly added one at line 1025 (def proxy(self, mock_verifier)). There is no intervening class statement (the next class boundary in the file is TestUpstreamTokenStorageTTL itself at line 644), so the second proxy shadows the first within the same class scope. Ruff catches this as F811. Ruff format separately wants a small reformat of the same file.

Fix: Move the new transparent-refresh tests (and their mock_verifier + second proxy fixture, starting around line 1024) into a new class — e.g. class TestTransparentUpstreamRefresh: — so each fixture lives in its own class scope. Then run uv run prek run --all-files locally and commit both the moved tests and the auto-applied ruff-format fix.

Log excerpt
F811 Redefinition of unused `proxy` from line 661
    --> tests/server/auth/oauth_proxy/test_tokens.py:1025:9
     |
1024 |     @pytest.fixture
1025 |     def proxy(self, mock_verifier):
     |         ^^^^^ `proxy` redefined here
...
 660 |     @pytest.fixture
 661 |     def proxy(self, jwt_verifier):
     |         ----- previous definition of `proxy` here
Found 1 error.

ruff format ... Failed
1 file reformatted, 760 files left unchanged
Related files
  • tests/server/auth/oauth_proxy/test_tokens.py:644class TestUpstreamTokenStorageTTL: (only class containing both fixtures)
  • tests/server/auth/oauth_proxy/test_tokens.py:660-662 — first proxy fixture (uses jwt_verifier)
  • tests/server/auth/oauth_proxy/test_tokens.py:1024-1037 — duplicate proxy fixture (uses mock_verifier) — likely belongs in a new test class for the transparent-refresh tests below it

chatgpt-codex-connector[bot]

This comment was marked as resolved.

@strawgate strawgate removed the DON'T MERGE PR is not ready for merging. Used by authors to prevent premature merging. label May 13, 2026
@strawgate
Copy link
Copy Markdown
Collaborator Author

I'm a little worried about handling this 0 sentinel globally, will investigate

… on subsequent refreshes

When Keycloak returns refresh_expires_in=0 (offline token, never expires) on
every token response, the previous code only guarded the initial exchange path.
On subsequent exchange_refresh_token and transparent refresh cycles, the code
would fall through to 'keep existing expiry' — inheriting the wall-clock
timestamp set at initial exchange.  After ~1 year that decayed to ~0 seconds,
issuing FastMCP RTs with 1-second TTL and forcing re-auth even though the
Keycloak offline token was still valid.

Fix: add elif val == 0 to both refresh paths that clears refresh_token_expires_at
to None.  The fallback branch then issues a fresh full fallback-TTL FastMCP RT
on every cycle, matching the 'always-valid' semantics of offline tokens.

Also improves the debug log message to distinguish 'never expires' from
'expiry not provided' so operators can see exactly what Keycloak sent.

Adds a regression test: test_refresh_expires_in_zero_subsequent_refresh_does_not_shrink

Co-authored-by: Copilot <[email protected]>
@strawgate strawgate added the DON'T MERGE PR is not ready for merging. Used by authors to prevent premature merging. label May 13, 2026
strawgate and others added 2 commits May 13, 2026 15:30
Keycloak returns refresh_expires_in=0 for offline_access tokens to signal
'this refresh token never expires'. Base OAuthProxy has no way to know this
is intentional rather than a malformed response, so it falls through to the
standard 1-year wall-clock fallback — which causes the FastMCP refresh token
TTL to shrink on every subsequent refresh cycle until it reaches ~0 after
one year, forcing re-authentication even though the Keycloak offline token
is still valid.

This commit:
- Adds KeycloakOAuthProxy(OAuthProxy) to providers/keycloak.py with a
  convenience __init__ that derives OIDC endpoints from realm_url
- Adds _zero_refresh_expiry_means_never_expires: bool = False class attr
  on OAuthProxy; KeycloakOAuthProxy sets it to True
- Adds refresh_token_never_expires: bool = False to UpstreamTokenSet so
  the intent is visible in stored state
- When the flag is set and val==0: marks the token as never-expiring and
  clears refresh_token_expires_at so subsequent refresh cycles always get
  a fresh full fallback-TTL FastMCP RT instead of a decaying one
- Base OAuthProxy is completely unchanged for val==0: falls through to
  the existing 1-year wall-clock fallback as before

Tests:
- test_refresh_expires_in_zero_issues_refresh_token: KeycloakOAuthProxy
  correctly issues a refresh token and marks upstream as never-expiring
- test_refresh_expires_in_zero_subsequent_refresh_does_not_shrink: TTL
  stays at ~1 year after repeated refresh cycles
- test_base_proxy_does_not_treat_zero_as_never_expires: confirms base
  OAuthProxy behaviour is unaffected

Co-authored-by: Copilot <[email protected]>
…rride

Replaces the _zero_refresh_expiry_means_never_expires class attribute with
a proper override hook. The base class stays free of any provider-specific
knowledge; KeycloakOAuthProxy overrides the method and returns True for val==0.

Co-authored-by: Copilot <[email protected]>
chatgpt-codex-connector[bot]

This comment was marked as resolved.

chatgpt-codex-connector[bot]

This comment was marked as resolved.

…ess DCR

🤖 Generated with Claude Code

Co-Authored-By: Claude Opus 4.7 <[email protected]>
chatgpt-codex-connector[bot]

This comment was marked as resolved.

strawgate and others added 2 commits May 17, 2026 00:26
…xy docs

🤖 Generated with Claude Code

Co-Authored-By: Claude Opus 4.7 <[email protected]>
…backs

🤖 Generated with Claude Code

Co-Authored-By: Claude Opus 4.7 <[email protected]>
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f19df2dce7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread fastmcp_slim/fastmcp/server/auth/providers/keycloak.py
🤖 Generated with Claude Code

Co-Authored-By: Claude Opus 4.7 <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

auth Related to authentication (Bearer, JWT, OAuth, WorkOS) for client or server. bug Something isn't working. Reports of errors, unexpected behavior, or broken functionality. DON'T MERGE PR is not ready for merging. Used by authors to prevent premature merging. server Related to FastMCP server implementation or server-side functionality.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant