Skip to content

Commit 484860a

Browse files
deagoncrivetimihairakdutta1
authored
fix(docs/auth): support basic auth (#640)
* fix(docs/auth): support basic auth Signed-off-by: Guoqiang Ding <[email protected]> * feat: add DOCS_BASIC_AUTH_ENABLED option which default value is False Signed-off-by: Guoqiang Ding <[email protected]> * test: add DOCS_BASIC_AUTH_ENABLED unit tests Signed-off-by: Guoqiang Ding <[email protected]> * Typo fix Signed-off-by: Mihai Criveti <[email protected]> * test Signed-off-by: RAKHI DUTTA <[email protected]> * updated doc_allow_basica-auth Signed-off-by: RAKHI DUTTA <[email protected]> * update readme Signed-off-by: RAKHI DUTTA <[email protected]> * update readme Signed-off-by: RAKHI DUTTA <[email protected]> * update readme Signed-off-by: RAKHI DUTTA <[email protected]> * formting change Signed-off-by: RAKHI DUTTA <[email protected]> --------- Signed-off-by: Guoqiang Ding <[email protected]> Signed-off-by: Mihai Criveti <[email protected]> Signed-off-by: RAKHI DUTTA <[email protected]> Co-authored-by: Mihai Criveti <[email protected]> Co-authored-by: RAKHI DUTTA <[email protected]>
1 parent ff6bb98 commit 484860a

File tree

6 files changed

+267
-9
lines changed

6 files changed

+267
-9
lines changed

.env.example

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,10 @@ ALLOWED_ORIGINS='["http://localhost", "http://localhost:4444"]'
130130
# Enable CORS handling in the gateway
131131
CORS_ENABLED=true
132132

133+
# Enable HTTP Basic Auth for docs endpoints (in addition to Bearer token auth)
134+
# Uses the same credentials as BASIC_AUTH_USER and BASIC_AUTH_PASSWORD
135+
DOCS_ALLOW_BASIC_AUTH=false
136+
133137
#####################################
134138
# Retry Config for HTTP Requests
135139
#####################################
@@ -285,4 +289,4 @@ DEBUG=false
285289

286290
# Gateway tool name separator
287291
GATEWAY_TOOL_NAME_SEPARATOR=-
288-
VALID_SLUG_SEPARATOR_REGEXP= r"^(-{1,2}|[_.])$"
292+
VALID_SLUG_SEPARATOR_REGEXP= r"^(-{1,2}|[_.])$"

README.md

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1000,13 +1000,19 @@ You can get started by copying the provided [.env.example](.env.example) to `.en
10001000

10011001
### Security
10021002

1003-
| Setting | Description | Default | Options |
1004-
| ----------------- | ------------------------------ | ---------------------------------------------- | ---------- |
1005-
| `SKIP_SSL_VERIFY` | Skip upstream TLS verification | `false` | bool |
1006-
| `ALLOWED_ORIGINS` | CORS allow-list | `["http://localhost","http://localhost:4444"]` | JSON array |
1007-
| `CORS_ENABLED` | Enable CORS | `true` | bool |
1003+
| Setting | Description | Default | Options |
1004+
| ------------------------- | ------------------------------ | ---------------------------------------------- | ---------- |
1005+
| `SKIP_SSL_VERIFY` | Skip upstream TLS verification | `false` | bool |
1006+
| `ALLOWED_ORIGINS` | CORS allow-list | `["http://localhost","http://localhost:4444"]` | JSON array |
1007+
| `CORS_ENABLED` | Enable CORS | `true` | bool |
1008+
| `DOCS_ALLOW_BASIC_AUTH` | Allow Basic Auth for docs (in addition to JWT) | `false` | bool |
1009+
1010+
> Note: do not quote the ALLOWED_ORIGINS values, this needs to be valid JSON, such as:
1011+
> ALLOWED_ORIGINS=["http://localhost", "http://localhost:4444"]
1012+
>
1013+
> Documentation endpoints (`/docs`, `/redoc`, `/openapi.json`) are always protected by authentication.
1014+
> By default, they require Bearer token authentication. Setting `DOCS_ALLOW_BASIC_AUTH=true` enables HTTP Basic Authentication as an additional method using the same credentials as `BASIC_AUTH_USER` and `BASIC_AUTH_PASSWORD`.
10081015

1009-
> Note: do not quote the ALLOWED_ORIGINS values, this needs to be valid JSON, such as: `ALLOWED_ORIGINS=["http://localhost", "http://localhost:4444"]`
10101016

10111017
### Logging
10121018

@@ -2125,4 +2131,4 @@ Special thanks to our contributors for helping us improve ContextForge MCP Gatew
21252131
[![Forks](https://img.shields.io/github/forks/ibm/mcp-context-forge?style=social)](https://github.com/ibm/mcp-context-forge/network/members)&nbsp;
21262132
[![Contributors](https://img.shields.io/github/contributors/ibm/mcp-context-forge)](https://github.com/ibm/mcp-context-forge/graphs/contributors)&nbsp;
21272133
[![Last Commit](https://img.shields.io/github/last-commit/ibm/mcp-context-forge)](https://github.com/ibm/mcp-context-forge/commits)&nbsp;
2128-
[![Open Issues](https://img.shields.io/github/issues/ibm/mcp-context-forge)](https://github.com/ibm/mcp-context-forge/issues)&nbsp;
2134+
[![Open Issues](https://img.shields.io/github/issues/ibm/mcp-context-forge)](https://github.com/ibm/mcp-context-forge/issues)&nbsp;

mcpgateway/config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
- AUTH_REQUIRED: Require authentication (default: True)
2121
- TRANSPORT_TYPE: Transport mechanisms (default: "all")
2222
- FEDERATION_ENABLED: Enable gateway federation (default: True)
23+
- DOCS_ALLOW_BASIC_AUTH: Allow basic auth for docs (default: False)
2324
- FEDERATION_DISCOVERY: Enable auto-discovery (default: False)
2425
- FEDERATION_PEERS: List of peer gateway URLs (default: [])
2526
- RESOURCE_CACHE_SIZE: Max cached resources (default: 1000)
@@ -98,6 +99,7 @@ class Settings(BaseSettings):
9899
app_name: str = "MCP_Gateway"
99100
host: str = "127.0.0.1"
100101
port: int = 4444
102+
docs_allow_basic_auth: bool = False # Allow basic auth for docs
101103
database_url: str = "sqlite:///./mcp.db"
102104
templates_dir: Path = Path("mcpgateway/templates")
103105
# Absolute paths resolved at import-time (still override-able via env vars)

mcpgateway/main.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,10 @@ class DocsAuthMiddleware(BaseHTTPMiddleware):
332332
333333
If a request to one of these paths is made without a valid token,
334334
the request is rejected with a 401 or 403 error.
335+
336+
Note:
337+
When DOCS_ALLOW_BASIC_AUTH is enabled, Basic Authentication
338+
is also accepted using BASIC_AUTH_USER and BASIC_AUTH_PASSWORD credentials.
335339
"""
336340

337341
async def dispatch(self, request: Request, call_next):

mcpgateway/utils/verify_credentials.py

Lines changed: 113 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
... basic_auth_password = 'pass'
1919
... auth_required = True
2020
... require_token_expiration = False
21+
... docs_allow_basic_auth = False
2122
>>> vc.settings = DummySettings()
2223
>>> import jwt
2324
>>> token = jwt.encode({'sub': 'alice'}, 'secret', algorithm='HS256')
@@ -40,6 +41,8 @@
4041
"""
4142

4243
# Standard
44+
from base64 import b64decode
45+
import binascii
4346
import logging
4447
from typing import Optional
4548

@@ -89,6 +92,7 @@ async def verify_jwt_token(token: str) -> dict:
8992
... basic_auth_password = 'pass'
9093
... auth_required = True
9194
... require_token_expiration = False
95+
... docs_allow_basic_auth = False
9296
>>> vc.settings = DummySettings()
9397
>>> import jwt
9498
>>> token = jwt.encode({'sub': 'alice'}, 'secret', algorithm='HS256')
@@ -199,6 +203,7 @@ async def verify_credentials(token: str) -> dict:
199203
... basic_auth_password = 'pass'
200204
... auth_required = True
201205
... require_token_expiration = False
206+
... docs_allow_basic_auth = False
202207
>>> vc.settings = DummySettings()
203208
>>> import jwt
204209
>>> token = jwt.encode({'sub': 'alice'}, 'secret', algorithm='HS256')
@@ -240,6 +245,7 @@ async def require_auth(credentials: Optional[HTTPAuthorizationCredentials] = Dep
240245
... basic_auth_password = 'pass'
241246
... auth_required = True
242247
... require_token_expiration = False
248+
... docs_allow_basic_auth = False
243249
>>> vc.settings = DummySettings()
244250
>>> import jwt
245251
>>> from fastapi.security import HTTPAuthorizationCredentials
@@ -305,6 +311,7 @@ async def verify_basic_credentials(credentials: HTTPBasicCredentials) -> str:
305311
... basic_auth_user = 'user'
306312
... basic_auth_password = 'pass'
307313
... auth_required = True
314+
... docs_allow_basic_auth = False
308315
>>> vc.settings = DummySettings()
309316
>>> from fastapi.security import HTTPBasicCredentials
310317
>>> creds = HTTPBasicCredentials(username='user', password='pass')
@@ -354,6 +361,7 @@ async def require_basic_auth(credentials: HTTPBasicCredentials = Depends(basic_s
354361
... basic_auth_user = 'user'
355362
... basic_auth_password = 'pass'
356363
... auth_required = True
364+
... docs_allow_basic_auth = False
357365
>>> vc.settings = DummySettings()
358366
>>> from fastapi.security import HTTPBasicCredentials
359367
>>> import asyncio
@@ -387,6 +395,103 @@ async def require_basic_auth(credentials: HTTPBasicCredentials = Depends(basic_s
387395
return "anonymous"
388396

389397

398+
async def require_docs_basic_auth(auth_header: str) -> str:
399+
"""Dedicated handler for HTTP Basic Auth for documentation endpoints only.
400+
401+
This function is ONLY intended for /docs, /redoc, or similar endpoints, and is enabled
402+
via the settings.docs_allow_basic_auth flag. It should NOT be used for general API authentication.
403+
404+
Args:
405+
auth_header: Raw Authorization header value (e.g. "Basic username:password").
406+
407+
Returns:
408+
str: The authenticated username if credentials are valid.
409+
410+
Raises:
411+
HTTPException: If credentials are invalid or malformed.
412+
ValueError: If the basic auth format is invalid (missing colon).
413+
"""
414+
"""Dedicated handler for HTTP Basic Auth for documentation endpoints only.
415+
416+
This function is ONLY intended for /docs, /redoc, or similar endpoints, and is enabled
417+
via the settings.docs_allow_basic_auth flag. It should NOT be used for general API authentication.
418+
419+
Args:
420+
auth_header: Raw Authorization header value (e.g. "Basic dXNlcjpwYXNz").
421+
422+
Returns:
423+
str: The authenticated username if credentials are valid.
424+
425+
Raises:
426+
HTTPException: If credentials are invalid or malformed.
427+
428+
Examples:
429+
>>> from mcpgateway.utils import verify_credentials as vc
430+
>>> class DummySettings:
431+
... jwt_secret_key = 'secret'
432+
... jwt_algorithm = 'HS256'
433+
... basic_auth_user = 'user'
434+
... basic_auth_password = 'pass'
435+
... auth_required = True
436+
... require_token_expiration = False
437+
... docs_allow_basic_auth = True
438+
>>> vc.settings = DummySettings()
439+
>>> import base64, asyncio
440+
>>> userpass = base64.b64encode(b'user:pass').decode()
441+
>>> auth_header = f'Basic {userpass}'
442+
>>> asyncio.run(vc.require_docs_basic_auth(auth_header))
443+
'user'
444+
445+
Test with invalid password:
446+
>>> badpass = base64.b64encode(b'user:wrong').decode()
447+
>>> bad_header = f'Basic {badpass}'
448+
>>> try:
449+
... asyncio.run(vc.require_docs_basic_auth(bad_header))
450+
... except vc.HTTPException as e:
451+
... print(e.status_code, e.detail)
452+
401 Invalid credentials
453+
454+
Test with malformed header:
455+
>>> malformed = base64.b64encode(b'userpass').decode()
456+
>>> malformed_header = f'Basic {malformed}'
457+
>>> try:
458+
... asyncio.run(vc.require_docs_basic_auth(malformed_header))
459+
... except vc.HTTPException as e:
460+
... print(e.status_code, e.detail)
461+
401 Invalid basic auth credentials
462+
463+
Test when docs_allow_basic_auth is False:
464+
>>> vc.settings.docs_allow_basic_auth = False
465+
>>> try:
466+
... asyncio.run(vc.require_docs_basic_auth(auth_header))
467+
... except vc.HTTPException as e:
468+
... print(e.status_code, e.detail)
469+
401 Basic authentication not allowed or malformed
470+
>>> vc.settings.docs_allow_basic_auth = True
471+
"""
472+
scheme, param = get_authorization_scheme_param(auth_header)
473+
if scheme.lower() == "basic" and param and settings.docs_allow_basic_auth:
474+
475+
try:
476+
data = b64decode(param).decode("ascii")
477+
username, separator, password = data.partition(":")
478+
if not separator:
479+
raise ValueError("Invalid basic auth format")
480+
credentials = HTTPBasicCredentials(username=username, password=password)
481+
return await require_basic_auth(credentials=credentials)
482+
except (ValueError, UnicodeDecodeError, binascii.Error):
483+
raise HTTPException(
484+
status_code=status.HTTP_401_UNAUTHORIZED,
485+
detail="Invalid basic auth credentials",
486+
headers={"WWW-Authenticate": "Basic"},
487+
)
488+
raise HTTPException(
489+
status_code=status.HTTP_401_UNAUTHORIZED,
490+
detail="Basic authentication not allowed or malformed",
491+
headers={"WWW-Authenticate": "Basic"},
492+
)
493+
494+
390495
async def require_auth_override(
391496
auth_header: str | None = None,
392497
jwt_token: str | None = None,
@@ -407,6 +512,10 @@ async def require_auth_override(
407512
str | dict: The decoded JWT payload or the string "anonymous",
408513
same as require_auth.
409514
515+
Raises:
516+
HTTPException: If authentication fails or credentials are invalid.
517+
ValueError: If basic auth credentials are malformed.
518+
410519
Note:
411520
This wrapper may propagate HTTPException raised by require_auth,
412521
but it does not raise anything on its own.
@@ -420,6 +529,7 @@ async def require_auth_override(
420529
... basic_auth_password = 'pass'
421530
... auth_required = True
422531
... require_token_expiration = False
532+
... docs_allow_basic_auth = False
423533
>>> vc.settings = DummySettings()
424534
>>> import jwt
425535
>>> import asyncio
@@ -454,5 +564,7 @@ async def require_auth_override(
454564
scheme, param = get_authorization_scheme_param(auth_header)
455565
if scheme.lower() == "bearer" and param:
456566
credentials = HTTPAuthorizationCredentials(scheme=scheme, credentials=param)
457-
567+
elif scheme.lower() == "basic" and param and settings.docs_allow_basic_auth:
568+
# Only allow Basic Auth for docs endpoints when explicitly enabled
569+
return await require_docs_basic_auth(auth_header)
458570
return await require_auth(credentials=credentials, jwt_token=jwt_token)

0 commit comments

Comments
 (0)