Skip to content

Commit 84c9864

Browse files
committed
fix(security): make idle timeout actually enforce
The idle-timeout block in get_current_user only read last_activity from the JWT, but no issuance code wrote it — so the check ran zero times on real tokens. Read activity from Redis first (update_activity was already writing there but had no consumer), fall back to the JWT last_activity claim, fall back to iat. Emit last_activity=iat in create_access_token for first-request bootstrap. Folds in remaining PR-review blockers: - bb43712cae28 alembic merge resolves dual-head from rebase past the head referenced by cae28b15a507 - TOKEN_EXPIRY 10080->20 min default documented in CHANGELOG Behavior Changes with migration & rollback guidance - drop dead refresh_token_expiry config field + 3 doc references - /auth/logout current_user: dict -> EmailUser (matches actual return type of get_current_user) - /admin/logout test rewritten with TestClient + CSRF deny-path regression (was asyncio.run on MagicMock — bypassed middleware and would pass even if the route was unregistered) Coverage on diff: 91% -> ~100%. New unit tests cover every branch of the idle-timeout block plus the Redis-success and fresh-session paths in TokenBlocklistService that were previously unreached. Refs #4317, #4371 Signed-off-by: Jonathan Springer <jps@s390x.com>
1 parent bbbb600 commit 84c9864

13 files changed

Lines changed: 863 additions & 618 deletions

