Skip to content

Commit 3dfe943

Browse files
committed
fix: asymmetric jwt enocding and decoding
This commit introduces the option to load private and public keys to use also support asymmetric jwt encoding and decoding Signed-off-by: Philip Miglinci <[email protected]>
1 parent 1a2c3db commit 3dfe943

17 files changed

+257
-205
lines changed

.dockerignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,7 @@ log/
241241

242242
# Certificates and secrets
243243
certs/
244+
jwt/
244245
*.pem
245246
*.key
246247
*.crt

.env.example

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,18 +89,26 @@ BASIC_AUTH_USER=admin
8989
BASIC_AUTH_PASSWORD=changeme
9090
AUTH_REQUIRED=true
9191

92+
93+
# Algorithm used to sign JWTs (e.g., HS256)
94+
JWT_ALGORITHM=HS256
95+
9296
# Secret used to sign JWTs (use long random value in prod)
9397
# PRODUCTION: Use a strong, random secret (minimum 32 characters)
9498
JWT_SECRET_KEY=my-test-key
9599

96-
# Algorithm used to sign JWTs (e.g., HS256)
97-
JWT_ALGORITHM=HS256
100+
# For asymetric algorithms liek RS256 public private keys are needed
101+
JWT_PUBLIC_KEY_PATH=jwt/public.pem
102+
JWT_PRIVATE_KEY_PATH=jwt/private.pem
98103

99104
# JWT Audience and Issuer claims for token validation
100105
# PRODUCTION: Set these to your service-specific values
101106
JWT_AUDIENCE=mcpgateway-api
102107
JWT_ISSUER=mcpgateway
103108

109+
# Disabling can be useful if dynamic client registration is used as the AUDIENCE in a JWT is the client
110+
JWT_AUDIENCE_VERIFICATION=true
111+
104112
# Expiry time for generated JWT tokens (in minutes; e.g. 7 days)
105113
TOKEN_EXPIRY=10080
106114
REQUIRE_TOKEN_EXPIRATION=false

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ node_modules/
4646
.tmp*
4747
mcp.db-journal
4848
certs/
49+
jwt/
4950
FIXMEs
5051
*.old
5152
logs/

README.md

Lines changed: 28 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1040,36 +1040,39 @@ You can get started by copying the provided [.env.example](.env.example) to `.en
10401040

10411041
### Basic
10421042

1043-
| Setting | Description | Default | Options |
1044-
| --------------- | ---------------------------------------- | ---------------------- | ---------------------- |
1045-
| `APP_NAME` | Gateway / OpenAPI title | `MCP Gateway` | string |
1046-
| `HOST` | Bind address for the app | `127.0.0.1` | IPv4/IPv6 |
1047-
| `PORT` | Port the server listens on | `4444` | 1-65535 |
1048-
| `DATABASE_URL` | SQLAlchemy connection URL | `sqlite:///./mcp.db` | any SQLAlchemy dialect |
1049-
| `APP_ROOT_PATH` | Subpath prefix for app (e.g. `/gateway`) | (empty) | string |
1050-
| `TEMPLATES_DIR` | Path to Jinja2 templates | `mcpgateway/templates` | path |
1051-
| `STATIC_DIR` | Path to static files | `mcpgateway/static` | path |
1052-
| `PROTOCOL_VERSION` | MCP protocol version supported | `2025-03-26` | string |
1043+
| Setting | Description | Default | Options |
1044+
|--------------------|------------------------------------------|------------------------|------------------------|
1045+
| `APP_NAME` | Gateway / OpenAPI title | `MCP Gateway` | string |
1046+
| `HOST` | Bind address for the app | `127.0.0.1` | IPv4/IPv6 |
1047+
| `PORT` | Port the server listens on | `4444` | 1-65535 |
1048+
| `DATABASE_URL` | SQLAlchemy connection URL | `sqlite:///./mcp.db` | any SQLAlchemy dialect |
1049+
| `APP_ROOT_PATH` | Subpath prefix for app (e.g. `/gateway`) | (empty) | string |
1050+
| `TEMPLATES_DIR` | Path to Jinja2 templates | `mcpgateway/templates` | path |
1051+
| `STATIC_DIR` | Path to static files | `mcpgateway/static` | path |
1052+
| `PROTOCOL_VERSION` | MCP protocol version supported | `2025-03-26` | string |
10531053

