18
18
... basic_auth_password = 'pass'
19
19
... auth_required = True
20
20
... require_token_expiration = False
21
+ ... docs_allow_basic_auth = False
21
22
>>> vc.settings = DummySettings()
22
23
>>> import jwt
23
24
>>> token = jwt.encode({'sub': 'alice'}, 'secret', algorithm='HS256')
40
41
"""
41
42
42
43
# Standard
44
+ from base64 import b64decode
45
+ import binascii
43
46
import logging
44
47
from typing import Optional
45
48
@@ -89,6 +92,7 @@ async def verify_jwt_token(token: str) -> dict:
89
92
... basic_auth_password = 'pass'
90
93
... auth_required = True
91
94
... require_token_expiration = False
95
+ ... docs_allow_basic_auth = False
92
96
>>> vc.settings = DummySettings()
93
97
>>> import jwt
94
98
>>> token = jwt.encode({'sub': 'alice'}, 'secret', algorithm='HS256')
@@ -199,6 +203,7 @@ async def verify_credentials(token: str) -> dict:
199
203
... basic_auth_password = 'pass'
200
204
... auth_required = True
201
205
... require_token_expiration = False
206
+ ... docs_allow_basic_auth = False
202
207
>>> vc.settings = DummySettings()
203
208
>>> import jwt
204
209
>>> token = jwt.encode({'sub': 'alice'}, 'secret', algorithm='HS256')
@@ -240,6 +245,7 @@ async def require_auth(credentials: Optional[HTTPAuthorizationCredentials] = Dep
240
245
... basic_auth_password = 'pass'
241
246
... auth_required = True
242
247
... require_token_expiration = False
248
+ ... docs_allow_basic_auth = False
243
249
>>> vc.settings = DummySettings()
244
250
>>> import jwt
245
251
>>> from fastapi.security import HTTPAuthorizationCredentials
@@ -305,6 +311,7 @@ async def verify_basic_credentials(credentials: HTTPBasicCredentials) -> str:
305
311
... basic_auth_user = 'user'
306
312
... basic_auth_password = 'pass'
307
313
... auth_required = True
314
+ ... docs_allow_basic_auth = False
308
315
>>> vc.settings = DummySettings()
309
316
>>> from fastapi.security import HTTPBasicCredentials
310
317
>>> creds = HTTPBasicCredentials(username='user', password='pass')
@@ -354,6 +361,7 @@ async def require_basic_auth(credentials: HTTPBasicCredentials = Depends(basic_s
354
361
... basic_auth_user = 'user'
355
362
... basic_auth_password = 'pass'
356
363
... auth_required = True
364
+ ... docs_allow_basic_auth = False
357
365
>>> vc.settings = DummySettings()
358
366
>>> from fastapi.security import HTTPBasicCredentials
359
367
>>> import asyncio
@@ -387,6 +395,103 @@ async def require_basic_auth(credentials: HTTPBasicCredentials = Depends(basic_s
387
395
return "anonymous"
388
396
389
397
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
+
390
495
async def require_auth_override (
391
496
auth_header : str | None = None ,
392
497
jwt_token : str | None = None ,
@@ -407,6 +512,10 @@ async def require_auth_override(
407
512
str | dict: The decoded JWT payload or the string "anonymous",
408
513
same as require_auth.
409
514
515
+ Raises:
516
+ HTTPException: If authentication fails or credentials are invalid.
517
+ ValueError: If basic auth credentials are malformed.
518
+
410
519
Note:
411
520
This wrapper may propagate HTTPException raised by require_auth,
412
521
but it does not raise anything on its own.
@@ -420,6 +529,7 @@ async def require_auth_override(
420
529
... basic_auth_password = 'pass'
421
530
... auth_required = True
422
531
... require_token_expiration = False
532
+ ... docs_allow_basic_auth = False
423
533
>>> vc.settings = DummySettings()
424
534
>>> import jwt
425
535
>>> import asyncio
@@ -454,5 +564,7 @@ async def require_auth_override(
454
564
scheme , param = get_authorization_scheme_param (auth_header )
455
565
if scheme .lower () == "bearer" and param :
456
566
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 )
458
570
return await require_auth (credentials = credentials , jwt_token = jwt_token )
0 commit comments