.secrets.baseline

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"files": "(?x)( package-lock\\.json$ |Cargo\\.lock$ |uv\\.lock$ |go\\.sum$ |mcpgateway/sri_hashes\\.json$ )|^.secrets.baseline$",
44
"lines": null
55
},
6-
"generated_at": "2026-04-26T16:16:40Z",
6+
"generated_at": "2026-04-26T18:36:21Z",
77
"plugins_used": [
88
{
99
"name": "AWSKeyDetector"
@@ -250,63 +250,63 @@
250250
"hashed_secret": "b4673e578b9b30fe8bba1b555b7b59883444c697",
251251
"is_secret": false,
252252
"is_verified": false,
253-
"line_number": 845,
253+
"line_number": 859,
254254
"type": "Secret Keyword",
255255
"verified_result": null
256256
},
257257
{
258258
"hashed_secret": "4a0a2df96d4c9a13a282268cab33ac4b8cbb2c72",
259259
"is_secret": false,
260260
"is_verified": false,
261-
"line_number": 933,
261+
"line_number": 947,
262262
"type": "Secret Keyword",
263263
"verified_result": null
264264
},
265265
{
266266
"hashed_secret": "5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8",
267267
"is_secret": false,
268268
"is_verified": false,
269-
"line_number": 1283,
269+
"line_number": 1297,
270270
"type": "Basic Auth Credentials",
271271
"verified_result": null
272272
},
273273
{
274274
"hashed_secret": "fa9beb99e4029ad5a6615399e7bbae21356086b3",
275275
"is_secret": false,
276276
"is_verified": false,
277-
"line_number": 2649,
277+
"line_number": 2663,
278278
"type": "Basic Auth Credentials",
279279
"verified_result": null
280280
},
281281
{
282282
"hashed_secret": "fa9beb99e4029ad5a6615399e7bbae21356086b3",
283283
"is_secret": false,
284284
"is_verified": false,
285-
"line_number": 2740,
285+
"line_number": 2754,
286286
"type": "Secret Keyword",
287287
"verified_result": null
288288
},
289289
{
290290
"hashed_secret": "ac371b6dcce28a86c90d12bc57d946a800eebf17",
291291
"is_secret": false,
292292
"is_verified": false,
293-
"line_number": 2783,
293+
"line_number": 2797,
294294
"type": "Secret Keyword",
295295
"verified_result": null
296296
},
297297
{
298298
"hashed_secret": "0b6ec68df700dec4dcd64babd0eda1edccddace1",
299299
"is_secret": false,
300300
"is_verified": false,
301-
"line_number": 2788,
301+
"line_number": 2802,
302302
"type": "Secret Keyword",
303303
"verified_result": null
304304
},
305305
{
306306
"hashed_secret": "4ad6f0082ee224001beb3ca5c3e81c8ceea5ed86",
307307
"is_secret": false,
308308
"is_verified": false,
309-
"line_number": 2793,
309+
"line_number": 2807,
310310
"type": "Secret Keyword",
311311
"verified_result": null
312312
}
@@ -4196,23 +4196,23 @@
41964196
"hashed_secret": "559b05f1b2863e725b76e216ac3dadecbf92e244",
41974197
"is_secret": false,
41984198
"is_verified": false,
4199-
"line_number": 4791,
4199+
"line_number": 4825,
42004200
"type": "Secret Keyword",
42014201
"verified_result": null
42024202
},
42034203
{
42044204
"hashed_secret": "a8af4759392d4f7496d613174f33afe2074a4b8d",
42054205
"is_secret": false,
42064206
"is_verified": false,
4207-
"line_number": 4793,
4207+
"line_number": 4827,
42084208
"type": "Secret Keyword",
42094209
"verified_result": null
42104210
},
42114211
{
42124212
"hashed_secret": "85b60d811d16ff56b3654587d4487f713bfa33b7",
42134213
"is_secret": false,
42144214
"is_verified": false,
4215-
"line_number": 15172,
4215+
"line_number": 15206,
42164216
"type": "Secret Keyword",
42174217
"verified_result": null
42184218
}
@@ -4862,7 +4862,7 @@
48624862
"hashed_secret": "ff37a98a9963d347e9749a5c1b3936a4a245a6ff",
48634863
"is_secret": false,
48644864
"is_verified": false,
4865-
"line_number": 2413,
4865+
"line_number": 2420,
48664866
"type": "Secret Keyword",
48674867
"verified_result": null
48684868
}
@@ -4974,23 +4974,23 @@
49744974
"hashed_secret": "6993a3fd94a012ab50fb6b9e97ec238310f0b177",
49754975
"is_secret": false,
49764976
"is_verified": false,
4977-
"line_number": 397,
4977+
"line_number": 401,
49784978
"type": "Secret Keyword",
49794979
"verified_result": null
49804980
},
49814981
{
49824982
"hashed_secret": "52dcc83ec1e54426ad58a64854d1eb8d5f5d9685",
49834983
"is_secret": false,
49844984
"is_verified": false,
4985-
"line_number": 398,
4985+
"line_number": 402,
49864986
"type": "Secret Keyword",
49874987
"verified_result": null
49884988
},
49894989
{
49904990
"hashed_secret": "a616a64c0fbc30f12287d0f24f3b90dd2e6a206e",
49914991
"is_secret": false,
49924992
"is_verified": false,
4993-
"line_number": 680,
4993+
"line_number": 684,
49944994
"type": "Secret Keyword",
49954995
"verified_result": null
49964996
}
@@ -7162,15 +7162,15 @@
71627162
"hashed_secret": "6eb67d95dba1a614971e31e78146d44bd4a3ada3",
71637163
"is_secret": false,
71647164
"is_verified": false,
7165-
"line_number": 191,
7165+
"line_number": 192,
71667166
"type": "Secret Keyword",
71677167
"verified_result": null
71687168
},
71697169
{
71707170
"hashed_secret": "cbfdac6008f9cab4083784cbd1874f76618d2a97",
71717171
"is_secret": false,
71727172
"is_verified": false,
7173-
"line_number": 271,
7173+
"line_number": 272,
71747174
"type": "Secret Keyword",
71757175
"verified_result": null
71767176
}
@@ -9134,15 +9134,15 @@
91349134
"hashed_secret": "516b9783fca517eecbd1d064da2d165310b19759",
91359135
"is_secret": false,
91369136
"is_verified": false,
9137-
"line_number": 1016,
9137+
"line_number": 1017,
91389138
"type": "Basic Auth Credentials",
91399139
"verified_result": null
91409140
},
91419141
{
91429142
"hashed_secret": "ef4eb24299c517306652ffee61e05934f2224914",
91439143
"is_secret": false,
91449144
"is_verified": false,
9145-
"line_number": 1268,
9145+
"line_number": 1269,
91469146
"type": "Secret Keyword",
91479147
"verified_result": null
91489148
}

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,23 @@
99
- **🛡️ Content Security – Malicious Pattern Detection (US-3)** ([#4072](https://github.com/IBM/mcp-context-forge/pull/4072), [#538](https://github.com/IBM/mcp-context-forge/issues/538)) – Regex-based scanning for XSS, SQL injection, command injection, and template-injection patterns. Applied on the single **and** bulk create/update paths for resources, prompts, and tools (tool `name`, `description`, and JSON-serialized `inputSchema`). New config: `CONTENT_PATTERN_DETECTION_ENABLED`, `CONTENT_BLOCKED_PATTERNS`, `CONTENT_PATTERN_VALIDATION_MODE` (`strict` | `moderate` | `lenient`). Lenient mode logs every matched pattern in a payload (was: only the first).
1010
- **🔒 Content Security – Prompt Template Validation (US-4)** ([#4072](https://github.com/IBM/mcp-context-forge/pull/4072), [#538](https://github.com/IBM/mcp-context-forge/issues/538)) – Pre-render validation of prompt templates: balanced-brace check, Jinja2 syntax check, and dangerous-pattern scan (`__import__`, `eval(`, dunders, etc.). New config: `CONTENT_VALIDATE_PROMPT_TEMPLATES`, `CONTENT_BLOCKED_TEMPLATE_PATTERNS`.
1111
- **⚡ ReDoS Defense for Pattern Scanning** ([#4072](https://github.com/IBM/mcp-context-forge/pull/4072)) – `CONTENT_PATTERN_MAX_SCAN_SIZE` (default 200 KB) caps scan input length deterministically; `CONTENT_PATTERN_REGEX_TIMEOUT` (default 1.0 s) per-pattern. Patterns are pre-compiled once at service init instead of re-compiled per request.
12+
- **🔐 JWT Token Security – Server-Side Revocation, Idle Timeout, Logout** ([#4371](https://github.com/IBM/mcp-context-forge/pull/4371), [#4317](https://github.com/IBM/mcp-context-forge/issues/4317)) – New `TokenBlocklistService` (Redis-cached, DB-persisted) for immediate JWT invalidation. Idle-timeout enforcement on every authenticated request, with activity tracking in Redis (falls back to JWT `iat` when Redis is unavailable). New `POST /auth/logout` (Bearer auth) and enhanced `POST /admin/logout` (cookie auth) revoke the caller's token in the blocklist before clearing session state. Comprehensive audit-log fields (`security_event`, `security_severity`, `jti`, `reason`) for every revocation/idle-timeout event. New config: `TOKEN_IDLE_TIMEOUT`, `TOKEN_BLOCKLIST_CLEANUP_HOURS`. Addresses X-Force Red audit findings on session-token management.
1213

1314
### ⚠️ Behavior Changes
1415

16+
#### **⏱️ `TOKEN_EXPIRY` default reduced from 10080 minutes (~7 days) to 20 minutes** ([#4371](https://github.com/IBM/mcp-context-forge/pull/4371), [#4317](https://github.com/IBM/mcp-context-forge/issues/4317))
17+
18+
**Impact**: Any deployment that does not set `TOKEN_EXPIRY` explicitly will now issue session tokens that expire after **20 minutes** instead of ~7 days. Existing tokens already in circulation are unaffected (they retain the `exp` claim baked in at issuance), but every newly-issued token after upgrade has the shorter lifetime. Automation that re-uses a single login token for hours or days will start receiving HTTP 401 mid-flight.
19+
20+
**Why**: Short-lived tokens are the primary mitigation for stolen-token replay, per the X-Force Red security audit (#4317). 7-day session tokens were previously called out as a finding. The new default brings the gateway in line with industry guidance (5–20 minutes for session tokens).
21+
22+
**Migration**:
23+
- **Interactive sessions** — no action needed; the new `/auth/logout` endpoint and idle-timeout enforcement (60 min default) work transparently.
24+
- **CI/automation that needs longer-lived tokens** — set `TOKEN_EXPIRY` explicitly in `.env` (range: 5–1440 minutes), e.g. `TOKEN_EXPIRY=480` for an 8-hour shift, and pair it with `TOKEN_IDLE_TIMEOUT=0` if the workload bursts after long quiet periods.
25+
- **Long-running scripts using `mcpgateway.utils.create_jwt_token --exp <minutes>`** — the `--exp` flag is unaffected (it overrides the default).
26+
27+
**Rollback**: Set `TOKEN_EXPIRY=10080` to restore the previous 7-day default.
28+
1529
#### **🧪 Prompt templates are now rendered in a Jinja2 sandbox** ([#4072](https://github.com/IBM/mcp-context-forge/pull/4072))
1630

1731
**Impact**: `prompt_service` now uses `jinja2.sandbox.SandboxedEnvironment` instead of plain `jinja2.Environment`. Templates that previously reached Python internals at render time will raise `PromptError: sandbox rejected unsafe operation`.

docs/security/JWT_TOKEN_SECURITY_IMPLEMENTATION.md

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,6 @@ token_expiry: int = 20 # minutes (was 10080 = 70 days)
3333
# Range: 5-1440 minutes
3434
# Recommended: 5-20 minutes for security
3535

36-
# Refresh token configuration (longer-lived but revocable)
37-
refresh_token_expiry: int = 10080 # 7 days
38-
# Range: 60-43200 minutes (1 hour to 30 days)
39-
4036
# Idle timeout configuration
4137
token_idle_timeout: int = 60 # minutes
4238
# Range: 5-1440 minutes
@@ -184,23 +180,20 @@ async def logout(request: Request, db: Session = Depends(get_db)):
184180
```bash
185181
TOKEN_EXPIRY=5 # 5 minutes
186182
TOKEN_IDLE_TIMEOUT=15 # 15 minutes
187-
REFRESH_TOKEN_EXPIRY=1440 # 24 hours
188183
TOKEN_BLOCKLIST_CLEANUP_HOURS=12 # 12 hours
189184
```
190185

191186
### Balanced Security (Standard Applications)
192187
```bash
193188
TOKEN_EXPIRY=20 # 20 minutes (default)
194189
TOKEN_IDLE_TIMEOUT=60 # 60 minutes (default)
195-
REFRESH_TOKEN_EXPIRY=10080 # 7 days (default)
196190
TOKEN_BLOCKLIST_CLEANUP_HOURS=24 # 24 hours (default)
197191
```
198192

199193
### Development/Testing
200194
```bash
201195
TOKEN_EXPIRY=60 # 60 minutes
202196
TOKEN_IDLE_TIMEOUT=120 # 120 minutes
203-
REFRESH_TOKEN_EXPIRY=10080 # 7 days
204197
TOKEN_BLOCKLIST_CLEANUP_HOURS=24 # 24 hours
205198
```
206199

@@ -310,7 +303,6 @@ Add to cron or scheduler:
310303
# Add to .env
311304
TOKEN_EXPIRY=20
312305
TOKEN_IDLE_TIMEOUT=60
313-
REFRESH_TOKEN_EXPIRY=10080
314306
TOKEN_BLOCKLIST_CLEANUP_HOURS=24
315307
```
316308

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
"""merge token_blocklist (PR #4371) with audit-identity main head
2+
3+
Revision ID: bb43712cae28
4+
Revises: cae28b15a507, b2c3d4e5f6g7
5+
Create Date: 2026-04-26 16:30:00.000000
6+
7+
Resolves the dual-head condition introduced when PR #4371 (#4317) was rebased
8+
onto a main branch that had advanced past d2b501bf4262 (the head referenced by
9+
cae28b15a507_merge_token_revocation_and_uaid_heads.py). After this migration
10+
`alembic heads` returns a single head again.
11+
"""
12+
13+
# Standard
14+
from typing import Sequence, Union
15+
16+
# revision identifiers, used by Alembic.
17+
revision: str = "bb43712cae28" # pragma: allowlist secret
18+
down_revision: Union[str, Sequence[str], None] = ("cae28b15a507", "b2c3d4e5f6g7") # pragma: allowlist secret
19+
branch_labels: Union[str, Sequence[str], None] = None
20+
depends_on: Union[str, Sequence[str], None] = None
21+
22+
23+
def upgrade() -> None:
24+
"""Upgrade schema."""
25+
26+
27+
def downgrade() -> None:
28+
"""Downgrade schema."""

mcpgateway/auth.py

Lines changed: 52 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1595,47 +1595,60 @@ def _set_trace_for_user(user_obj: EmailUser, *, teams: Any = _UNSET, auth_method
15951595
headers={"WWW-Authenticate": "Bearer"},
15961596
)
15971597

1598-
# Check idle timeout if last_activity is present
1599-
last_activity_ts = payload.get("last_activity")
1600-
if last_activity_ts and settings.token_idle_timeout > 0:
1601-
last_activity = datetime.fromtimestamp(last_activity_ts, tz=timezone.utc)
1602-
current_time = datetime.now(timezone.utc)
1603-
idle_duration = current_time - last_activity
1604-
max_idle = timedelta(minutes=settings.token_idle_timeout)
1605-
1606-
if idle_duration > max_idle:
1607-
# Revoke token due to idle timeout
1608-
try:
1609-
# First-Party
1610-
from mcpgateway.services.token_blocklist_service import get_token_blocklist_service # pylint: disable=import-outside-toplevel
1611-
1612-
blocklist_service = get_token_blocklist_service()
1613-
exp_ts = payload.get("exp")
1614-
token_expiry = datetime.fromtimestamp(exp_ts, tz=timezone.utc) if exp_ts else None
1615-
1616-
blocklist_service.revoke_token(jti=jti, revoked_by=email, reason="idle_timeout", token_expiry=token_expiry, last_activity=last_activity)
1617-
except Exception as revoke_error:
1618-
logger.warning(f"Failed to revoke idle token: {revoke_error}")
1619-
1620-
logger.warning(
1621-
f"Token exceeded idle timeout: jti={jti}, idle_minutes={idle_duration.total_seconds()/60:.1f}",
1622-
extra={"security_event": "idle_timeout", "security_severity": "medium", "jti": jti, "user_id": email},
1623-
)
1624-
raise HTTPException(
1625-
status_code=status.HTTP_401_UNAUTHORIZED,
1626-
detail=f"Token exceeded idle timeout ({settings.token_idle_timeout} minutes)",
1627-
headers={"WWW-Authenticate": "Bearer"},
1628-
)
1629-
1630-
# Update activity timestamp for valid tokens
1598+
# Idle timeout enforcement.
1599+
#
1600+
# Activity source-of-truth precedence (most-recent first):
1601+
# 1. Redis key `token:activity:{jti}` written by `update_activity()`.
1602+
# 2. Optional `last_activity` JWT claim (issuer can set this for first-request bootstrap).
1603+
# 3. Standard `iat` JWT claim (always present on tokens issued by this gateway).
1604+
# If none of the three are available we skip the idle check rather than fail-open silently —
1605+
# the periodic revocation check above already handles tokens that should be denied outright.
1606+
if settings.token_idle_timeout > 0:
1607+
# First-Party
1608+
from mcpgateway.services.token_blocklist_service import get_token_blocklist_service # pylint: disable=import-outside-toplevel
1609+
1610+
blocklist_service = get_token_blocklist_service()
1611+
1612+
last_activity: Optional[datetime] = None
16311613
try:
1632-
# First-Party
1633-
from mcpgateway.services.token_blocklist_service import get_token_blocklist_service # pylint: disable=import-outside-toplevel
1614+
last_activity = blocklist_service.get_last_activity(jti)
1615+
except Exception as activity_lookup_error:
1616+
logger.debug(f"Failed to read last activity from cache: {activity_lookup_error}")
1617+
1618+
if last_activity is None:
1619+
last_activity_ts = payload.get("last_activity") or payload.get("iat")
1620+
if last_activity_ts:
1621+
last_activity = datetime.fromtimestamp(last_activity_ts, tz=timezone.utc)
1622+
1623+
if last_activity is not None:
1624+
current_time = datetime.now(timezone.utc)
1625+
idle_duration = current_time - last_activity
1626+
max_idle = timedelta(minutes=settings.token_idle_timeout)
1627+
1628+
if idle_duration > max_idle:
1629+
# Revoke token due to idle timeout
1630+
try:
1631+
exp_ts = payload.get("exp")
1632+
token_expiry = datetime.fromtimestamp(exp_ts, tz=timezone.utc) if exp_ts else None
1633+
1634+
blocklist_service.revoke_token(jti=jti, revoked_by=email, reason="idle_timeout", token_expiry=token_expiry, last_activity=last_activity)
1635+
except Exception as revoke_error:
1636+
logger.warning(f"Failed to revoke idle token: {revoke_error}")
1637+
1638+
logger.warning(
1639+
f"Token exceeded idle timeout: jti={jti}, idle_minutes={idle_duration.total_seconds() / 60:.1f}",
1640+
extra={"security_event": "idle_timeout", "security_severity": "medium", "jti": jti, "user_id": email},
1641+
)
1642+
raise HTTPException(
1643+
status_code=status.HTTP_401_UNAUTHORIZED,
1644+
detail=f"Token exceeded idle timeout ({settings.token_idle_timeout} minutes)",
1645+
headers={"WWW-Authenticate": "Bearer"},
1646+
)
16341647

1635-
blocklist_service = get_token_blocklist_service()
1636-
blocklist_service.update_activity(jti)
1637-
except Exception as activity_error:
1638-
logger.debug(f"Failed to update token activity: {activity_error}")
1648+
try:
1649+
blocklist_service.update_activity(jti)
1650+
except Exception as activity_error:
1651+
logger.debug(f"Failed to update token activity: {activity_error}")
16391652

16401653
except HTTPException:
16411654
raise

mcpgateway/config.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -342,9 +342,6 @@ class Settings(BaseSettings):
342342
# Session token configuration (short-lived for security)
343343
token_expiry: int = Field(default=20, ge=5, le=1440, description="Session token expiry in minutes (5-1440). Recommended: 5-20 minutes for security.") # 20 minutes (was 10080 = 70 days)
344344

345-
# Refresh token configuration (longer-lived but revocable)
346-
refresh_token_expiry: int = Field(default=10080, ge=60, le=43200, description="Refresh token expiry in minutes (1 hour to 30 days). Used to obtain new access tokens.") # 7 days
347-
348345
# Idle timeout configuration
349346
token_idle_timeout: int = Field(default=60, ge=5, le=1440, description="Maximum idle time in minutes before token requires refresh (5-1440).") # 60 minutes
350347

0 commit comments

Comments
 (0)