10541054
> 💡 Use `APP_ROOT_PATH=/foo` if reverse-proxying under a subpath like `https://host.com/foo/`.
10551055

10561056
### Authentication
10571057

1058-
| Setting | Description | Default | Options |
1059-
| --------------------- | ---------------------------------------------------------------- | ------------- | ---------- |
1060-
| `BASIC_AUTH_USER` | Username for Admin UI login and HTTP Basic authentication | `admin` | string |
1061-
| `BASIC_AUTH_PASSWORD` | Password for Admin UI login and HTTP Basic authentication | `changeme` | string |
1062-
| `PLATFORM_ADMIN_EMAIL` | Email for bootstrap platform admin user (auto-created with admin privileges) | `[email protected]` | string |
1063-
| `AUTH_REQUIRED` | Require authentication for all API routes | `true` | bool |
1064-
| `JWT_SECRET_KEY` | Secret key used to **sign JWT tokens** for API access | `my-test-key` | string |
1065-
| `JWT_ALGORITHM` | Algorithm used to sign the JWTs (`HS256` is default, HMAC-based) | `HS256` | PyJWT algs |
1066-
| `JWT_AUDIENCE` | JWT audience claim for token validation | `mcpgateway-api` | string |
1067-
| `JWT_ISSUER` | JWT issuer claim for token validation | `mcpgateway` | string |
1068-
| `TOKEN_EXPIRY` | Expiry of generated JWTs in minutes | `10080` | int > 0 |
1069-
| `REQUIRE_TOKEN_EXPIRATION` | Require all JWT tokens to have expiration claims | `false` | bool |
1070-
| `AUTH_ENCRYPTION_SECRET` | Passphrase used to derive AES key for encrypting tool auth headers | `my-test-salt` | string |
1071-
| `OAUTH_REQUEST_TIMEOUT` | OAuth request timeout in seconds | `30` | int > 0 |
1072-
| `OAUTH_MAX_RETRIES` | Maximum retries for OAuth token requests | `3` | int > 0 |
1058+
| Setting | Description | Default | Options |
1059+
|-----------------------------|------------------------------------------------------------------------------|---------------------|-------------|
1060+
| `BASIC_AUTH_USER` | Username for Admin UI login and HTTP Basic authentication | `admin` | string |
1061+
| `BASIC_AUTH_PASSWORD` | Password for Admin UI login and HTTP Basic authentication | `changeme` | string |
1062+
| `PLATFORM_ADMIN_EMAIL` | Email for bootstrap platform admin user (auto-created with admin privileges) | `[email protected]` | string |
1063+
| `AUTH_REQUIRED` | Require authentication for all API routes | `true` | bool |
1064+
| `JWT_ALGORITHM` | Algorithm used to sign the JWTs (`HS256` is default, HMAC-based) | `HS256` | PyJWT algs |
1065+
| `JWT_SECRET_KEY` | Secret key used to **sign JWT tok(empty)or API access | `my-test-key` | string |
1066+
| `JWT_PUBLIC_KEY_PATH` | If an asymmetric algorithm is used, a public key is required | (empty) | path to pem |
1067+
| `JWT_PRIVATE_KEY_PATH` | If an asymmetric algorithm is used, a private key is required | (empty) | path to pem |
1068+
| `JWT_AUDIENCE` | JWT audience claim for token validation | `mcpgateway-api` | string |
1069+
| `JWT_AUDIENCE_VERIFICATION` | Disables jwt audience verification (useful for DCR) | `true` | boolean |
1070+
| `JWT_ISSUER` | JWT issuer claim for token validation | `mcpgateway` | string |
1071+
| `TOKEN_EXPIRY` | Expiry of generated JWTs in minutes | `10080` | int > 0 |
1072+
| `REQUIRE_TOKEN_EXPIRATION` | Require all JWT tokens to have expiration claims | `false` | bool |
1073+
| `AUTH_ENCRYPTION_SECRET` | Passphrase used to derive AES key for encrypting tool auth headers | `my-test-salt` | string |
1074+
| `OAUTH_REQUEST_TIMEOUT` | OAuth request timeout in seconds | `30` | int > 0 |
1075+
| `OAUTH_MAX_RETRIES` | Maximum retries for OAuth token requests | `3` | int > 0 |
10731076

