Repository: IBM/mcp-context-forge

Issue:
[SECURITY FEATURE]: Make JWT Token Expiration Mandatory when REQUIRE_TOKEN_EXPIRATION=true (depends on #87)
# Feature: Configurable JWT Token Expiration Requirement

## 🎯 Overview

### Summary
Add configurable enforcement of JWT token expiration claims, allowing administrators to choose between strict security (mandatory expiration) or flexibility (optional expiration with warnings).

### Problem Statement
Currently, tokens created with `--exp 0` have no expiration and remain valid forever. While this is sometimes necessary for long-running automations, it poses a security risk. There's no way to enforce expiration requirements or get warnings about non-expiring tokens.

### Solution
Introduce a `REQUIRE_TOKEN_EXPIRATION` configuration setting that:
- When `true`: Rejects tokens without expiration (strict mode)
- When `false`: Accepts tokens without expiration but logs warnings (flexible mode)
- Provides clear audit trail for security compliance

### Dependencies
- **Requires**: #87 - JWT Token Catalog with Per-User Expiry and Revocation
  - Until automatic token renewal is available via the JWT API, some users may need non-expiring tokens
  - Once implemented, administrators can enforce expiration and use the API for token renewal

## 📊 Architecture

```mermaid
flowchart TD
    subgraph "Token Validation Flow"
        T[Token] --> CHECK{Has exp claim?}
        CHECK -->|Yes| VALIDATE[Validate expiration]
        CHECK -->|No| CONFIG{REQUIRE_TOKEN_EXPIRATION?}
        
        CONFIG -->|true| REJECT[❌ HTTP 401]
        CONFIG -->|false| WARN[⚠️ Log warning]
        
        VALIDATE -->|Valid| OK[✅ Authorized]
        VALIDATE -->|Expired| EXPIRED[❌ Token expired]
        WARN --> OK
    end
    
    subgraph "Configuration"
        ENV[.env] --> SETTING[REQUIRE_TOKEN_EXPIRATION=true/false]
    end
    
    style REJECT fill:#FFB6C1
    style WARN fill:#FFA500
    style OK fill:#90EE90
    style EXPIRED fill:#FFB6C1
```

## 🏗️ Technical Design

### Configuration
```python
# In settings.py
require_token_expiration: bool = Field(
    default=False,  # Default to flexible mode for backward compatibility
    description="Require all JWT tokens to have expiration claims"
)
```

### Environment Variable
```bash
# .env
REQUIRE_TOKEN_EXPIRATION=false  # Allow non-expiring tokens with warnings
# or
REQUIRE_TOKEN_EXPIRATION=true   # Strict mode - reject non-expiring tokens
```

### Implementation
```python
async def verify_jwt_token(token: str) -> dict:
    """Verify and decode a JWT token with configurable expiration requirement."""
    try:
        # First decode to check claims
        unverified = jwt.decode(token, options={"verify_signature": False})
        
        # Check for expiration claim
        if "exp" not in unverified and settings.require_token_expiration:
            raise jwt.MissingRequiredClaimError("exp")
        
        # Log warning for non-expiring tokens
        if "exp" not in unverified:
            logger.warning(
                "JWT token without expiration accepted. "
                "Consider enabling REQUIRE_TOKEN_EXPIRATION for better security. "
                f"Token sub: {unverified.get('sub', 'unknown')}"
            )
        
        # Full validation
        options = {}
        if settings.require_token_expiration:
            options["require"] = ["exp"]
            
        payload = jwt.decode(
            token,
            settings.jwt_secret_key,
            algorithms=[settings.jwt_algorithm],
            options=options
        )
        return payload
        
    except jwt.MissingRequiredClaimError:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Token is missing required expiration claim. Set REQUIRE_TOKEN_EXPIRATION=false to allow.",
            headers={"WWW-Authenticate": "Bearer"},
        )
    except jwt.ExpiredSignatureError:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Token has expired",
            headers={"WWW-Authenticate": "Bearer"},
        )
```

### CLI Warning Enhancement
```python
# In create_jwt_token.py
def _create_jwt_token(...):
    """Create JWT with optional expiration warning."""
    payload = data.copy()
    
    if expires_in_minutes > 0:
        expire = _dt.datetime.now(_dt.timezone.utc) + _dt.timedelta(minutes=expires_in_minutes)
        payload["exp"] = int(expire.timestamp())
    else:
        # Warn about non-expiring token
        print(
            "⚠️  WARNING: Creating token without expiration. This is a security risk!\n"
            "   Consider using --exp with a value > 0 for production use.\n"
            "   Once JWT API (#425) is available, use it for automatic token renewal.",
            file=sys.stderr
        )
    
    return jwt.encode(payload, secret, algorithm=algorithm)
```

## 👥 User Story

**As a** security-conscious API administrator  
**I want** to configure whether tokens must have expiration  
**So that** I can balance security requirements with operational needs  

**Acceptance Criteria:**
- Configuration via `REQUIRE_TOKEN_EXPIRATION` environment variable
- When enabled: tokens without `exp` return HTTP 401
- When disabled: tokens without `exp` work but log warnings
- CLI warns when creating non-expiring tokens
- Clear documentation on security implications

## 🛠️ Implementation Plan

### Phase 1: Core Configuration
- [x] Add `require_token_expiration` to settings with default `false`
- [x] Update `verify_jwt_token()` to check configuration
- [ ] Implement conditional expiration requirement
- [ ] Add warning logs for non-expiring tokens

### Phase 2: Enhanced Logging
- [x] Log warnings with token metadata (sub, iat)
- [ ] Add metrics/monitoring for non-expiring tokens
- [ ] Create audit trail for compliance reporting

### Phase 3: CLI Enhancement  
- [ ] Add warning when using `--exp 0`
- [ ] Reference JWT API (#425) in warning message
- [ ] Add `--force` flag to suppress warnings if needed

### Phase 4: Documentation
- [ ] Document `REQUIRE_TOKEN_EXPIRATION` setting
- [ ] Explain security trade-offs
- [ ] Provide migration guide from non-expiring to expiring tokens
- [ ] Reference JWT API (#425) for automatic renewal

## 📋 Test Scenarios

### Configuration Testing
```bash
# Test 1: Flexible mode (default)
export REQUIRE_TOKEN_EXPIRATION=false
export NO_EXP_TOKEN=$(python3 -m mcpgateway.utils.create_jwt_token --username test --exp 0)
curl -H "Authorization: Bearer $NO_EXP_TOKEN" http://localhost:4444/version
# Returns: 200 OK (with warning in logs)

# Test 2: Strict mode
export REQUIRE_TOKEN_EXPIRATION=true
curl -H "Authorization: Bearer $NO_EXP_TOKEN" http://localhost:4444/version
# Returns: 401 Unauthorized
# {"detail": "Token is missing required expiration claim. Set REQUIRE_TOKEN_EXPIRATION=false to allow."}

# Test 3: Token with expiration works in both modes
export EXP_TOKEN=$(python3 -m mcpgateway.utils.create_jwt_token --username test --exp 60)
curl -H "Authorization: Bearer $EXP_TOKEN" http://localhost:4444/version
# Returns: 200 OK (regardless of REQUIRE_TOKEN_EXPIRATION)
```

### CLI Warning Test
```bash
# Creating non-expiring token shows warning
$ python3 -m mcpgateway.utils.create_jwt_token --username admin --exp 0
⚠️  WARNING: Creating token without expiration. This is a security risk!
   Consider using --exp with a value > 0 for production use.
   Once JWT API (#425) is available, use it for automatic token renewal.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
```

## 📚 Documentation Template

```markdown
## JWT Token Expiration Configuration

### Setting: REQUIRE_TOKEN_EXPIRATION
Controls whether JWT tokens must include an expiration claim.

- `false` (default): Accepts tokens without expiration, logs warnings
- `true`: Rejects tokens without expiration (recommended for production)

### Security Considerations
Non-expiring tokens pose security risks:
- Cannot be invalidated without rotating secrets
- Remain valid if compromised
- Fail security audits

### Migration Path
1. **Current State**: Use non-expiring tokens with `REQUIRE_TOKEN_EXPIRATION=false`
2. **Transition**: Monitor warnings, identify non-expiring token usage
3. **Future State**: Once JWT API (#425) is available:
   - Enable `REQUIRE_TOKEN_EXPIRATION=true`
   - Use API for automatic token renewal
   - Implement token rotation policies

### Examples
# Development - Allow flexibility
REQUIRE_TOKEN_EXPIRATION=false

# Production - Enforce security
REQUIRE_TOKEN_EXPIRATION=true

# Until #425 is implemented, long-lived tokens may be necessary:
python3 -m mcpgateway.utils.create_jwt_token --username automation --exp 43200  # 30 days
```

## 🔄 Migration Strategy

### Before JWT API (#425)
- Allow non-expiring tokens for critical automations
- Use long expiration (30-90 days) where possible
- Monitor and log all non-expiring token usage

### After JWT API (#425)
- Enable `REQUIRE_TOKEN_EXPIRATION=true`
- Migrate to shorter expiration (1-7 days)
- Implement automatic token renewal via API
- Phase out all non-expiring tokens

## 📊 Success Metrics
- % of tokens with expiration (target: increase over time)
- Warning log frequency (target: decrease to zero)
- Security audit compliance score
- Zero service disruptions during migration

## 🔗 Standards Compliance
- ✅ Configurable to meet various security policies
- ✅ Provides path to JWT best practices
- ✅ Backward compatible by default
- ✅ Clear audit trail for compliance

## 📝 Notes
- Default `false` maintains backward compatibility
- Warning logs help identify technical debt
- Configuration allows gradual security hardening
- JWT API (#425) enables full security without operational impact


Discussion:

Patch:
diff --git a/.env.example b/.env.example
index 9f0de09b..8db5a235 100644
--- a/.env.example
+++ b/.env.example
@@ -97,6 +97,9 @@ JWT_ALGORITHM=HS256
 # Expiry time for generated JWT tokens (in minutes; e.g. 7 days)
 TOKEN_EXPIRY=10080
 
+# Require all JWT tokens to have expiration claims (true or false)
+REQUIRE_TOKEN_EXPIRATION=false
+
 # Used to derive an AES encryption key for secure auth storage
 # Must be a non-empty string (e.g. passphrase or random secret)
 AUTH_ENCRYPTION_SECRET=my-test-salt
diff --git a/mcpgateway/config.py b/mcpgateway/config.py
index 461c6430..cf8cfb0b 100644
--- a/mcpgateway/config.py
+++ b/mcpgateway/config.py
@@ -60,7 +60,7 @@
 import jq
 from jsonpath_ng.ext import parse
 from jsonpath_ng.jsonpath import JSONPath
-from pydantic import field_validator
+from pydantic import Field, field_validator
 from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
 
 logging.basicConfig(
@@ -116,6 +116,8 @@ class Settings(BaseSettings):
     auth_required: bool = True
     token_expiry: int = 10080  # minutes
 
+    require_token_expiration: bool = Field(default=False, description="Require all JWT tokens to have expiration claims")  # Default to flexible mode for backward compatibility
+
     #  Encryption key phrase for auth storage
     auth_encryption_secret: str = "my-test-salt"
 
diff --git a/mcpgateway/utils/create_jwt_token.py b/mcpgateway/utils/create_jwt_token.py
index 14149c31..5180267b 100755
--- a/mcpgateway/utils/create_jwt_token.py
+++ b/mcpgateway/utils/create_jwt_token.py
@@ -105,6 +105,14 @@ def _create_jwt_token(
     if expires_in_minutes > 0:
         expire = _dt.datetime.now(_dt.timezone.utc) + _dt.timedelta(minutes=expires_in_minutes)
         payload["exp"] = int(expire.timestamp())
+    else:
+        # Warn about non-expiring token
+        print(
+            "⚠️  WARNING: Creating token without expiration. This is a security risk!\n"
+            "   Consider using --exp with a value > 0 for production use.\n"
+            "   Once JWT API (#425) is available, use it for automatic token renewal.",
+            file=sys.stderr,
+        )
     return jwt.encode(payload, secret, algorithm=algorithm)
 
 
diff --git a/mcpgateway/utils/verify_credentials.py b/mcpgateway/utils/verify_credentials.py
index 0a5b4d19..32e487b8 100644
--- a/mcpgateway/utils/verify_credentials.py
+++ b/mcpgateway/utils/verify_credentials.py
@@ -17,6 +17,7 @@
     ...     basic_auth_user = 'user'
     ...     basic_auth_password = 'pass'
     ...     auth_required = True
+    ...     require_token_expiration = False
     >>> vc.settings = DummySettings()
     >>> import jwt
     >>> token = jwt.encode({'sub': 'alice'}, 'secret', algorithm='HS256')
@@ -39,6 +40,7 @@
 """
 
 # Standard
+import logging
 from typing import Optional
 
 # Third-Party
@@ -51,7 +53,6 @@
 )
 from fastapi.security.utils import get_authorization_scheme_param
 import jwt
-from jwt import PyJWTError
 
 # First-Party
 from mcpgateway.config import settings
@@ -59,6 +60,9 @@
 basic_security = HTTPBasic(auto_error=False)
 security = HTTPBearer(auto_error=False)
 
+# Standard
+logger = logging.getLogger(__name__)
+
 
 async def verify_jwt_token(token: str) -> dict:
     """Verify and decode a JWT token.
@@ -74,6 +78,7 @@ async def verify_jwt_token(token: str) -> dict:
 
     Raises:
         HTTPException: 401 status if the token has expired or is invalid.
+        MissingRequiredClaimError: If the 'exp' claim is required but missing.
 
     Examples:
         >>> from mcpgateway.utils import verify_credentials as vc
@@ -83,6 +88,7 @@ async def verify_jwt_token(token: str) -> dict:
         ...     basic_auth_user = 'user'
         ...     basic_auth_password = 'pass'
         ...     auth_required = True
+        ...     require_token_expiration = False
         >>> vc.settings = DummySettings()
         >>> import jwt
         >>> token = jwt.encode({'sub': 'alice'}, 'secret', algorithm='HS256')
@@ -108,22 +114,60 @@ async def verify_jwt_token(token: str) -> dict:
         ...     print(e.status_code, e.detail)
         401 Invalid token
     """
+    # try:
+    #     Decode and validate token
+    #     payload = jwt.decode(
+    #         token,
+    #         settings.jwt_secret_key,
+    #         algorithms=[settings.jwt_algorithm],
+    #         # options={"require": ["exp"]},  # Require expiration
+    #     )
+    #     return payload  # Contains the claims (e.g., user info)
+    # except jwt.ExpiredSignatureError:
+    #     raise HTTPException(
+    #         status_code=status.HTTP_401_UNAUTHORIZED,
+    #         detail="Token has expired",
+    #         headers={"WWW-Authenticate": "Bearer"},
+    #     )
+    # except PyJWTError:
+    #     raise HTTPException(
+    #         status_code=status.HTTP_401_UNAUTHORIZED,
+    #         detail="Invalid token",
+    #         headers={"WWW-Authenticate": "Bearer"},
+    #     )
     try:
-        # Decode and validate token
-        payload = jwt.decode(
-            token,
-            settings.jwt_secret_key,
-            algorithms=[settings.jwt_algorithm],
-            # options={"require": ["exp"]},  # Require expiration
+        # First decode to check claims
+        unverified = jwt.decode(token, options={"verify_signature": False})
+
+        # Check for expiration claim
+        if "exp" not in unverified and settings.require_token_expiration:
+            raise jwt.MissingRequiredClaimError("exp")
+
+        # Log warning for non-expiring tokens
+        if "exp" not in unverified:
+            logger.warning("JWT token without expiration accepted. " "Consider enabling REQUIRE_TOKEN_EXPIRATION for better security. " f"Token sub: {unverified.get('sub', 'unknown')}")
+
+        # Full validation
+        options = {}
+        if settings.require_token_expiration:
+            options["require"] = ["exp"]
+
+        payload = jwt.decode(token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm], options=options)
+        return payload
+
+    except jwt.MissingRequiredClaimError:
+        raise HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail="Token is missing required expiration claim. Set REQUIRE_TOKEN_EXPIRATION=false to allow.",
+            headers={"WWW-Authenticate": "Bearer"},
         )
-        return payload  # Contains the claims (e.g., user info)
     except jwt.ExpiredSignatureError:
         raise HTTPException(
             status_code=status.HTTP_401_UNAUTHORIZED,
             detail="Token has expired",
             headers={"WWW-Authenticate": "Bearer"},
         )
-    except PyJWTError:
+    except jwt.PyJWTError:
         raise HTTPException(
             status_code=status.HTTP_401_UNAUTHORIZED,
             detail="Invalid token",
@@ -154,6 +198,7 @@ async def verify_credentials(token: str) -> dict:
         ...     basic_auth_user = 'user'
         ...     basic_auth_password = 'pass'
         ...     auth_required = True
+        ...     require_token_expiration = False
         >>> vc.settings = DummySettings()
         >>> import jwt
         >>> token = jwt.encode({'sub': 'alice'}, 'secret', algorithm='HS256')
@@ -194,6 +239,7 @@ async def require_auth(credentials: Optional[HTTPAuthorizationCredentials] = Dep
         ...     basic_auth_user = 'user'
         ...     basic_auth_password = 'pass'
         ...     auth_required = True
+        ...     require_token_expiration = False
         >>> vc.settings = DummySettings()
         >>> import jwt
         >>> from fastapi.security import HTTPAuthorizationCredentials
@@ -373,6 +419,7 @@ async def require_auth_override(
         ...     basic_auth_user = 'user'
         ...     basic_auth_password = 'pass'
         ...     auth_required = True
+        ...     require_token_expiration = False
         >>> vc.settings = DummySettings()
         >>> import jwt
         >>> import asyncio


Test patch:
diff --git a/tests/unit/mcpgateway/utils/test_verify_credentials.py b/tests/unit/mcpgateway/utils/test_verify_credentials.py
index f5086741..94a115c3 100644
--- a/tests/unit/mcpgateway/utils/test_verify_credentials.py
+++ b/tests/unit/mcpgateway/utils/test_verify_credentials.py
@@ -41,7 +41,15 @@
 ALGO = "HS256"
 
 
-def _token(payload: dict, *, exp_delta: int | None = None, secret: str = SECRET) -> str:
+# def _token(payload: dict, *, exp_delta: int | None = None, secret: str = SECRET) -> str:
+#     """Return a signed JWT with optional expiry offset (minutes)."""
+#     if exp_delta is not None:
+#         expire = datetime.now(timezone.utc) + timedelta(minutes=exp_delta)
+#         payload = payload | {"exp": int(expire.timestamp())}
+#     return jwt.encode(payload, secret, algorithm=ALGO)
+
+
+def _token(payload: dict, *, exp_delta: int | None = 60, secret: str = SECRET) -> str:
     """Return a signed JWT with optional expiry offset (minutes)."""
     if exp_delta is not None:
         expire = datetime.now(timezone.utc) + timedelta(minutes=exp_delta)
@@ -56,6 +64,7 @@ def _token(payload: dict, *, exp_delta: int | None = None, secret: str = SECRET)
 async def test_verify_jwt_token_success(monkeypatch):
     monkeypatch.setattr(vc.settings, "jwt_secret_key", SECRET, raising=False)
     monkeypatch.setattr(vc.settings, "jwt_algorithm", ALGO, raising=False)
+    monkeypatch.setattr(vc.settings, "require_token_expiration", False, raising=False)
 
     token = _token({"sub": "abc"})
     data = await vc.verify_jwt_token(token)