10741077
> 🔐 `BASIC_AUTH_USER`/`PASSWORD` are used for:
10751078
>

mcpgateway/admin.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@
3535
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
3636
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, StreamingResponse
3737
import httpx
38-
import jwt
3938
from pydantic import ValidationError
4039
from pydantic_core import ValidationError as CoreValidationError
4140
from sqlalchemy.exc import IntegrityError
@@ -47,6 +46,7 @@
4746
from mcpgateway.db import Tool as DbTool
4847
from mcpgateway.middleware.rbac import get_current_user_with_permissions, require_permission
4948
from mcpgateway.models import LogLevel
49+
from mcpgateway.utils.create_jwt_token import create_jwt_token
5050
from mcpgateway.schemas import (
5151
A2AAgentCreate,
5252
GatewayCreate,
@@ -1890,7 +1890,8 @@ async def admin_ui(
18901890
"scopes": {"server_id": None, "permissions": ["*"], "ip_restrictions": [], "time_restrictions": {}},
18911891
}
18921892

1893-
token = jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)
1893+
# Generate token using centralized token creation
1894+
token = await create_jwt_token(payload)
18941895

18951896
# Set HTTP-only cookie for security
18961897
response.set_cookie(
@@ -2027,7 +2028,7 @@ async def admin_login_handler(request: Request, db: Session = Depends(get_db)) -
20272028
# First-Party
20282029
from mcpgateway.routers.email_auth import create_access_token # pylint: disable=import-outside-toplevel
20292030

2030-
token, _ = create_access_token(user) # expires_seconds not needed here
2031+
token, _ = await create_access_token(user) # expires_seconds not needed here
20312032

20322033
# Create redirect response
20332034
root_path = request.scope.get("root_path", "")

mcpgateway/auth.py

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,12 @@
1414
# Third-Party
1515
from fastapi import Depends, HTTPException, status
1616
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
17-
import jwt
1817
from sqlalchemy.orm import Session
1918

2019
# First-Party
2120
from mcpgateway.config import settings
2221
from mcpgateway.db import EmailUser, SessionLocal
22+
from mcpgateway.utils.verify_credentials import verify_jwt_token
2323

2424
# Security scheme
2525
bearer_scheme = HTTPBearer(auto_error=False)
@@ -73,9 +73,9 @@ async def get_current_user(credentials: Optional[HTTPAuthorizationCredentials] =
7373
email = None
7474

7575
try:
76-
# Try JWT token first
76+
# Try JWT token first using the centralized verify_jwt_token function
7777
logger.debug("Attempting JWT token validation")
78-
payload = jwt.decode(credentials.credentials, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm], audience=settings.jwt_audience, issuer=settings.jwt_issuer)
78+
payload = await verify_jwt_token(credentials.credentials)
7979

8080
logger.debug("JWT token validated successfully")
8181
# Extract user identifier (support both new and legacy token formats)
@@ -113,13 +113,10 @@ async def get_current_user(credentials: Optional[HTTPAuthorizationCredentials] =
113113
# Log the error but don't fail authentication for admin tokens
114114
logger.warning(f"Token revocation check failed for JTI {jti}: {revoke_check_error}")
115115

116-
except jwt.ExpiredSignatureError:
117-
raise HTTPException(
118-
status_code=status.HTTP_401_UNAUTHORIZED,
119-
detail="Token expired",
120-
headers={"WWW-Authenticate": "Bearer"},
121-
)
122-
except jwt.PyJWTError as jwt_error:
116+
except HTTPException:
117+
# Re-raise HTTPException from verify_jwt_token (handles expired/invalid tokens)
118+
raise
119+
except Exception as jwt_error:
123120
# JWT validation failed, try database API token
124121
logger.debug("JWT validation failed with error: %s, trying database API token", jwt_error)
125122
try:

mcpgateway/cache/session_registry.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,6 @@
6161

6262
# Third-Party
6363
from fastapi import HTTPException, status
64-
import jwt
6564

6665
# First-Party
6766
from mcpgateway import __version__
@@ -71,6 +70,7 @@
7170
from mcpgateway.services import PromptService, ResourceService, ToolService
7271
from mcpgateway.services.logging_service import LoggingService
7372
from mcpgateway.transports import SSETransport
73+
from mcpgateway.utils.create_jwt_token import create_jwt_token
7474
from mcpgateway.utils.retry_manager import ResilientHttpClient
7575
from mcpgateway.validation.jsonrpc import JSONRPCError
7676

@@ -1319,7 +1319,8 @@ async def generate_response(self, message: Dict[str, Any], transport: SSETranspo
13191319
"auth_provider": "internal",
13201320
},
13211321
}
1322-
token = jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)
1322+
# Generate token using centralized token creation
1323+
token = await create_jwt_token(payload)
13231324

13241325
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
13251326
# Extract root URL from base_url (remove /servers/{id} path)

mcpgateway/config.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -163,10 +163,13 @@ class Settings(BaseSettings):
163163
# Authentication
164164
basic_auth_user: str = "admin"
165165
basic_auth_password: str = "changeme"
166-
jwt_secret_key: str = "my-test-key"
167166
jwt_algorithm: str = "HS256"
168-
jwt_audience: str = "mcpgateway-api"
169-
jwt_issuer: str = "mcpgateway"
167+
jwt_secret_key: str = "my-test-key"
168+
jwt_public_key_path: str = ""
169+
jwt_private_key_path: str = ""
170+
jwt_audience: str = "JRKETZQLRBELYNGI5M3CUUNAL5"
171+
jwt_issuer: str = "http://localhost:5556"
172+
jwt_audience_verification: bool = True
170173
auth_required: bool = True
171174
token_expiry: int = 10080 # minutes
172175

mcpgateway/middleware/token_scoping.py

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,10 @@
1919
# Third-Party
2020
from fastapi import HTTPException, Request, status
2121
from fastapi.security import HTTPBearer
22-
import jwt
2322

2423
# First-Party
25-
from mcpgateway.config import settings
2624
from mcpgateway.db import Permissions
25+
from mcpgateway.utils.verify_credentials import verify_jwt_token
2726

2827
# Security scheme
2928
bearer_scheme = HTTPBearer(auto_error=False)
@@ -47,7 +46,7 @@ def __init__(self):
4746
True
4847
"""
4948

50-
def _extract_token_scopes(self, request: Request) -> Optional[dict]:
49+
async def _extract_token_scopes(self, request: Request) -> Optional[dict]:
5150
"""Extract token scopes from JWT in request.
5251
5352
Args:
@@ -64,11 +63,14 @@ def _extract_token_scopes(self, request: Request) -> Optional[dict]:
6463
token = auth_header.split(" ", 1)[1]
6564

6665
try:
67-
# Decode JWT with signature verification but skip audience/issuer checks for scope extraction
68-
# (full verification including audience/issuer is handled by the auth system)
69-
payload = jwt.decode(token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm], options={"verify_aud": False, "verify_iss": False})
66+
# Use the centralized verify_jwt_token function for consistent JWT validation
67+
payload = await verify_jwt_token(token)
7068
return payload.get("scopes")
71-
except jwt.PyJWTError:
69+
except HTTPException:
70+
# Token validation failed (expired, invalid, etc.)
71+
return None
72+
except Exception:
73+
# Any other error in token validation
7274
return None
7375

7476
def _get_client_ip(self, request: Request) -> str:
@@ -352,7 +354,7 @@ async def __call__(self, request: Request, call_next):
352354
return await call_next(request)
353355

354356
# Extract token scopes
355-
scopes = self._extract_token_scopes(request)
357+
scopes = await self._extract_token_scopes(request)
356358

357359
# If no scopes, continue (regular auth will handle this)
358360
if not scopes:

mcpgateway/routers/email_auth.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,14 @@
2424
# Third-Party
2525
from fastapi import APIRouter, Depends, HTTPException, Request, status
2626
from fastapi.security import HTTPBearer
27-
import jwt
2827
from sqlalchemy.orm import Session
2928

3029
# First-Party
3130
from mcpgateway.auth import get_current_user
3231
from mcpgateway.config import settings
3332
from mcpgateway.db import EmailUser, SessionLocal
3433
from mcpgateway.middleware.rbac import require_permission
34+
from mcpgateway.utils.create_jwt_token import create_jwt_token
3535
from mcpgateway.schemas import (
3636
AuthenticationResponse,
3737
AuthEventResponse,
@@ -104,7 +104,7 @@ def get_user_agent(request: Request) -> str:
104104
return request.headers.get("User-Agent", "unknown")
105105

106106

107-
def create_access_token(user: EmailUser, token_scopes: Optional[dict] = None, jti: Optional[str] = None) -> tuple[str, int]:
107+
async def create_access_token(user: EmailUser, token_scopes: Optional[dict] = None, jti: Optional[str] = None) -> tuple[str, int]:
108108
"""Create JWT access token for user with enhanced scoping.
109109
110110
Args:
@@ -149,13 +149,13 @@ def create_access_token(user: EmailUser, token_scopes: Optional[dict] = None, jt
149149
"scopes": token_scopes or {"server_id": None, "permissions": ["*"], "ip_restrictions": [], "time_restrictions": {}}, # Full access for regular user tokens
150150
}
151151

152-
# Generate token
153-
token = jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)
152+
# Generate token using centralized token creation
153+
token = await create_jwt_token(payload)
154154

155155
return token, int(expires_delta.total_seconds())
156156

157157

158-
def create_legacy_access_token(user: EmailUser) -> tuple[str, int]:
158+
async def create_legacy_access_token(user: EmailUser) -> tuple[str, int]:
159159
"""Create legacy JWT access token for backwards compatibility.
160160
161161
Args:
@@ -181,8 +181,8 @@ def create_legacy_access_token(user: EmailUser) -> tuple[str, int]:
181181
"aud": settings.jwt_audience,
182182
}
183183

184-
# Generate token
185-
token = jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)
184+
# Generate token using centralized token creation
185+
token = await create_jwt_token(payload)
186186

187187
return token, int(expires_delta.total_seconds())
188188

@@ -226,7 +226,7 @@ async def login(login_request: EmailLoginRequest, request: Request, db: Session
226226
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password")
227227

228228
# Create access token
229-
access_token, expires_in = create_access_token(user)
229+
access_token, expires_in = await create_access_token(user)
230230

231231
# Return authentication response
232232
return AuthenticationResponse(access_token=access_token, token_type="bearer", expires_in=expires_in, user=EmailUserResponse.from_email_user(user))
@@ -274,7 +274,7 @@ async def register(registration_request: EmailRegistrationRequest, request: Requ
274274
)
275275

276276
# Create access token
277-
access_token, expires_in = create_access_token(user)
277+
access_token, expires_in = await create_access_token(user)
278278

279279
logger.info(f"New user registered: {user.email}")
280280

0 commit comments

Comments
 (0)